Support for org-scoped ClickHouse (#3333)

Added `OrganizationDataStore` which allows orgs to have data stored in
specific separate services.

For now this is just used for ClickHouse. When using ClickHouse we get a
client for the factory and pass in the org id.

Particular care has to be made with two hot-insert paths:
1. RunReplicationService
2. OTLPExporter

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Matt Aitken
2026-05-22 14:43:03 +01:00
committed by GitHub
parent 832cf7220b
commit 71d98b4e6b
87 changed files with 2914 additions and 1055 deletions
+5 -1
View File
@@ -1,3 +1,7 @@
{
"mcpServers": {}
"mcpServers": {
"linear": {
"url": "https://mcp.linear.app/mcp"
}
}
}
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---
Organization-scoped ClickHouse routing enables customers with HIPAA and other data security requirements to use dedicated database instances
+11
View File
@@ -69,6 +69,17 @@ containerTest("should use both", async ({ prisma, redisOptions }) => {
});
```
## Code Style
### Imports
**Prefer static imports over dynamic imports.** Only use dynamic `import()` when:
- Circular dependencies cannot be resolved otherwise
- Code splitting is genuinely needed for performance
- The module must be loaded conditionally at runtime
Dynamic imports add unnecessary overhead in hot paths and make code harder to analyze. If you find yourself using `await import()`, ask if a regular `import` statement would work instead.
## Changesets and Server Changes
When modifying any public package (`packages/*` or `integrations/*`), add a changeset:
+5 -37
View File
@@ -24,44 +24,12 @@ import {
registerRunEngineEventBusHandlers,
setupBatchQueueCallbacks,
} from "./v3/runEngineHandlers.server";
// Touch the sessions replication singleton at entry so it boots deterministically
// on webapp startup. The singleton's initializer wires start (gated on
// `clickhouseFactory.isReady()`) and SIGTERM/SIGINT shutdown — mirrors
// runsReplicationInstance.
import { sessionsReplicationInstance } from "./services/sessionsReplicationInstance.server";
import { signalsEmitter } from "./services/signals.server";
// Start the sessions replication service (subscribes to the logical replication
// slot, runs leader election, flushes to ClickHouse). Done at entry level so it
// runs deterministically on webapp boot rather than lazily via a singleton
// reference elsewhere in the module graph.
if (sessionsReplicationInstance && env.SESSION_REPLICATION_ENABLED === "1") {
// Capture a non-nullable reference so the shutdown closure below
// doesn't need to re-null-check (TS narrowing doesn't follow through
// an inner function scope).
const replicator = sessionsReplicationInstance;
replicator
.start()
.then(() => {
console.log("🗃️ Sessions replication service started");
})
.catch((error) => {
console.error("🗃️ Sessions replication service failed to start", {
error,
});
});
// Wrap the async shutdown in a sync handler that catches rejections —
// SIGTERM/SIGINT fire during process teardown, and an unhandled
// promise rejection from `_replicationClient.stop()` there would
// bubble up past the process exit. Matches the pattern in
// dynamicFlushScheduler.server.ts.
const shutdownSessionsReplication = () => {
replicator.shutdown().catch((error) => {
console.error("🗃️ Sessions replication service shutdown error", {
error,
});
});
};
signalsEmitter.on("SIGTERM", shutdownSessionsReplication);
signalsEmitter.on("SIGINT", shutdownSessionsReplication);
}
void sessionsReplicationInstance;
const ABORT_DELAY = 30000;
+14 -2
View File
@@ -459,7 +459,10 @@ const EnvironmentSchema = z
// If specified, you must configure the corresponding provider using OBJECT_STORE_{PROTOCOL}_* env vars.
// Example: OBJECT_STORE_DEFAULT_PROTOCOL=s3 requires OBJECT_STORE_S3_BASE_URL, OBJECT_STORE_S3_ACCESS_KEY_ID, etc.
// Enables zero-downtime migration between providers (old data keeps working, new data uses new provider).
OBJECT_STORE_DEFAULT_PROTOCOL: z.string().regex(/^[a-z0-9]+$/).optional(),
OBJECT_STORE_DEFAULT_PROTOCOL: z
.string()
.regex(/^[a-z0-9]+$/)
.optional(),
ARTIFACTS_OBJECT_STORE_BUCKET: z.string().optional(),
ARTIFACTS_OBJECT_STORE_BASE_URL: z.string().optional(),
@@ -1489,9 +1492,18 @@ const EnvironmentSchema = z
EVENTS_CLICKHOUSE_MAX_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(5_000),
EVENTS_CLICKHOUSE_MAX_LIVE_RELOADING_SETTING: z.coerce.number().int().default(2000),
// Organization data stores registry
ORGANIZATION_DATA_STORES_RELOAD_INTERVAL_MS: z.coerce
.number()
.int()
.default(60 * 1000), // 1 minute
// LLM cost tracking
LLM_COST_TRACKING_ENABLED: BoolEnv.default(true),
LLM_PRICING_RELOAD_INTERVAL_MS: z.coerce.number().int().default(5 * 60 * 1000), // 5 minutes
LLM_PRICING_RELOAD_INTERVAL_MS: z.coerce
.number()
.int()
.default(5 * 60 * 1000), // 5 minutes
LLM_PRICING_RELOAD_CHANNEL: z.string().default("llm-registry:reload"),
LLM_PRICING_RELOAD_DEBOUNCE_MS: z.coerce.number().int().default(1000),
// Whether to subscribe this process to the LLM_PRICING_RELOAD_CHANNEL.
@@ -3,10 +3,10 @@ import {
type RuntimeEnvironmentType,
type TaskTriggerSource,
} from "@trigger.dev/database";
import { ClickHouse } from "@internal/clickhouse";
import { type ClickHouse } from "@internal/clickhouse";
import { z } from "zod";
import { $replica } from "~/db.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { singleton } from "~/utils/singleton";
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
@@ -24,10 +24,7 @@ export type AgentActiveState = {
};
export class AgentListPresenter {
constructor(
private readonly clickhouse: ClickHouse,
private readonly _replica: PrismaClientOrTransaction
) {}
constructor(private readonly _replica: PrismaClientOrTransaction) {}
public async call({
organizationId,
@@ -40,6 +37,11 @@ export class AgentListPresenter {
environmentId: string;
environmentType: RuntimeEnvironmentType;
}) {
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
organizationId,
"standard"
);
const currentWorker = await findCurrentWorkerFromEnvironment(
{
id: environmentId,
@@ -89,20 +91,21 @@ export class AgentListPresenter {
}
// All queries are deferred for streaming
const activeStates = this.#getActiveStates(environmentId, slugs);
const conversationSparklines = this.#getConversationSparklines(environmentId, slugs);
const costSparklines = this.#getCostSparklines(environmentId, slugs);
const tokenSparklines = this.#getTokenSparklines(environmentId, slugs);
const activeStates = this.#getActiveStates(clickhouse, environmentId, slugs);
const conversationSparklines = this.#getConversationSparklines(clickhouse, environmentId, slugs);
const costSparklines = this.#getCostSparklines(clickhouse, environmentId, slugs);
const tokenSparklines = this.#getTokenSparklines(clickhouse, environmentId, slugs);
return { agents, activeStates, conversationSparklines, costSparklines, tokenSparklines };
}
/** Count runs currently executing vs suspended per agent */
async #getActiveStates(
clickhouse: ClickHouse,
environmentId: string,
slugs: string[]
): Promise<Record<string, AgentActiveState>> {
const queryFn = this.clickhouse.reader.query({
const queryFn = clickhouse.reader.query({
name: "agentActiveStates",
query: `SELECT
task_identifier,
@@ -140,10 +143,11 @@ export class AgentListPresenter {
/** 24h hourly sparkline of conversation (run) count per agent */
async #getConversationSparklines(
clickhouse: ClickHouse,
environmentId: string,
slugs: string[]
): Promise<Record<string, number[]>> {
const queryFn = this.clickhouse.reader.query({
const queryFn = clickhouse.reader.query({
name: "agentConversationSparklines",
query: `SELECT
task_identifier,
@@ -172,10 +176,11 @@ export class AgentListPresenter {
/** 24h hourly sparkline of LLM cost per agent */
async #getCostSparklines(
clickhouse: ClickHouse,
environmentId: string,
slugs: string[]
): Promise<Record<string, number[]>> {
const queryFn = this.clickhouse.reader.query({
const queryFn = clickhouse.reader.query({
name: "agentCostSparklines",
query: `SELECT
task_identifier,
@@ -203,10 +208,11 @@ export class AgentListPresenter {
/** 24h hourly sparkline of total tokens per agent */
async #getTokenSparklines(
clickhouse: ClickHouse,
environmentId: string,
slugs: string[]
): Promise<Record<string, number[]>> {
const queryFn = this.clickhouse.reader.query({
const queryFn = clickhouse.reader.query({
name: "agentTokenSparklines",
query: `SELECT
task_identifier,
@@ -284,5 +290,5 @@ export class AgentListPresenter {
export const agentListPresenter = singleton("agentListPresenter", setupAgentListPresenter);
function setupAgentListPresenter() {
return new AgentListPresenter(clickhouseClient, $replica);
return new AgentListPresenter($replica);
}
@@ -9,7 +9,7 @@ import { type Project, type RuntimeEnvironment, type TaskRunStatus } from "@trig
import assertNever from "assert-never";
import { z } from "zod";
import { API_VERSIONS, RunStatusUnspecifiedApiVersion } from "~/api/versions";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { logger } from "~/services/logger.server";
import { CoercedDate } from "~/utils/zod";
import { ServiceValidationError } from "~/v3/services/baseService.server";
@@ -269,7 +269,8 @@ export class ApiRunListPresenter extends BasePresenter {
options.machines = searchParams["filter[machine]"];
}
const presenter = new NextRunListPresenter(this._replica, clickhouseClient);
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(organizationId, "standard");
const presenter = new NextRunListPresenter(this._replica, clickhouse);
logger.debug("Calling RunListPresenter", { options });
@@ -1,6 +1,6 @@
import { type PrismaClient } from "@trigger.dev/database";
import { CreateBulkActionSearchParams } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
import { getRunFiltersFromRequest } from "../RunFilters.server";
import { BasePresenter } from "./basePresenter.server";
@@ -24,8 +24,9 @@ export class CreateBulkActionPresenter extends BasePresenter {
Object.fromEntries(new URL(request.url).searchParams)
);
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(organizationId, "standard");
const runsRepository = new RunsRepository({
clickhouse: clickhouseClient,
clickhouse,
prisma: this._replica as PrismaClient,
});
@@ -3,11 +3,11 @@ import { createTreeFromFlatItems, flattenTree } from "~/components/primitives/Tr
import { prisma, type PrismaClient } from "~/db.server";
import { createTimelineSpanEventsFromSpanEvents } from "~/utils/timelineSpanEvents";
import { getUsername } from "~/utils/username";
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
import { SpanSummary } from "~/v3/eventRepository/eventRepository.types";
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
import { isFinalRunStatus } from "~/v3/taskStatus";
import { env } from "~/env.server";
import { getEventRepositoryForStore } from "~/v3/eventRepository/index.server";
type Result = Awaited<ReturnType<RunPresenter["call"]>>;
export type Run = Result["run"];
@@ -145,10 +145,13 @@ export class RunPresenter {
};
}
const eventRepository = resolveEventRepositoryForStore(run.taskEventStore);
const repository = await getEventRepositoryForStore(
run.taskEventStore,
run.runtimeEnvironment.organizationId
);
// get the events
let traceSummary = await eventRepository.getTraceSummary(
let traceSummary = await repository.getTraceSummary(
getTaskEventStoreTableForRun(run),
run.runtimeEnvironment.id,
run.traceId,
@@ -272,7 +275,7 @@ export class RunPresenter {
overridesBySpanId: traceSummary.overridesBySpanId,
linkedRunIdBySpanId,
},
maximumLiveReloadingSetting: eventRepository.maximumLiveReloadingSetting,
maximumLiveReloadingSetting: repository.maximumLiveReloadingSetting,
};
}
}
@@ -1,6 +1,6 @@
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
import { BasePresenter } from "./basePresenter.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { type PrismaClient } from "@trigger.dev/database";
import { timeFilters } from "~/components/runs/v3/SharedFilters";
@@ -37,8 +37,9 @@ export class RunTagListPresenter extends BasePresenter {
}: TagListOptions) {
const hasFilters = Boolean(name?.trim());
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(organizationId, "standard");
const runsRepository = new RunsRepository({
clickhouse: clickhouseClient,
clickhouse,
prisma: this._replica as PrismaClient,
});
@@ -23,7 +23,6 @@ import { isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus";
import { BasePresenter } from "./basePresenter.server";
import { WaitpointPresenter } from "./WaitpointPresenter.server";
import { engine } from "~/v3/runEngine.server";
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
import { IEventRepository, SpanDetail } from "~/v3/eventRepository/eventRepository.types";
import { safeJsonParse } from "~/utils/json";
import {
@@ -32,6 +31,7 @@ import {
extractAIToolCallData,
extractAIEmbedData,
} from "~/components/runs/v3/ai";
import { getEventRepositoryForStore } from "~/v3/eventRepository/index.server";
export type PromptSpanData = {
slug: string;
@@ -132,14 +132,17 @@ export class SpanPresenter extends BasePresenter {
const { traceId } = parentRun;
const eventRepository = resolveEventRepositoryForStore(parentRun.taskEventStore);
const repository = await getEventRepositoryForStore(
parentRun.taskEventStore,
project.organizationId
);
const eventStore = getTaskEventStoreTableForRun(parentRun);
const run = await this.getRun({
eventStore,
traceId,
eventRepository,
eventRepository: repository,
spanId,
linkedRunId,
createdAt: parentRun.createdAt,
@@ -161,7 +164,7 @@ export class SpanPresenter extends BasePresenter {
projectId: parentRun.projectId,
createdAt: parentRun.createdAt,
completedAt: parentRun.completedAt,
eventRepository,
eventRepository: repository,
});
if (!span) {
@@ -4,7 +4,7 @@ import {
type TaskTriggerSource,
} from "@trigger.dev/database";
import { $replica } from "~/db.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import {
type AverageDurations,
ClickHouseEnvironmentMetricsRepository,
@@ -25,10 +25,7 @@ export type TaskListItem = {
export type TaskActivity = DailyTaskActivity[string];
export class TaskListPresenter {
constructor(
private readonly environmentMetricsRepository: EnvironmentMetricsRepository,
private readonly _replica: PrismaClientOrTransaction
) {}
constructor(private readonly _replica: PrismaClientOrTransaction) {}
public async call({
organizationId,
@@ -77,9 +74,15 @@ export class TaskListPresenter {
const slugs = tasks.map((t) => t.slug);
// Create org-specific environment metrics repository
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(organizationId, "standard");
const environmentMetricsRepository = new ClickHouseEnvironmentMetricsRepository({
clickhouse,
});
// IMPORTANT: Don't await these, we want to return the promises
// so we can defer the loading of the data
const activity = this.environmentMetricsRepository.getDailyTaskActivity({
const activity = environmentMetricsRepository.getDailyTaskActivity({
organizationId,
projectId,
environmentId,
@@ -87,7 +90,7 @@ export class TaskListPresenter {
tasks: slugs,
});
const runningStats = this.environmentMetricsRepository.getCurrentRunningStats({
const runningStats = environmentMetricsRepository.getCurrentRunningStats({
organizationId,
projectId,
environmentId,
@@ -95,7 +98,7 @@ export class TaskListPresenter {
tasks: slugs,
});
const durations = this.environmentMetricsRepository.getAverageDurations({
const durations = environmentMetricsRepository.getAverageDurations({
organizationId,
projectId,
environmentId,
@@ -110,9 +113,5 @@ export class TaskListPresenter {
export const taskListPresenter = singleton("taskListPresenter", setupTaskListPresenter);
function setupTaskListPresenter() {
const environmentMetricsRepository = new ClickHouseEnvironmentMetricsRepository({
clickhouse: clickhouseClient,
});
return new TaskListPresenter(environmentMetricsRepository, $replica);
return new TaskListPresenter($replica);
}
@@ -4,7 +4,7 @@ import { getUsage, getUsageSeries } from "~/services/platform.v3.server";
import { createTimeSeriesData } from "~/utils/graphs";
import { BasePresenter } from "./basePresenter.server";
import { DataPoint, linear } from "regression";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
type Options = {
organizationId: string;
@@ -124,7 +124,8 @@ async function getTaskUsageByOrganization(
endOfMonth: Date,
replica: PrismaClientOrTransaction
) {
const [queryError, tasks] = await clickhouseClient.taskRuns.getTaskUsageByOrganization({
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(organizationId, "standard");
const [queryError, tasks] = await clickhouse.taskRuns.getTaskUsageByOrganization({
startTime: startOfMonth.getTime(),
endTime: endOfMonth.getTime(),
organizationId,
@@ -1,7 +1,7 @@
import { ScheduleObject } from "@trigger.dev/core/v3";
import { PrismaClient, prisma } from "~/db.server";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { nextScheduledTimestamps } from "~/v3/utils/calculateNextSchedule.server";
import { NextRunListPresenter } from "./NextRunListPresenter.server";
import { scheduleWhereClause } from "~/models/schedules.server";
@@ -75,7 +75,8 @@ export class ViewSchedulePresenter {
? nextScheduledTimestamps(schedule.generatorExpression, schedule.timezone, new Date(), 5)
: [];
const runPresenter = new NextRunListPresenter(this.#prismaClient, clickhouseClient);
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(schedule.project.organizationId, "standard");
const runPresenter = new NextRunListPresenter(this.#prismaClient, clickhouse);
const { runs } = await runPresenter.call(schedule.project.organizationId, environmentId, {
projectId: schedule.project.id,
scheduleId: schedule.id,
@@ -1,5 +1,5 @@
import { isWaitpointOutputTimeout, prettyPrintPacket } from "@trigger.dev/core/v3";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { generateHttpCallbackUrl } from "~/services/httpCallback.server";
import { logger } from "~/services/logger.server";
import { BasePresenter } from "./basePresenter.server";
@@ -79,7 +79,8 @@ export class WaitpointPresenter extends BasePresenter {
const connectedRuns: NextRunListItem[] = [];
if (connectedRunIds.length > 0) {
const runPresenter = new NextRunListPresenter(this._prisma, clickhouseClient);
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(waitpoint.environment.organizationId, "standard");
const runPresenter = new NextRunListPresenter(this._prisma, clickhouse);
const { runs } = await runPresenter.call(
waitpoint.environment.organizationId,
environmentId,
@@ -34,7 +34,7 @@ import {
MetricDashboardPresenter,
} from "~/presenters/v3/MetricDashboardPresenter.server";
import { PromptPresenter } from "~/presenters/v3/PromptPresenter.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { requireUser } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
@@ -77,10 +77,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const filters = dashboard.filters ?? ["tasks", "queues"];
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(project.organizationId, "standard");
// Load distinct models from ClickHouse if the dashboard has a models filter
let possibleModels: { model: string; system: string }[] = [];
if (filters.includes("models")) {
const queryFn = clickhouseClient.reader.query({
const queryFn = clickhouse.reader.query({
name: "getDistinctModels",
query: `SELECT response_model, any(gen_ai_system) AS gen_ai_system FROM trigger_dev.llm_metrics_v1 WHERE organization_id = {organizationId: String} AND project_id = {projectId: String} AND environment_id = {environmentId: String} AND response_model != '' GROUP BY response_model ORDER BY response_model`,
params: z.object({
@@ -100,7 +102,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
}
}
const promptPresenter = new PromptPresenter(clickhouseClient);
const promptPresenter = new PromptPresenter(clickhouse);
const [possiblePrompts, possibleOperations, possibleProviders] = await Promise.all([
filters.includes("prompts")
? promptPresenter.getDistinctPromptSlugs(project.organizationId, project.id, environment.id)
@@ -1,56 +1,16 @@
import { type LoaderFunctionArgs, type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { type MetaFunction, useFetcher, useRevalidator } from "@remix-run/react";
import { parse } from "@conform-to/zod";
import { BellAlertIcon } from "@heroicons/react/20/solid";
import { type MetaFunction, useFetcher, useRevalidator } from "@remix-run/react";
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import {
IconAlarmSnooze as IconAlarmSnoozeBase,
IconBugFilled,
IconCircleDotted,
} from "@tabler/icons-react";
import { parse } from "@conform-to/zod";
import { z } from "zod";
import { ErrorStatusBadge } from "~/components/errors/ErrorStatusBadge";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
import { requireUser, requireUserId } from "~/services/session.server";
import {
EnvironmentParamSchema,
v3CreateBulkActionPath,
v3ErrorsPath,
v3RunsPath,
} from "~/utils/pathBuilder";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import {
ErrorGroupPresenter,
type ErrorGroupActivity,
type ErrorGroupActivityVersions,
type ErrorGroupOccurrences,
type ErrorGroupSummary,
type ErrorGroupState,
} from "~/presenters/v3/ErrorGroupPresenter.server";
import { type NextRunList } from "~/presenters/v3/NextRunListPresenter.server";
import { $replica } from "~/db.server";
import { logsClickhouseClient, clickhouseClient } from "~/services/clickhouseInstance.server";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { PageBody } from "~/components/layout/AppLayout";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import { ErrorId } from "@trigger.dev/core/v3/isomorphic";
import { isPast } from "date-fns";
import { AnimatePresence, motion } from "framer-motion";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { Spinner } from "~/components/primitives/Spinner";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Callout } from "~/components/primitives/Callout";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { formatDistanceToNow, isPast } from "date-fns";
import * as Property from "~/components/primitives/PropertyTable";
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
import { DateTime, RelativeDateTime } from "~/components/primitives/DateTime";
import { ErrorId } from "@trigger.dev/core/v3/isomorphic";
import {
Bar,
BarChart,
@@ -61,31 +21,68 @@ import {
XAxis,
YAxis,
} from "recharts";
import TooltipPortal from "~/components/primitives/TooltipPortal";
import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { DirectionSchema, ListPagination } from "~/components/ListPagination";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useEnvironment } from "~/hooks/useEnvironment";
import { RunsIcon } from "~/assets/icons/RunsIcon";
import type { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
import { useSearchParams } from "~/hooks/useSearchParam";
import { CopyableText } from "~/components/primitives/CopyableText";
import { cn } from "~/utils/cn";
import { LogsVersionFilter } from "~/components/logs/LogsVersionFilter";
import { CodeBlock } from "~/components/code/CodeBlock";
import { Popover, PopoverArrowTrigger, PopoverContent } from "~/components/primitives/Popover";
import { ErrorGroupActions } from "~/v3/services/errorGroupActions.server";
import { ErrorStatusBadge } from "~/components/errors/ErrorStatusBadge";
import {
ErrorStatusMenuItems,
CustomIgnoreDialog,
ErrorStatusMenuItems,
statusActionToastMessage,
} from "~/components/errors/ErrorStatusMenu";
import { PageBody } from "~/components/layout/AppLayout";
import { DirectionSchema, ListPagination } from "~/components/ListPagination";
import { LogsVersionFilter } from "~/components/logs/LogsVersionFilter";
import { LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { CopyableText } from "~/components/primitives/CopyableText";
import { DateTime, RelativeDateTime } from "~/components/primitives/DateTime";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Popover, PopoverArrowTrigger, PopoverContent } from "~/components/primitives/Popover";
import * as Property from "~/components/primitives/PropertyTable";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import { Spinner } from "~/components/primitives/Spinner";
import { useToast } from "~/components/primitives/Toast";
import TooltipPortal from "~/components/primitives/TooltipPortal";
import type { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
import { $replica } from "~/db.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import {
type ErrorGroupActivity,
type ErrorGroupActivityVersions,
type ErrorGroupOccurrences,
ErrorGroupPresenter,
type ErrorGroupState,
type ErrorGroupSummary,
} from "~/presenters/v3/ErrorGroupPresenter.server";
import { type NextRunList } from "~/presenters/v3/NextRunListPresenter.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { requireUser, requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import {
EnvironmentParamSchema,
v3CreateBulkActionPath,
v3ErrorsPath,
v3RunsPath,
} from "~/utils/pathBuilder";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { ErrorGroupActions } from "~/v3/services/errorGroupActions.server";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [
@@ -167,6 +164,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
let occurrenceCountAtIgnoreTime: number | undefined;
if (submission.value.totalOccurrences) {
const clickhouseClient = await clickhouseFactory.getClickhouseForOrganization(
environment.organizationId,
"query"
);
const qb = clickhouseClient.errors.listQueryBuilder();
qb.where("organization_id = {organizationId: String}", {
organizationId: project.organizationId,
@@ -240,6 +242,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const directionRaw = url.searchParams.get("direction") ?? undefined;
const direction = directionRaw ? DirectionSchema.parse(directionRaw) : undefined;
const [logsClickhouseClient, clickhouseClient] = await Promise.all([
clickhouseFactory.getClickhouseForOrganization(environment.organizationId, "logs"),
clickhouseFactory.getClickhouseForOrganization(environment.organizationId, "standard"),
]);
const presenter = new ErrorGroupPresenter($replica, logsClickhouseClient, clickhouseClient);
const detailPromise = presenter
@@ -71,7 +71,7 @@ import {
type ErrorOccurrences,
type ErrorsList as ErrorsListData,
} from "~/presenters/v3/ErrorsListPresenter.server";
import { logsClickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { getCurrentPlan } from "~/services/platform.v3.server";
import { requireUser } from "~/services/session.server";
import { formatNumberCompact } from "~/utils/numberFormatter";
@@ -124,6 +124,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const plan = await getCurrentPlan(project.organizationId);
const retentionLimitDays = plan?.v3Subscription?.plan?.limits.logRetentionDays.number ?? 30;
const logsClickhouseClient = await clickhouseFactory.getClickhouseForOrganization(
project.organizationId,
"logs"
);
const presenter = new ErrorsListPresenter($replica, logsClickhouseClient);
const listPromise = presenter
@@ -16,7 +16,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { LogsListPresenter, LogEntry } from "~/presenters/v3/LogsListPresenter.server";
import type { LogLevel } from "~/utils/logUtils";
import { $replica, prisma } from "~/db.server";
import { logsClickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
@@ -137,7 +137,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const plan = await getCurrentPlan(project.organizationId);
const retentionLimitDays = plan?.v3Subscription?.plan?.limits.logRetentionDays.number ?? 30;
const presenter = new LogsListPresenter($replica, logsClickhouseClient);
const logsClickhouse = await clickhouseFactory.getClickhouseForOrganization(project.organizationId, "logs");
const presenter = new LogsListPresenter($replica, logsClickhouse);
const listPromise = presenter
.call(project.organizationId, environment.id, {
@@ -28,7 +28,7 @@ import type { QueryWidgetConfig } from "~/components/metrics/QueryWidget";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { ModelRegistryPresenter } from "~/presenters/v3/ModelRegistryPresenter.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { requireUserId } from "~/services/session.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
@@ -68,7 +68,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
throw new Response("Environment not found", { status: 404 });
}
const presenter = new ModelRegistryPresenter(clickhouseClient);
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(project.organizationId, "standard");
const presenter = new ModelRegistryPresenter(clickhouse);
const model = await presenter.getModelDetail(modelId);
if (!model) {
@@ -73,7 +73,7 @@ import {
type PopularModel,
ModelRegistryPresenter,
} from "~/presenters/v3/ModelRegistryPresenter.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { requireUserId } from "~/services/session.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
@@ -112,7 +112,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
throw new Response("Environment not found", { status: 404 });
}
const presenter = new ModelRegistryPresenter(clickhouseClient);
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(project.organizationId, "standard");
const presenter = new ModelRegistryPresenter(clickhouse);
const catalog = await presenter.getModelCatalog();
const now = new Date();
@@ -20,7 +20,7 @@ import {
type ModelComparisonItem,
ModelRegistryPresenter,
} from "~/presenters/v3/ModelRegistryPresenter.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { requireUserId } from "~/services/session.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
@@ -55,7 +55,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
return typedjson({ comparison: [] as ModelComparisonItem[], models: responseModels });
}
const presenter = new ModelRegistryPresenter(clickhouseClient);
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(project.organizationId, "standard");
const presenter = new ModelRegistryPresenter(clickhouse);
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
@@ -70,7 +70,7 @@ import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { type GenerationRow, PromptPresenter } from "~/presenters/v3/PromptPresenter.server";
import { SpanView } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { getResizableSnapshot } from "~/services/resizablePanel.server";
import { requireUserId } from "~/services/session.server";
import { PromptService } from "~/v3/services/promptService.server";
@@ -242,7 +242,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const startTime = fromTime ? new Date(fromTime) : new Date(Date.now() - periodMs);
const endTime = toTime ? new Date(toTime) : new Date();
const presenter = new PromptPresenter(clickhouseClient);
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(project.organizationId, "standard");
const presenter = new PromptPresenter(clickhouse);
let generations: Awaited<ReturnType<typeof presenter.listGenerations>>["generations"] = [];
let generationsPagination: { next?: string } = {};
try {
@@ -273,7 +274,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
// Load distinct filter values and resizable snapshots in parallel
const distinctQuery = (col: string, name: string) =>
clickhouseClient.reader.query({
clickhouse.reader.query({
name,
query: `SELECT DISTINCT ${col} AS val FROM trigger_dev.llm_metrics_v1 WHERE environment_id = {environmentId: String} AND prompt_slug = {promptSlug: String} AND ${col} != '' ORDER BY val`,
params: z.object({ environmentId: z.string(), promptSlug: z.string() }),
@@ -22,7 +22,7 @@ import { useProject } from "~/hooks/useProject";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { PromptPresenter } from "~/presenters/v3/PromptPresenter.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { requireUserId } from "~/services/session.server";
import { docsPath, EnvironmentParamSchema, v3PromptsPath } from "~/utils/pathBuilder";
import { LinkButton } from "~/components/primitives/Buttons";
@@ -46,7 +46,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
throw new Response("Environment not found", { status: 404 });
}
const presenter = new PromptPresenter(clickhouseClient);
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(project.organizationId, "standard");
const presenter = new PromptPresenter(clickhouse);
const prompts = await presenter.listPrompts(project.id, environment.id);
const sparklines = await presenter.getUsageSparklines(
@@ -92,7 +92,7 @@ import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
import { RunEnvironmentMismatchError, RunPresenter } from "~/presenters/v3/RunPresenter.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { getImpersonationId } from "~/services/impersonation.server";
import { logger } from "~/services/logger.server";
import { getResizableSnapshot } from "~/services/resizablePanel.server";
@@ -182,7 +182,8 @@ async function getRunsListFromTableState({
return null;
}
const runsListPresenter = new NextRunListPresenter($replica, clickhouseClient);
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(project.organizationId, "standard");
const runsListPresenter = new NextRunListPresenter($replica, clickhouse);
const currentPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
@@ -45,7 +45,7 @@ import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server";
import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import {
setRootOnlyFilterPreference,
uiPreferencesStorage,
@@ -89,7 +89,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const filters = await getRunFiltersFromRequest(request);
const presenter = new NextRunListPresenter($replica, clickhouseClient);
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(project.organizationId, "standard");
const presenter = new NextRunListPresenter($replica, clickhouse);
const list = presenter.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
@@ -16,7 +16,7 @@ import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { getSessionFiltersFromRequest } from "~/presenters/SessionFilters.server";
import { SessionListPresenter } from "~/presenters/v3/SessionListPresenter.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { requireUserId } from "~/services/session.server";
import { docsPath, EnvironmentParamSchema } from "~/utils/pathBuilder";
import { throwNotFound } from "~/utils/httpErrors";
@@ -45,7 +45,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const filters = getSessionFiltersFromRequest(request);
const presenter = new SessionListPresenter($replica, clickhouseClient);
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
project.organizationId,
"standard"
);
const presenter = new SessionListPresenter($replica, clickhouse);
const list = await presenter.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
@@ -74,7 +74,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components
import { DialogClose, DialogDescription } from "@radix-ui/react-dialog";
import { FormButtons } from "~/components/primitives/FormButtons";
import { $replica } from "~/db.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { RegionsPresenter, type Region } from "~/presenters/v3/RegionsPresenter.server";
import { TestSidebarTabs } from "./TestSidebarTabs";
import { AIPayloadTabContent } from "./AIPayloadTabContent";
@@ -102,8 +102,13 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
});
}
const presenter = new TestTaskPresenter($replica, clickhouseClient);
try {
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
project.organizationId,
"standard"
);
const presenter = new TestTaskPresenter($replica, clickhouse);
const [result, regionsResult] = await Promise.all([
presenter.call({
userId: user.id,
@@ -1,8 +1,8 @@
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
import { z } from "zod";
import { ClickHouse } from "@internal/clickhouse";
import { env } from "~/env.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { RunsReplicationService } from "~/services/runsReplicationService.server";
import {
getRunsReplicationGlobal,
@@ -42,6 +42,8 @@ export async function action({ request }: ActionFunctionArgs) {
const params = CreateRunReplicationServiceParams.parse(await request.json());
await clickhouseFactory.isReady();
const service = createRunReplicationService(params);
setRunsReplicationGlobal(service);
@@ -57,24 +59,23 @@ export async function action({ request }: ActionFunctionArgs) {
}
function createRunReplicationService(params: CreateRunReplicationServiceParams) {
const clickhouse = new ClickHouse({
url: env.RUN_REPLICATION_CLICKHOUSE_URL,
name: params.name,
keepAlive: {
enabled: params.keepAliveEnabled,
idleSocketTtl: params.keepAliveIdleSocketTtl,
},
logLevel: "debug",
compression: {
request: true,
},
maxOpenConnections: params.maxOpenConnections,
});
const {
name,
maxFlushConcurrency,
flushIntervalMs,
flushBatchSize,
leaderLockTimeoutMs,
leaderLockExtendIntervalMs,
leaderLockAcquireAdditionalTimeMs,
leaderLockRetryIntervalMs,
ackIntervalSeconds,
waitForAsyncInsert,
} = params;
const service = new RunsReplicationService({
clickhouse: clickhouse,
clickhouseFactory,
pgConnectionUrl: env.DATABASE_URL,
serviceName: params.name,
serviceName: name,
slotName: env.RUN_REPLICATION_SLOT_NAME,
publicationName: env.RUN_REPLICATION_PUBLICATION_NAME,
redisOptions: {
@@ -86,16 +87,16 @@ function createRunReplicationService(params: CreateRunReplicationServiceParams)
enableAutoPipelining: true,
...(env.RUN_REPLICATION_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
},
maxFlushConcurrency: params.maxFlushConcurrency,
flushIntervalMs: params.flushIntervalMs,
flushBatchSize: params.flushBatchSize,
leaderLockTimeoutMs: params.leaderLockTimeoutMs,
leaderLockExtendIntervalMs: params.leaderLockExtendIntervalMs,
leaderLockAcquireAdditionalTimeMs: params.leaderLockAcquireAdditionalTimeMs,
leaderLockRetryIntervalMs: params.leaderLockRetryIntervalMs,
ackIntervalSeconds: params.ackIntervalSeconds,
maxFlushConcurrency,
flushIntervalMs,
flushBatchSize,
leaderLockTimeoutMs,
leaderLockExtendIntervalMs,
leaderLockAcquireAdditionalTimeMs,
leaderLockRetryIntervalMs,
ackIntervalSeconds,
logLevel: "debug",
waitForAsyncInsert: params.waitForAsyncInsert,
waitForAsyncInsert,
});
return service;
@@ -1,5 +1,6 @@
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { getRunsReplicationGlobal } from "~/services/runsReplicationGlobal.server";
import { runsReplicationInstance } from "~/services/runsReplicationInstance.server";
@@ -9,6 +10,8 @@ export async function action({ request }: ActionFunctionArgs) {
try {
const globalService = getRunsReplicationGlobal();
await clickhouseFactory.isReady();
if (globalService) {
await globalService.start();
} else {
@@ -0,0 +1,481 @@
import { useState } from "react";
import { useFetcher } from "@remix-run/react";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { Button } from "~/components/primitives/Buttons";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/primitives/Dialog";
import { Input } from "~/components/primitives/Input";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { prisma } from "~/db.server";
import { requireUser } from "~/services/session.server";
import { ClickhouseConnectionSchema } from "~/services/clickhouse/clickhouseSecretSchemas.server";
import { organizationDataStoresRegistry } from "~/services/dataStores/organizationDataStoresRegistryInstance.server";
import { tryCatch } from "@trigger.dev/core/utils";
// ---------------------------------------------------------------------------
// Loader
// ---------------------------------------------------------------------------
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireUser(request);
if (!user.admin) throw redirect("/");
const dataStores = await prisma.organizationDataStore.findMany({
orderBy: { createdAt: "desc" },
});
return typedjson({ dataStores });
};
// ---------------------------------------------------------------------------
// Action
// ---------------------------------------------------------------------------
const AddSchema = z.object({
_action: z.literal("add"),
key: z.string().min(1),
organizationIds: z.string().min(1),
connectionUrl: z.string().url(),
});
const UpdateSchema = z.object({
_action: z.literal("update"),
key: z.string().min(1),
organizationIds: z.string().min(1),
connectionUrl: z.string().url().optional(),
});
const DeleteSchema = z.object({
_action: z.literal("delete"),
key: z.string().min(1),
});
const FormSchema = z.discriminatedUnion("_action", [AddSchema, UpdateSchema, DeleteSchema]);
export async function action({ request }: ActionFunctionArgs) {
const user = await requireUser(request);
if (!user.admin) throw redirect("/");
const formData = await request.formData();
const result = FormSchema.safeParse(Object.fromEntries(formData));
if (!result.success) {
return typedjson(
{ error: result.error.issues.map((i) => i.message).join(", ") },
{ status: 400 }
);
}
switch (result.data._action) {
case "add": {
const { key, organizationIds: rawOrgIds, connectionUrl } = result.data;
const organizationIds = rawOrgIds
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const parsedConfig = ClickhouseConnectionSchema.safeParse({ url: connectionUrl });
if (!parsedConfig.success) {
return typedjson(
{ error: parsedConfig.error.issues.map((i) => i.message).join(", ") },
{ status: 400 }
);
}
const [error, _] = await tryCatch(
organizationDataStoresRegistry.addDataStore({
key,
kind: "CLICKHOUSE",
organizationIds,
config: parsedConfig.data,
})
);
if (error) {
return typedjson({ error: error.message }, { status: 400 });
}
return typedjson({ success: true });
}
case "update": {
const { key, organizationIds: rawOrgIds, connectionUrl } = result.data;
const organizationIds = rawOrgIds
.split(",")
.map((s) => s.trim())
.filter(Boolean);
let config: ReturnType<typeof ClickhouseConnectionSchema.parse> | undefined;
if (connectionUrl) {
const parsedConfig = ClickhouseConnectionSchema.safeParse({ url: connectionUrl });
if (!parsedConfig.success) {
return typedjson(
{ error: parsedConfig.error.issues.map((i) => i.message).join(", ") },
{ status: 400 }
);
}
config = parsedConfig.data;
}
const [error, _] = await tryCatch(
organizationDataStoresRegistry.updateDataStore({
key,
kind: "CLICKHOUSE",
organizationIds,
config,
})
);
if (error) {
return typedjson({ error: error.message }, { status: 400 });
}
return typedjson({ success: true });
}
case "delete": {
const { key } = result.data;
const [error, _] = await tryCatch(
organizationDataStoresRegistry.deleteDataStore({
key,
kind: "CLICKHOUSE",
})
);
if (error) {
return typedjson({ error: error.message }, { status: 400 });
}
return typedjson({ success: true });
}
default: {
return typedjson({ error: "Unknown action" }, { status: 400 });
}
}
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function AdminDataStoresRoute() {
const { dataStores } = useTypedLoaderData<typeof loader>();
const [addOpen, setAddOpen] = useState(false);
return (
<main className="flex h-full min-w-0 flex-1 flex-col overflow-y-auto px-4 pb-4">
<div className="space-y-4">
<div className="flex items-center justify-between">
<Paragraph variant="small" className="text-text-dimmed">
{dataStores.length} data store{dataStores.length !== 1 ? "s" : ""}
</Paragraph>
<Button variant="primary/small" onClick={() => setAddOpen(true)}>
Add data store
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Key</TableHeaderCell>
<TableHeaderCell>Kind</TableHeaderCell>
<TableHeaderCell>Organizations</TableHeaderCell>
<TableHeaderCell>Created</TableHeaderCell>
<TableHeaderCell>Updated</TableHeaderCell>
<TableHeaderCell>
<span className="sr-only">Actions</span>
</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{dataStores.length === 0 ? (
<TableBlankRow colSpan={6}>
<Paragraph>No data stores configured</Paragraph>
</TableBlankRow>
) : (
dataStores.map((ds) => (
<TableRow key={ds.id}>
<TableCell>
<span className="font-mono text-xs text-text-bright">{ds.key}</span>
</TableCell>
<TableCell>
<span className="inline-flex rounded-sm bg-indigo-500/20 px-1.5 py-0.5 text-[11px] font-medium text-indigo-400">
{ds.kind}
</span>
</TableCell>
<TableCell>
<span className="text-xs text-text-dimmed">
{ds.organizationIds.length} org{ds.organizationIds.length !== 1 ? "s" : ""}
</span>
{ds.organizationIds.length > 0 && (
<span
className="ml-1 text-xs text-text-dimmed"
title={ds.organizationIds.join(", ")}
>
({ds.organizationIds.slice(0, 2).join(", ")}
{ds.organizationIds.length > 2
? ` +${ds.organizationIds.length - 2} more`
: ""}
)
</span>
)}
</TableCell>
<TableCell>
<span className="text-xs text-text-dimmed">
{new Date(ds.createdAt).toLocaleString()}
</span>
</TableCell>
<TableCell>
<span className="text-xs text-text-dimmed">
{new Date(ds.updatedAt).toLocaleString()}
</span>
</TableCell>
<TableCell isSticky>
<div className="flex items-center gap-1">
<EditButton name={ds.key} organizationIds={ds.organizationIds} />
<DeleteButton name={ds.key} />
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<AddDataStoreDialog open={addOpen} onOpenChange={setAddOpen} />
</main>
);
}
// ---------------------------------------------------------------------------
// Delete button with popover confirmation
// ---------------------------------------------------------------------------
function DeleteButton({ name }: { name: string }) {
const [open, setOpen] = useState(false);
const fetcher = useFetcher<{ success?: boolean; error?: string }>();
const isDeleting = fetcher.state !== "idle";
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="danger/small" disabled={isDeleting}>
{isDeleting ? "Deleting…" : "Delete"}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72 space-y-3">
<Paragraph variant="small" className="text-text-bright">
Delete <span className="font-mono font-medium">{name}</span>?
</Paragraph>
<Paragraph variant="extra-small" className="text-text-dimmed">
This will remove the data store and its secret. Organizations using it will fall back to
the default ClickHouse instance.
</Paragraph>
<div className="flex items-center justify-end gap-2">
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
Cancel
</Button>
<fetcher.Form method="post" onSubmit={() => setOpen(false)}>
<input type="hidden" name="_action" value="delete" />
<input type="hidden" name="key" value={name} />
<Button type="submit" variant="danger/small">
Confirm delete
</Button>
</fetcher.Form>
</div>
</PopoverContent>
</Popover>
);
}
// ---------------------------------------------------------------------------
// Edit button with dialog
// ---------------------------------------------------------------------------
function EditButton({ name, organizationIds }: { name: string; organizationIds: string[] }) {
const [open, setOpen] = useState(false);
const fetcher = useFetcher<{ success?: boolean; error?: string }>();
const isSubmitting = fetcher.state !== "idle";
if (fetcher.data?.success && open) {
setOpen(false);
}
return (
<>
<Button variant="secondary/small" onClick={() => setOpen(true)}>
Edit
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Edit data store</DialogTitle>
</DialogHeader>
<fetcher.Form method="post" className="space-y-4 pt-2">
<input type="hidden" name="_action" value="update" />
<input type="hidden" name="key" value={name} />
<div className="space-y-1.5">
<label className="text-xs font-medium text-text-dimmed">Key</label>
<Input name="_key_display" value={name} readOnly variant="medium" className="font-mono opacity-60" />
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-text-dimmed">
Organization IDs <span className="text-rose-400">*</span>
</label>
<Input
name="organizationIds"
defaultValue={organizationIds.join(", ")}
placeholder="clxxxxx, clyyyyy, clzzzzz"
variant="medium"
required
/>
<p className="text-[11px] text-text-dimmed">Comma-separated organization IDs.</p>
</div>
{fetcher.data?.error && <p className="text-xs text-rose-400">{fetcher.data.error}</p>}
<DialogFooter>
<Button variant="tertiary/small" type="button" onClick={() => setOpen(false)} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" variant="primary/small" disabled={isSubmitting}>
{isSubmitting ? "Saving…" : "Save"}
</Button>
</DialogFooter>
</fetcher.Form>
</DialogContent>
</Dialog>
</>
);
}
// ---------------------------------------------------------------------------
// Add data store dialog
// ---------------------------------------------------------------------------
function AddDataStoreDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const fetcher = useFetcher<{ success?: boolean; error?: string }>();
const isSubmitting = fetcher.state !== "idle";
// Close dialog on success
if (fetcher.data?.success && open) {
onOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Add data store</DialogTitle>
</DialogHeader>
<fetcher.Form method="post" className="space-y-4 pt-2">
<input type="hidden" name="_action" value="add" />
<div className="space-y-1.5">
<label className="text-xs font-medium text-text-dimmed">
Key <span className="text-rose-400">*</span>
</label>
<Input
name="key"
placeholder="e.g. hipaa-clickhouse-us-east"
variant="medium"
required
className="font-mono"
/>
<p className="text-[11px] text-text-dimmed">
Unique identifier for this data store. Used as the secret key prefix.
</p>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-text-dimmed">
Kind <span className="text-rose-400">*</span>
</label>
<Input
name="kind"
value="CLICKHOUSE"
readOnly
variant="medium"
className="opacity-60"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-text-dimmed">
Organization IDs <span className="text-rose-400">*</span>
</label>
<Input
name="organizationIds"
placeholder="clxxxxx, clyyyyy, clzzzzz"
variant="medium"
required
/>
<p className="text-[11px] text-text-dimmed">Comma-separated organization IDs.</p>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-text-dimmed">
ClickHouse connection URL <span className="text-rose-400">*</span>
</label>
<Input
name="connectionUrl"
type="password"
placeholder="https://user:password@host:8443"
variant="medium"
required
className="font-mono"
/>
<p className="text-[11px] text-text-dimmed">
Stored encrypted in SecretStore. Never logged or displayed again.
</p>
</div>
{fetcher.data?.error && <p className="text-xs text-rose-400">{fetcher.data.error}</p>}
<DialogFooter>
<Button
variant="tertiary/small"
type="button"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" variant="primary/small" disabled={isSubmitting}>
{isSubmitting ? "Adding…" : "Add data store"}
</Button>
</DialogFooter>
</fetcher.Form>
</DialogContent>
</Dialog>
);
}
+4
View File
@@ -48,6 +48,10 @@ export default function Page() {
to: "/admin/back-office",
end: false,
},
{
label: "Data Stores",
to: "/admin/data-stores",
},
]}
layoutId={"admin"}
/>
+12 -3
View File
@@ -2,7 +2,7 @@ import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { PromptPresenter } from "~/presenters/v3/PromptPresenter.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import {
createActionApiRoute,
createLoaderApiRoute,
@@ -33,6 +33,13 @@ export const loader = createLoaderApiRoute(
slug: params.slug,
},
},
include: {
project: {
select: {
organizationId: true,
},
},
},
});
},
authorization: {
@@ -45,7 +52,8 @@ export const loader = createLoaderApiRoute(
return json({ error: "Prompt not found" }, { status: 404 });
}
const presenter = new PromptPresenter(clickhouseClient);
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(prompt.project.organizationId, "standard");
const presenter = new PromptPresenter(clickhouse);
const version = await presenter.resolveVersion(prompt.id, {
version: searchParams.version,
label: searchParams.label,
@@ -115,7 +123,8 @@ const { action } = createActionApiRoute(
return json({ error: "Prompt not found" }, { status: 404 });
}
const presenter = new PromptPresenter(clickhouseClient);
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(authentication.environment.organizationId, "standard");
const presenter = new PromptPresenter(clickhouse);
const version = await presenter.resolveVersion(prompt.id, {
version: body.version,
label: body.label,
@@ -2,7 +2,7 @@ import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { PromptPresenter } from "~/presenters/v3/PromptPresenter.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
const ParamsSchema = z.object({
@@ -23,6 +23,13 @@ export const loader = createLoaderApiRoute(
slug: params.slug,
},
},
include: {
project: {
select: {
organizationId: true,
},
},
},
});
},
authorization: {
@@ -35,7 +42,8 @@ export const loader = createLoaderApiRoute(
return json({ error: "Prompt not found" }, { status: 404 });
}
const presenter = new PromptPresenter(clickhouseClient);
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(prompt.project.organizationId, "standard");
const presenter = new PromptPresenter(clickhouse);
const versions = await presenter.listVersions(prompt.id);
return json({
@@ -1,6 +1,6 @@
import { json } from "@remix-run/server-runtime";
import { PromptPresenter } from "~/presenters/v3/PromptPresenter.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
export const loader = createLoaderApiRoute(
@@ -14,7 +14,8 @@ export const loader = createLoaderApiRoute(
},
},
async ({ authentication }) => {
const presenter = new PromptPresenter(clickhouseClient);
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(authentication.environment.organizationId, "standard");
const presenter = new PromptPresenter(clickhouse);
const prompts = await presenter.listPrompts(
authentication.environment.projectId,
authentication.environment.id
@@ -6,7 +6,7 @@ import {
createLoaderApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server";
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
import { getEventRepositoryForStore } from "~/v3/eventRepository/index.server";
const ParamsSchema = z.object({
runId: z.string(), // This is the run friendly ID
@@ -38,7 +38,10 @@ export const loader = createLoaderApiRoute(
},
},
async ({ resource: run, authentication }) => {
const eventRepository = resolveEventRepositoryForStore(run.taskEventStore);
const eventRepository = await getEventRepositoryForStore(
run.taskEventStore,
authentication.environment.organization.id
);
const runEvents = await eventRepository.getRunEvents(
getTaskEventStoreTableForRun(run),
@@ -7,7 +7,7 @@ import {
anyResource,
createLoaderApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
import { getEventRepositoryForStore } from "~/v3/eventRepository/index.server";
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
const ParamsSchema = z.object({
@@ -45,7 +45,10 @@ export const loader = createLoaderApiRoute(
},
},
async ({ params, resource: run, authentication }) => {
const eventRepository = resolveEventRepositoryForStore(run.taskEventStore);
const eventRepository = await getEventRepositoryForStore(
run.taskEventStore,
authentication.environment.organization.id
);
const eventStore = getTaskEventStoreTableForRun(run);
const span = await eventRepository.getSpan(
@@ -6,7 +6,7 @@ import {
anyResource,
createLoaderApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
import { getEventRepositoryForStore } from "~/v3/eventRepository/index.server";
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
const ParamsSchema = z.object({
@@ -43,7 +43,10 @@ export const loader = createLoaderApiRoute(
},
},
async ({ resource: run, authentication }) => {
const eventRepository = resolveEventRepositoryForStore(run.taskEventStore);
const eventRepository = await getEventRepositoryForStore(
run.taskEventStore,
authentication.environment.organization.id
);
const traceSummary = await eventRepository.getTraceDetailedSummary(
getTaskEventStoreTableForRun(run),
+6 -2
View File
@@ -10,7 +10,7 @@ import {
import { SessionId } from "@trigger.dev/core/v3/isomorphic";
import type { Prisma, Session } from "@trigger.dev/database";
import { $replica, prisma, type PrismaClient } from "~/db.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { logger } from "~/services/logger.server";
import { mintSessionToken } from "~/services/realtime/mintSessionToken.server";
import {
@@ -58,8 +58,12 @@ export const loader = createLoaderApiRoute(
findResource: async () => 1,
},
async ({ searchParams, authentication }) => {
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
authentication.environment.organizationId,
"standard"
);
const repository = new SessionsRepository({
clickhouse: clickhouseClient,
clickhouse,
prisma: $replica as PrismaClient,
});
+3 -2
View File
@@ -4,12 +4,13 @@ import { otlpExporter } from "~/v3/otlpExporter.server";
export async function action({ request }: ActionFunctionArgs) {
try {
const exporter = await otlpExporter;
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
if (contentType.startsWith("application/json")) {
const body = await request.json();
const exportResponse = await otlpExporter.exportLogs(body as ExportLogsServiceRequest);
const exportResponse = await exporter.exportLogs(body as ExportLogsServiceRequest);
return json(exportResponse, { status: 200 });
} else if (contentType.startsWith("application/x-protobuf")) {
@@ -17,7 +18,7 @@ export async function action({ request }: ActionFunctionArgs) {
const exportRequest = ExportLogsServiceRequest.decode(new Uint8Array(buffer));
const exportResponse = await otlpExporter.exportLogs(exportRequest);
const exportResponse = await exporter.exportLogs(exportRequest);
return new Response(ExportLogsServiceResponse.encode(exportResponse).finish(), {
status: 200,
+4 -2
View File
@@ -10,19 +10,21 @@ export async function action({ request }: ActionFunctionArgs) {
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
if (contentType.startsWith("application/json")) {
const exporter = await otlpExporter;
const body = await request.json();
const exportResponse = await otlpExporter.exportMetrics(
const exportResponse = await exporter.exportMetrics(
body as ExportMetricsServiceRequest
);
return json(exportResponse, { status: 200 });
} else if (contentType.startsWith("application/x-protobuf")) {
const exporter = await otlpExporter;
const buffer = await request.arrayBuffer();
const exportRequest = ExportMetricsServiceRequest.decode(new Uint8Array(buffer));
const exportResponse = await otlpExporter.exportMetrics(exportRequest);
const exportResponse = await exporter.exportMetrics(exportRequest);
return new Response(ExportMetricsServiceResponse.encode(exportResponse).finish(), {
status: 200,
+3 -2
View File
@@ -4,12 +4,13 @@ import { otlpExporter } from "~/v3/otlpExporter.server";
export async function action({ request }: ActionFunctionArgs) {
try {
const exporter = await otlpExporter;
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
if (contentType.startsWith("application/json")) {
const body = await request.json();
const exportResponse = await otlpExporter.exportTraces(body as ExportTraceServiceRequest);
const exportResponse = await exporter.exportTraces(body as ExportTraceServiceRequest);
return json(exportResponse, { status: 200 });
} else if (contentType.startsWith("application/x-protobuf")) {
@@ -17,7 +18,7 @@ export async function action({ request }: ActionFunctionArgs) {
const exportRequest = ExportTraceServiceRequest.decode(new Uint8Array(buffer));
const exportResponse = await otlpExporter.exportTraces(exportRequest);
const exportResponse = await exporter.exportTraces(exportRequest);
return new Response(ExportTraceServiceResponse.encode(exportResponse).finish(), {
status: 200,
@@ -1,7 +1,7 @@
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson } from "remix-typedjson";
import { z } from "zod";
import { logsClickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { requireUserId } from "~/services/session.server";
import { LogDetailPresenter } from "~/presenters/v3/LogDetailPresenter.server";
import { findProjectBySlug } from "~/models/project.server";
@@ -43,7 +43,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const [traceId, spanId, , startTime] = parts;
const presenter = new LogDetailPresenter($replica, logsClickhouseClient);
const logsClickhouse = await clickhouseFactory.getClickhouseForOrganization(project.organizationId, "logs");
const presenter = new LogDetailPresenter($replica, logsClickhouse);
let result;
try {
@@ -6,7 +6,7 @@ import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { LogsListPresenter, type LogLevel, LogsListOptionsSchema } from "~/presenters/v3/LogsListPresenter.server";
import { $replica } from "~/db.server";
import { logsClickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { getCurrentPlan } from "~/services/platform.v3.server";
// Valid log levels for filtering
@@ -69,7 +69,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
retentionLimitDays,
}) as any; // Validated by LogsListOptionsSchema at runtime
const presenter = new LogsListPresenter($replica, logsClickhouseClient);
const logsClickhouse = await clickhouseFactory.getClickhouseForOrganization(project.organizationId, "logs");
const presenter = new LogsListPresenter($replica, logsClickhouse);
const result = await presenter.call(project.organizationId, environment.id, options);
return json({
@@ -6,7 +6,7 @@ import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { parsePeriodToMs } from "~/utils/periods";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import {
PromptPresenter,
type GenerationRow,
@@ -59,7 +59,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const operations = url.searchParams.getAll("operations").filter(Boolean);
const providers = url.searchParams.getAll("providers").filter(Boolean);
const presenter = new PromptPresenter(clickhouseClient);
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(project.organizationId, "standard");
const presenter = new PromptPresenter(clickhouse);
const result = await presenter.listGenerations({
environmentId: environment.id,
promptSlug,
@@ -8,7 +8,7 @@ import { Readable } from "stream";
import { formatDurationMilliseconds } from "@trigger.dev/core/v3/utils/durations";
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
import { TaskEventKind } from "@trigger.dev/database";
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
import { getEventRepositoryForStore } from "~/v3/eventRepository/index.server";
export async function loader({ params, request }: LoaderFunctionArgs) {
const user = await requireUser(request);
@@ -29,11 +29,14 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
},
});
if (!run) {
if (!run || !run.organizationId) {
return new Response("Not found", { status: 404 });
}
const eventRepository = resolveEventRepositoryForStore(run.taskEventStore);
const eventRepository = await getEventRepositoryForStore(
run.taskEventStore,
run.organizationId
);
const runEvents = await eventRepository.getRunEvents(
getTaskEventStoreTableForRun(run),
@@ -10,6 +10,7 @@ export class DefaultTraceEventsConcern implements TraceEventConcern {
parentStore: string | undefined
): Promise<{ repository: IEventRepository; store: string }> {
return await getEventRepository(
request.environment.organization.id,
request.environment.organization.featureFlags as Record<string, unknown>,
parentStore
);
@@ -162,18 +163,15 @@ export class DefaultTraceEventsConcern implements TraceEventConcern {
},
async (event, traceContext, traceparent) => {
// Log a message about the debounced trigger
await repository.recordEvent(
`Debounced: using existing run with key "${debounceKey}"`,
{
taskSlug: request.taskId,
environment: request.environment,
attributes: {
runId: existingRun.friendlyId,
},
context: request.options?.traceContext,
parentId: event.spanId,
}
);
await repository.recordEvent(`Debounced: using existing run with key "${debounceKey}"`, {
taskSlug: request.taskId,
environment: request.environment,
attributes: {
runId: existingRun.friendlyId,
},
context: request.options?.traceContext,
parentId: event.spanId,
});
return await callback(
{
@@ -74,6 +74,7 @@ export class TriggerFailedTaskService {
try {
const { repository, store } = await getEventRepository(
request.environment.organization.id,
request.environment.organization.featureFlags as Record<string, unknown>,
undefined
);
@@ -81,11 +82,11 @@ export class TriggerFailedTaskService {
// Resolve parent run for rootTaskRunId and depth (same as triggerTask.server.ts)
const parentRun = request.parentRunId
? await this.prisma.taskRun.findFirst({
where: {
id: RunId.fromFriendlyId(request.parentRunId),
runtimeEnvironmentId: request.environment.id,
},
})
where: {
id: RunId.fromFriendlyId(request.parentRunId),
runtimeEnvironmentId: request.environment.id,
},
})
: undefined;
const depth = parentRun ? parentRun.depth + 1 : 0;
@@ -116,18 +117,18 @@ export class TriggerFailedTaskService {
// resolveQueueProperties requires the worker to be passed when lockToVersion is present.
const lockedToBackgroundWorker = bodyOptions?.lockToVersion
? await this.prisma.backgroundWorker.findFirst({
where: {
projectId: request.environment.projectId,
runtimeEnvironmentId: request.environment.id,
version: bodyOptions.lockToVersion,
},
select: {
id: true,
version: true,
sdkVersion: true,
cliVersion: true,
},
})
where: {
projectId: request.environment.projectId,
runtimeEnvironmentId: request.environment.id,
version: bodyOptions.lockToVersion,
},
select: {
id: true,
version: true,
sdkVersion: true,
cliVersion: true,
},
})
: undefined;
const resolved = await queueConcern.resolveQueueProperties(
@@ -273,9 +274,7 @@ export class TriggerFailedTaskService {
},
taskIdentifier: opts.taskId,
payload:
typeof opts.payload === "string"
? opts.payload
: JSON.stringify(opts.payload ?? ""),
typeof opts.payload === "string" ? opts.payload : JSON.stringify(opts.payload ?? ""),
payloadType: opts.payloadType ?? "application/json",
error: {
type: "INTERNAL_ERROR" as const,
@@ -1,4 +1,4 @@
import { adminClickhouseClient } from "~/services/clickhouseInstance.server";
import { getAdminClickhouse } from "~/services/clickhouse/clickhouseFactory.server";
import { llmPricingRegistry } from "~/v3/llmPricingRegistry.server";
export type MissingLlmModel = {
@@ -13,8 +13,10 @@ export async function getMissingLlmModels(opts: {
const lookbackHours = opts.lookbackHours ?? 24;
const since = new Date(Date.now() - lookbackHours * 60 * 60 * 1000);
const adminClickhouse = getAdminClickhouse();
// queryBuilderFast returns a factory function — call it to get the builder
const createBuilder = adminClickhouseClient.reader.queryBuilderFast<{
const createBuilder = adminClickhouse.reader.queryBuilderFast<{
model: string;
system: string;
cnt: string;
@@ -93,7 +95,9 @@ export async function getMissingModelSamples(opts: {
const limit = opts.limit ?? 10;
const since = new Date(Date.now() - lookbackHours * 60 * 60 * 1000);
const createBuilder = adminClickhouseClient.reader.queryBuilderFast<MissingModelSample>({
const adminClickhouse = getAdminClickhouse();
const createBuilder = adminClickhouse.reader.queryBuilderFast<MissingModelSample>({
name: "missingModelSamples",
table: "trigger_dev.task_events_v2",
columns: [
@@ -0,0 +1,489 @@
import { ClickHouse } from "@internal/clickhouse";
import { createHash } from "crypto";
import { ClickhouseEventRepository } from "~/v3/eventRepository/clickhouseEventRepository.server";
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
import type { OrganizationDataStoresRegistry } from "~/services/dataStores/organizationDataStoresRegistry.server";
import { type IEventRepository } from "~/v3/eventRepository/eventRepository.types";
// ---------------------------------------------------------------------------
// Default clients (singleton per process)
// ---------------------------------------------------------------------------
const defaultClickhouseClient = singleton("clickhouseClient", initializeClickhouseClient);
function initializeClickhouseClient() {
const url = new URL(env.CLICKHOUSE_URL);
url.searchParams.delete("secure");
console.log(`🗃️ Clickhouse service enabled to host ${url.host}`);
return new ClickHouse({
url: url.toString(),
name: "clickhouse-instance",
keepAlive: {
enabled: env.CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.CLICKHOUSE_LOG_LEVEL,
compression: { request: true },
maxOpenConnections: env.CLICKHOUSE_MAX_OPEN_CONNECTIONS,
});
}
const defaultLogsClickhouseClient = singleton(
"logsClickhouseClient",
initializeLogsClickhouseClient
);
function getLogsListClickhouseSettings() {
return {
max_memory_usage: env.CLICKHOUSE_LOGS_LIST_MAX_MEMORY_USAGE.toString(),
max_bytes_before_external_sort:
env.CLICKHOUSE_LOGS_LIST_MAX_BYTES_BEFORE_EXTERNAL_SORT.toString(),
max_threads: env.CLICKHOUSE_LOGS_LIST_MAX_THREADS,
...(env.CLICKHOUSE_LOGS_LIST_MAX_ROWS_TO_READ && {
max_rows_to_read: env.CLICKHOUSE_LOGS_LIST_MAX_ROWS_TO_READ.toString(),
}),
...(env.CLICKHOUSE_LOGS_LIST_MAX_EXECUTION_TIME && {
max_execution_time: env.CLICKHOUSE_LOGS_LIST_MAX_EXECUTION_TIME,
}),
};
}
function initializeLogsClickhouseClient() {
if (!env.LOGS_CLICKHOUSE_URL) {
throw new Error("LOGS_CLICKHOUSE_URL is not set");
}
const url = new URL(env.LOGS_CLICKHOUSE_URL);
url.searchParams.delete("secure");
return new ClickHouse({
url: url.toString(),
name: "logs-clickhouse",
keepAlive: {
enabled: env.CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.CLICKHOUSE_LOG_LEVEL,
compression: { request: true },
maxOpenConnections: env.CLICKHOUSE_MAX_OPEN_CONNECTIONS,
clickhouseSettings: getLogsListClickhouseSettings(),
});
}
const defaultAdminClickhouseClient = singleton(
"adminClickhouseClient",
initializeAdminClickhouseClient
);
function initializeAdminClickhouseClient() {
if (!env.ADMIN_CLICKHOUSE_URL) {
throw new Error("ADMIN_CLICKHOUSE_URL is not set");
}
const url = new URL(env.ADMIN_CLICKHOUSE_URL);
url.searchParams.delete("secure");
return new ClickHouse({
url: url.toString(),
name: "admin-clickhouse",
keepAlive: {
enabled: env.CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.CLICKHOUSE_LOG_LEVEL,
compression: { request: true },
maxOpenConnections: env.CLICKHOUSE_MAX_OPEN_CONNECTIONS,
});
}
const defaultQueryClickhouseClient = singleton(
"queryClickhouseClient",
initializeQueryClickhouseClient
);
function initializeQueryClickhouseClient() {
if (!env.QUERY_CLICKHOUSE_URL) {
throw new Error("QUERY_CLICKHOUSE_URL is not set");
}
const url = new URL(env.QUERY_CLICKHOUSE_URL);
url.searchParams.delete("secure");
return new ClickHouse({
url: url.toString(),
name: "query-clickhouse",
keepAlive: {
enabled: env.CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.CLICKHOUSE_LOG_LEVEL,
compression: { request: true },
maxOpenConnections: env.CLICKHOUSE_MAX_OPEN_CONNECTIONS,
});
}
/** TaskRun replication to ClickHouse (`RUN_REPLICATION_CLICKHOUSE_URL`); not exported. */
const defaultRunsReplicationClickhouseClient = singleton(
"runsReplicationClickhouseClient",
initializeRunsReplicationClickhouseClient
);
function initializeRunsReplicationClickhouseClient(): ClickHouse {
if (!env.RUN_REPLICATION_CLICKHOUSE_URL) {
// Runs replication worker gates on this URL; factory may still resolve "replication" for tests.
return defaultClickhouseClient;
}
const url = new URL(env.RUN_REPLICATION_CLICKHOUSE_URL);
url.searchParams.delete("secure");
return new ClickHouse({
url: url.toString(),
name: "runs-replication",
keepAlive: {
enabled: env.RUN_REPLICATION_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.RUN_REPLICATION_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.RUN_REPLICATION_CLICKHOUSE_LOG_LEVEL,
compression: { request: true },
maxOpenConnections: env.RUN_REPLICATION_MAX_OPEN_CONNECTIONS,
});
}
/** Session replication to ClickHouse (`SESSION_REPLICATION_CLICKHOUSE_URL`); not exported. */
const defaultSessionsReplicationClickhouseClient = singleton(
"sessionsReplicationClickhouseClient",
initializeSessionsReplicationClickhouseClient
);
function initializeSessionsReplicationClickhouseClient(): ClickHouse {
if (!env.SESSION_REPLICATION_CLICKHOUSE_URL) {
// Sessions replication worker gates on this URL; factory may still resolve "sessions_replication" for tests.
return defaultClickhouseClient;
}
const url = new URL(env.SESSION_REPLICATION_CLICKHOUSE_URL);
url.searchParams.delete("secure");
return new ClickHouse({
url: url.toString(),
name: "sessions-replication",
keepAlive: {
enabled: env.SESSION_REPLICATION_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.SESSION_REPLICATION_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.SESSION_REPLICATION_CLICKHOUSE_LOG_LEVEL,
compression: { request: true },
maxOpenConnections: env.SESSION_REPLICATION_MAX_OPEN_CONNECTIONS,
});
}
/** Task events (`EVENTS_CLICKHOUSE_URL`); not exported — accessed via factory. */
const defaultEventsClickhouseClient = singleton(
"eventsClickhouseClient",
initializeEventsClickhouseClient
);
function initializeEventsClickhouseClient(): ClickHouse {
if (!env.EVENTS_CLICKHOUSE_URL) {
throw new Error("EVENTS_CLICKHOUSE_URL is not set");
}
const url = new URL(env.EVENTS_CLICKHOUSE_URL);
url.searchParams.delete("secure");
return new ClickHouse({
url: url.toString(),
name: "task-events",
keepAlive: {
enabled: env.EVENTS_CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.EVENTS_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.EVENTS_CLICKHOUSE_LOG_LEVEL,
compression: {
request: env.EVENTS_CLICKHOUSE_COMPRESSION_REQUEST === "1",
},
maxOpenConnections: env.EVENTS_CLICKHOUSE_MAX_OPEN_CONNECTIONS,
});
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function hashHostname(url: string): string {
const parsed = new URL(url);
return createHash("sha256").update(parsed.hostname).digest("hex");
}
export type ClientType =
| "standard"
| "events"
| "replication"
| "sessions_replication"
| "logs"
| "query"
| "admin";
function buildOrgClickhouseClient(url: string, clientType: ClientType): ClickHouse {
const parsed = new URL(url);
parsed.searchParams.delete("secure");
const name = `org-clickhouse-${clientType}`;
switch (clientType) {
case "events":
return new ClickHouse({
url: parsed.toString(),
name,
keepAlive: {
enabled: env.EVENTS_CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.EVENTS_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.EVENTS_CLICKHOUSE_LOG_LEVEL,
compression: {
request: env.EVENTS_CLICKHOUSE_COMPRESSION_REQUEST === "1",
},
maxOpenConnections: env.EVENTS_CLICKHOUSE_MAX_OPEN_CONNECTIONS,
});
case "replication":
return new ClickHouse({
url: parsed.toString(),
name,
keepAlive: {
enabled: env.RUN_REPLICATION_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.RUN_REPLICATION_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.RUN_REPLICATION_CLICKHOUSE_LOG_LEVEL,
compression: { request: true },
maxOpenConnections: env.RUN_REPLICATION_MAX_OPEN_CONNECTIONS,
});
case "sessions_replication":
return new ClickHouse({
url: parsed.toString(),
name,
keepAlive: {
enabled: env.SESSION_REPLICATION_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.SESSION_REPLICATION_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.SESSION_REPLICATION_CLICKHOUSE_LOG_LEVEL,
compression: { request: true },
maxOpenConnections: env.SESSION_REPLICATION_MAX_OPEN_CONNECTIONS,
});
case "logs":
return new ClickHouse({
url: parsed.toString(),
name,
keepAlive: {
enabled: env.CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.CLICKHOUSE_LOG_LEVEL,
compression: { request: true },
maxOpenConnections: env.CLICKHOUSE_MAX_OPEN_CONNECTIONS,
clickhouseSettings: getLogsListClickhouseSettings(),
});
case "standard":
case "query":
case "admin":
return new ClickHouse({
url: parsed.toString(),
name,
keepAlive: {
enabled: env.CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.CLICKHOUSE_LOG_LEVEL,
compression: { request: true },
maxOpenConnections: env.CLICKHOUSE_MAX_OPEN_CONNECTIONS,
});
}
}
// ---------------------------------------------------------------------------
// Factory class (injectable for testing)
// ---------------------------------------------------------------------------
export class ClickhouseFactory {
/** ClickHouse clients keyed by hostname hash + clientType. */
private readonly _clientCache = new Map<string, ClickHouse>();
/** Event repositories keyed by hostname hash (stateful, must be reused). */
private readonly _eventRepositoryCache = new Map<string, ClickhouseEventRepository>();
constructor(private readonly _registry: OrganizationDataStoresRegistry) {}
async isReady(): Promise<boolean> {
if (!this._registry.isLoaded) {
await this._registry.isReady;
}
return true;
}
async getClickhouseForOrganization(
organizationId: string,
clientType: ClientType
): Promise<ClickHouse> {
if (!this._registry.isLoaded) {
await this._registry.isReady;
}
return this.getClickhouseForOrganizationSync(organizationId, clientType);
}
getClickhouseForOrganizationSync(organizationId: string, clientType: ClientType): ClickHouse {
const dataStore = this._registry.get(organizationId, "CLICKHOUSE");
if (!dataStore) {
switch (clientType) {
case "standard":
return defaultClickhouseClient;
case "events":
return defaultEventsClickhouseClient;
case "replication":
return defaultRunsReplicationClickhouseClient;
case "sessions_replication":
return defaultSessionsReplicationClickhouseClient;
case "logs":
return defaultLogsClickhouseClient;
case "query":
return defaultQueryClickhouseClient;
case "admin":
return defaultAdminClickhouseClient;
}
}
const hostnameHash = hashHostname(dataStore.url);
const cacheKey = `${hostnameHash}:${clientType}`;
let client = this._clientCache.get(cacheKey);
if (!client) {
client = buildOrgClickhouseClient(dataStore.url, clientType);
this._clientCache.set(cacheKey, client);
}
return client;
}
async getEventRepositoryForOrganization(
store: string,
organizationId: string
): Promise<{ key: string; repository: IEventRepository }> {
if (!this._registry.isLoaded) {
await this._registry.isReady;
}
return this.getEventRepositoryForOrganizationSync(store, organizationId);
}
getEventRepositoryForOrganizationSync(
store: string,
organizationId: string
): { key: string; repository: IEventRepository } {
const dataStore = this._registry.get(organizationId, "CLICKHOUSE");
if (!dataStore) {
const defaultKey = `default:events:${store}`;
let defaultRepo = this._eventRepositoryCache.get(defaultKey);
if (!defaultRepo) {
const eventsClickhouse = getEventsClickhouseClient();
defaultRepo = buildEventRepository(store, eventsClickhouse);
this._eventRepositoryCache.set(defaultKey, defaultRepo);
}
return { key: defaultKey, repository: defaultRepo };
}
const hostnameHash = hashHostname(dataStore.url);
const cacheKey = `${hostnameHash}:events:${store}`;
let repository = this._eventRepositoryCache.get(cacheKey);
if (!repository) {
const client = this.getClickhouseForOrganizationSync(organizationId, "events");
repository = buildEventRepository(store, client);
this._eventRepositoryCache.set(cacheKey, repository);
}
return { key: cacheKey, repository: repository };
}
}
/**
* Get admin ClickHouse client for cross-organization queries.
* Only use for admin tools and analytics that need to query across all orgs.
*/
export function getAdminClickhouse(): ClickHouse {
return defaultAdminClickhouseClient;
}
export function getDefaultClickhouseClient(): ClickHouse {
return defaultClickhouseClient;
}
export function getDefaultLogsClickhouseClient(): ClickHouse {
return defaultLogsClickhouseClient;
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
function getEventsClickhouseClient(): ClickHouse {
return defaultEventsClickhouseClient;
}
function buildEventRepository(store: string, clickhouse: ClickHouse): ClickhouseEventRepository {
switch (store) {
case "clickhouse": {
return new ClickhouseEventRepository({
clickhouse,
batchSize: env.EVENTS_CLICKHOUSE_BATCH_SIZE,
flushInterval: env.EVENTS_CLICKHOUSE_FLUSH_INTERVAL_MS,
maximumTraceSummaryViewCount: env.EVENTS_CLICKHOUSE_MAX_TRACE_SUMMARY_VIEW_COUNT,
maximumTraceDetailedSummaryViewCount:
env.EVENTS_CLICKHOUSE_MAX_TRACE_DETAILED_SUMMARY_VIEW_COUNT,
maximumLiveReloadingSetting: env.EVENTS_CLICKHOUSE_MAX_LIVE_RELOADING_SETTING,
insertStrategy: env.EVENTS_CLICKHOUSE_INSERT_STRATEGY,
waitForAsyncInsert: env.EVENTS_CLICKHOUSE_WAIT_FOR_ASYNC_INSERT === "1",
asyncInsertMaxDataSize: env.EVENTS_CLICKHOUSE_ASYNC_INSERT_MAX_DATA_SIZE,
asyncInsertBusyTimeoutMs: env.EVENTS_CLICKHOUSE_ASYNC_INSERT_BUSY_TIMEOUT_MS,
startTimeMaxAgeMs: env.EVENTS_CLICKHOUSE_START_TIME_MAX_AGE_MS,
llmMetricsBatchSize: env.LLM_METRICS_BATCH_SIZE,
llmMetricsFlushInterval: env.LLM_METRICS_FLUSH_INTERVAL_MS,
llmMetricsMaxBatchSize: env.LLM_METRICS_MAX_BATCH_SIZE,
llmMetricsMaxConcurrency: env.LLM_METRICS_MAX_CONCURRENCY,
otlpMetricsBatchSize: env.METRICS_CLICKHOUSE_BATCH_SIZE,
otlpMetricsFlushInterval: env.METRICS_CLICKHOUSE_FLUSH_INTERVAL_MS,
otlpMetricsMaxConcurrency: env.METRICS_CLICKHOUSE_MAX_CONCURRENCY,
version: "v1",
});
}
case "clickhouse_v2": {
return new ClickhouseEventRepository({
clickhouse: clickhouse,
batchSize: env.EVENTS_CLICKHOUSE_BATCH_SIZE,
flushInterval: env.EVENTS_CLICKHOUSE_FLUSH_INTERVAL_MS,
maximumTraceSummaryViewCount: env.EVENTS_CLICKHOUSE_MAX_TRACE_SUMMARY_VIEW_COUNT,
maximumTraceDetailedSummaryViewCount:
env.EVENTS_CLICKHOUSE_MAX_TRACE_DETAILED_SUMMARY_VIEW_COUNT,
maximumLiveReloadingSetting: env.EVENTS_CLICKHOUSE_MAX_LIVE_RELOADING_SETTING,
insertStrategy: env.EVENTS_CLICKHOUSE_INSERT_STRATEGY,
waitForAsyncInsert: env.EVENTS_CLICKHOUSE_WAIT_FOR_ASYNC_INSERT === "1",
asyncInsertMaxDataSize: env.EVENTS_CLICKHOUSE_ASYNC_INSERT_MAX_DATA_SIZE,
asyncInsertBusyTimeoutMs: env.EVENTS_CLICKHOUSE_ASYNC_INSERT_BUSY_TIMEOUT_MS,
startTimeMaxAgeMs: env.EVENTS_CLICKHOUSE_START_TIME_MAX_AGE_MS,
llmMetricsBatchSize: env.LLM_METRICS_BATCH_SIZE,
llmMetricsFlushInterval: env.LLM_METRICS_FLUSH_INTERVAL_MS,
llmMetricsMaxBatchSize: env.LLM_METRICS_MAX_BATCH_SIZE,
llmMetricsMaxConcurrency: env.LLM_METRICS_MAX_CONCURRENCY,
otlpMetricsBatchSize: env.METRICS_CLICKHOUSE_BATCH_SIZE,
otlpMetricsFlushInterval: env.METRICS_CLICKHOUSE_FLUSH_INTERVAL_MS,
otlpMetricsMaxConcurrency: env.METRICS_CLICKHOUSE_MAX_CONCURRENCY,
version: "v2",
});
}
default: {
throw new Error(`Unknown ClickHouse event repository store: ${store}`);
}
}
}
@@ -0,0 +1,13 @@
import { organizationDataStoresRegistry } from "~/services/dataStores/organizationDataStoresRegistryInstance.server";
import { singleton } from "~/utils/singleton";
import { ClickhouseFactory } from "./clickhouseFactory.server";
/**
* Production singleton wired to the global organization data-stores registry.
* Import this only from app/runtime code not from tests that construct a
* {@link ClickhouseFactory} with a stub registry (see `clickhouseFactory.server.ts`).
*/
export const clickhouseFactory = singleton(
"clickhouseFactory",
() => new ClickhouseFactory(organizationDataStoresRegistry)
);
@@ -0,0 +1,11 @@
import { z } from "zod";
export const ClickhouseConnectionSchema = z.object({
url: z.string().url(),
});
export type ClickhouseConnection = z.infer<typeof ClickhouseConnectionSchema>;
export function getClickhouseSecretKey(orgId: string, clientType: string): string {
return `org:${orgId}:clickhouse:${clientType}`;
}
@@ -1,130 +0,0 @@
import { ClickHouse } from "@internal/clickhouse";
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
export const clickhouseClient = singleton("clickhouseClient", initializeClickhouseClient);
function initializeClickhouseClient() {
const url = new URL(env.CLICKHOUSE_URL);
// Remove secure param
url.searchParams.delete("secure");
console.log(`🗃️ Clickhouse service enabled to host ${url.host}`);
const clickhouse = new ClickHouse({
url: url.toString(),
name: "clickhouse-instance",
keepAlive: {
enabled: env.CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.CLICKHOUSE_LOG_LEVEL,
compression: {
request: true,
},
maxOpenConnections: env.CLICKHOUSE_MAX_OPEN_CONNECTIONS,
});
return clickhouse;
}
export const logsClickhouseClient = singleton(
"logsClickhouseClient",
initializeLogsClickhouseClient
);
function initializeLogsClickhouseClient() {
if (!env.LOGS_CLICKHOUSE_URL) {
throw new Error("LOGS_CLICKHOUSE_URL is not set");
}
const url = new URL(env.LOGS_CLICKHOUSE_URL);
// Remove secure param
url.searchParams.delete("secure");
return new ClickHouse({
url: url.toString(),
name: "logs-clickhouse",
keepAlive: {
enabled: env.CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.CLICKHOUSE_LOG_LEVEL,
compression: {
request: true,
},
maxOpenConnections: env.CLICKHOUSE_MAX_OPEN_CONNECTIONS,
clickhouseSettings: {
max_memory_usage: env.CLICKHOUSE_LOGS_LIST_MAX_MEMORY_USAGE.toString(),
max_bytes_before_external_sort:
env.CLICKHOUSE_LOGS_LIST_MAX_BYTES_BEFORE_EXTERNAL_SORT.toString(),
max_threads: env.CLICKHOUSE_LOGS_LIST_MAX_THREADS,
...(env.CLICKHOUSE_LOGS_LIST_MAX_ROWS_TO_READ && {
max_rows_to_read: env.CLICKHOUSE_LOGS_LIST_MAX_ROWS_TO_READ.toString(),
}),
...(env.CLICKHOUSE_LOGS_LIST_MAX_EXECUTION_TIME && {
max_execution_time: env.CLICKHOUSE_LOGS_LIST_MAX_EXECUTION_TIME,
}),
},
});
}
export const adminClickhouseClient = singleton(
"adminClickhouseClient",
initializeAdminClickhouseClient
);
function initializeAdminClickhouseClient() {
if (!env.ADMIN_CLICKHOUSE_URL) {
throw new Error("ADMIN_CLICKHOUSE_URL is not set");
}
const url = new URL(env.ADMIN_CLICKHOUSE_URL);
url.searchParams.delete("secure");
return new ClickHouse({
url: url.toString(),
name: "admin-clickhouse",
keepAlive: {
enabled: env.CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.CLICKHOUSE_LOG_LEVEL,
compression: {
request: true,
},
maxOpenConnections: env.CLICKHOUSE_MAX_OPEN_CONNECTIONS,
});
}
export const queryClickhouseClient = singleton(
"queryClickhouseClient",
initializeQueryClickhouseClient
);
function initializeQueryClickhouseClient() {
if (!env.QUERY_CLICKHOUSE_URL) {
throw new Error("QUERY_CLICKHOUSE_URL is not set");
}
const url = new URL(env.QUERY_CLICKHOUSE_URL);
// Remove secure param
url.searchParams.delete("secure");
return new ClickHouse({
url: url.toString(),
name: "query-clickhouse",
keepAlive: {
enabled: env.CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.CLICKHOUSE_LOG_LEVEL,
compression: {
request: true,
},
maxOpenConnections: env.CLICKHOUSE_MAX_OPEN_CONNECTIONS,
});
}
@@ -0,0 +1,39 @@
import { z } from "zod";
// ---------------------------------------------------------------------------
// ClickHouse config (kind = CLICKHOUSE)
// ---------------------------------------------------------------------------
/** V1: single secret-store key that supplies the ClickHouse connection URL. */
export const ClickhouseDataStoreConfigV1 = z.object({
version: z.literal(1),
data: z.object({
/** Key into the SecretStore that resolves to a ClickhouseConnection ({url}). */
secretKey: z.string(),
}),
});
export type ClickhouseDataStoreConfigV1 = z.infer<typeof ClickhouseDataStoreConfigV1>;
/** Discriminated union over version — extend by adding new literals here. */
export const ClickhouseDataStoreConfig = z.discriminatedUnion("version", [
ClickhouseDataStoreConfigV1,
]);
export type ClickhouseDataStoreConfig = z.infer<typeof ClickhouseDataStoreConfig>;
// ---------------------------------------------------------------------------
// Top-level per-kind union
// ---------------------------------------------------------------------------
/**
* Secrets are resolved to URLs at registry load time so the factory never
* needs to touch the secret store on the hot path.
*/
export type ParsedClickhouseDataStore = {
kind: "CLICKHOUSE";
url: string;
};
/** Union of all parsed data store types. Extend as new DataStoreKind values are added. */
export type ParsedDataStore = ParsedClickhouseDataStore;
@@ -0,0 +1,189 @@
import type { DataStoreKind, PrismaClient, PrismaReplicaClient } from "@trigger.dev/database";
import {
ClickhouseDataStoreConfig,
type ParsedDataStore,
} from "./organizationDataStoreConfigSchemas.server";
import { getSecretStore } from "../secrets/secretStore.server";
import { ClickhouseConnectionSchema } from "../clickhouse/clickhouseSecretSchemas.server";
export class OrganizationDataStoresRegistry {
private _prisma: PrismaClient | PrismaReplicaClient;
/** Keyed by `${organizationId}:${kind}` */
private _lookup: Map<string, ParsedDataStore> = new Map();
private _loaded = false;
private _readyResolve!: () => void;
/**
* Resolves once the initial `loadFromDatabase()` completes successfully.
* At process startup the singleton loads the registry with unbounded retries
* (exponential backoff, capped delay) until Postgres is reachable; until then
* this promise stays pending and callers that await readiness will block.
*/
readonly isReady: Promise<void>;
constructor(prisma: PrismaClient | PrismaReplicaClient) {
this._prisma = prisma;
this.isReady = new Promise<void>((resolve) => {
this._readyResolve = resolve;
});
}
get isLoaded(): boolean {
return this._loaded;
}
async loadFromDatabase(): Promise<void> {
// Sort by `key` (unique, immutable) to ensure a deterministic winner when the
// same `${orgId}:${kind}` appears in multiple rows. The registry must never
// throw on overlap — failing the load would break every customer, not just the
// misconfigured orgs — so we keep the first entry and log an error instead.
const rows = await this._prisma.organizationDataStore.findMany({
orderBy: { key: "asc" },
});
const secretStore = getSecretStore("DATABASE", { prismaClient: this._prisma });
const lookup = new Map<string, ParsedDataStore>();
/** Tracks which row's `key` already owns each `${orgId}:${kind}` so we can log conflicts. */
const winnerByLookupKey = new Map<string, string>();
for (const row of rows) {
let parsed: ParsedDataStore | null = null;
switch (row.kind) {
case "CLICKHOUSE": {
const result = ClickhouseDataStoreConfig.safeParse(row.config);
if (!result.success) {
console.warn(
`[OrganizationDataStoresRegistry] Invalid config for OrganizationDataStore "${row.key}" (kind=CLICKHOUSE): ${result.error.message}`
);
continue;
}
const connection = await secretStore.getSecret(
ClickhouseConnectionSchema,
result.data.data.secretKey
);
if (!connection) {
console.warn(
`[OrganizationDataStoresRegistry] Secret "${result.data.data.secretKey}" not found for OrganizationDataStore "${row.key}" — skipping`
);
continue;
}
parsed = { kind: "CLICKHOUSE", url: connection.url };
break;
}
default: {
console.warn(
`[OrganizationDataStoresRegistry] Unknown kind "${row.kind}" for OrganizationDataStore "${row.key}" — skipping`
);
continue;
}
}
for (const orgId of row.organizationIds) {
const lookupKey = `${orgId}:${row.kind}`;
const existingWinner = winnerByLookupKey.get(lookupKey);
if (existingWinner) {
console.error(
`[OrganizationDataStoresRegistry] Overlapping OrganizationDataStore assignment for orgId="${orgId}" kind=${row.kind}: already routed to "${existingWinner}", ignoring "${row.key}". Pick one store per (org, kind) to resolve.`
);
continue;
}
winnerByLookupKey.set(lookupKey, row.key);
lookup.set(lookupKey, parsed);
}
}
this._lookup = lookup;
if (!this._loaded) {
this._loaded = true;
this._readyResolve();
}
}
async reload(): Promise<void> {
await this.loadFromDatabase();
}
#secretKey(key: string, kind: DataStoreKind) {
return `data-store:${key}:${kind.toLocaleLowerCase()}`;
}
async addDataStore({
key,
kind,
organizationIds,
config,
}: {
key: string;
kind: DataStoreKind;
organizationIds: string[];
config: any;
}) {
const secretKey = this.#secretKey(key, kind);
const secretStore = getSecretStore("DATABASE", { prismaClient: this._prisma });
await secretStore.setSecret(secretKey, config);
return this._prisma.organizationDataStore.create({
data: {
key,
organizationIds,
kind,
config: { version: 1, data: { secretKey } },
},
});
}
async updateDataStore({
key,
kind,
organizationIds,
config,
}: {
key: string;
kind: DataStoreKind;
organizationIds: string[];
config?: any;
}) {
const secretKey = this.#secretKey(key, kind);
if (config) {
const secretStore = getSecretStore("DATABASE", { prismaClient: this._prisma });
await secretStore.setSecret(secretKey, config);
}
return this._prisma.organizationDataStore.update({
where: {
key,
},
data: {
organizationIds,
kind: "CLICKHOUSE",
},
});
}
async deleteDataStore({ key, kind }: { key: string; kind: DataStoreKind }) {
const secretKey = this.#secretKey(key, kind);
const secretStore = getSecretStore("DATABASE", { prismaClient: this._prisma });
await secretStore.deleteSecret(secretKey).catch(() => {
// Secret may not exist — proceed with deletion
});
await this._prisma.organizationDataStore.delete({ where: { key } });
}
/**
* Returns the parsed data store config for the given organization and kind,
* or `null` if no override is configured (caller should use the default).
*/
get(organizationId: string, kind: DataStoreKind): ParsedDataStore | null {
if (!this._loaded) return null;
return this._lookup.get(`${organizationId}:${kind}`) ?? null;
}
}
@@ -0,0 +1,42 @@
import pRetry from "p-retry";
import { $replica } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { signalsEmitter } from "~/services/signals.server";
import { singleton } from "~/utils/singleton";
import { OrganizationDataStoresRegistry } from "./organizationDataStoresRegistry.server";
export const organizationDataStoresRegistry = singleton("organizationDataStoresRegistry", () => {
const registry = new OrganizationDataStoresRegistry($replica);
// Runs as soon as this singleton is created (first import of this module). The
// registrys `isReady` promise resolves when this eventually succeeds.
const startupLoadPromise = pRetry(() => registry.loadFromDatabase(), {
forever: true,
retries: 10,
minTimeout: 1_000,
maxTimeout: 60_000,
factor: 2,
onFailedAttempt: (error) => {
logger.warn("[OrganizationDataStoresRegistry] Startup load failed, retrying", {
attemptNumber: error.attemptNumber,
retriesLeft: error.retriesLeft,
error: error.message,
});
},
});
startupLoadPromise.catch((err) => {
console.error("[OrganizationDataStoresRegistry] Unexpected startup load failure", err);
});
const interval = setInterval(() => {
registry.reload().catch((err) => {
console.error("[OrganizationDataStoresRegistry] Failed to reload", err);
});
}, env.ORGANIZATION_DATA_STORES_RELOAD_INTERVAL_MS);
signalsEmitter.on("SIGTERM", () => clearInterval(interval));
signalsEmitter.on("SIGINT", () => clearInterval(interval));
return registry;
});
@@ -11,7 +11,7 @@ import type { TableSchema, WhereClauseCondition } from "@internal/tsql";
import { z } from "zod";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { queryClickhouseClient } from "./clickhouseInstance.server";
import { clickhouseFactory } from "./clickhouse/clickhouseFactoryInstance.server";
import {
queryConcurrencyLimiter,
DEFAULT_ORG_CONCURRENCY_LIMIT,
@@ -275,7 +275,8 @@ export async function executeQuery<TOut extends z.ZodSchema>(
environment: Object.fromEntries(environments.map((e) => [e.id, e.slug])),
};
const result = await executeTSQL(queryClickhouseClient.reader, {
const queryClickhouse = await clickhouseFactory.getClickhouseForOrganization(organizationId, "query");
const result = await executeTSQL(queryClickhouse.reader, {
...baseOptions,
schema: z.record(z.any()),
tableSchema: querySchemas,
@@ -1,6 +1,6 @@
import { ClickHouse } from "@internal/clickhouse";
import invariant from "tiny-invariant";
import { env } from "~/env.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { singleton } from "~/utils/singleton";
import { meter, provider } from "~/v3/tracer.server";
import { RunsReplicationService } from "./runsReplicationService.server";
@@ -22,22 +22,8 @@ function initializeRunsReplicationInstance() {
console.log("🗃️ Runs replication service enabled");
const clickhouse = new ClickHouse({
url: env.RUN_REPLICATION_CLICKHOUSE_URL,
name: "runs-replication",
keepAlive: {
enabled: env.RUN_REPLICATION_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.RUN_REPLICATION_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.RUN_REPLICATION_CLICKHOUSE_LOG_LEVEL,
compression: {
request: true,
},
maxOpenConnections: env.RUN_REPLICATION_MAX_OPEN_CONNECTIONS,
});
const service = new RunsReplicationService({
clickhouse: clickhouse,
clickhouseFactory,
pgConnectionUrl: DATABASE_URL,
serviceName: "runs-replication",
slotName: env.RUN_REPLICATION_SLOT_NAME,
@@ -72,8 +58,9 @@ function initializeRunsReplicationInstance() {
});
if (env.RUN_REPLICATION_ENABLED === "1") {
service
.start()
clickhouseFactory
.isReady()
.then(() => service.start())
.then(() => {
console.log("🗃️ Runs replication service started");
})
@@ -1,5 +1,11 @@
import type { ClickHouse, TaskRunInsertArray, PayloadInsertArray } from "@internal/clickhouse";
import { getTaskRunField, getPayloadField } from "@internal/clickhouse";
import type { ClickhouseFactory } from "~/services/clickhouse/clickhouseFactory.server";
import {
type ClickHouse,
type PayloadInsertArray,
type TaskRunInsertArray,
getPayloadField,
getTaskRunField,
} from "@internal/clickhouse";
import { type RedisOptions } from "@internal/redis";
import {
LogicalReplicationClient,
@@ -21,7 +27,10 @@ import {
import { Logger, type LogLevel } from "@trigger.dev/core/logger";
import { tryCatch } from "@trigger.dev/core/utils";
import { parsePacketAsJson } from "@trigger.dev/core/v3/utils/ioSerialization";
import { unsafeExtractIdempotencyKeyScope, unsafeExtractIdempotencyKeyUser } from "@trigger.dev/core/v3/serverOnly";
import {
unsafeExtractIdempotencyKeyScope,
unsafeExtractIdempotencyKeyUser,
} from "@trigger.dev/core/v3/serverOnly";
import { RunAnnotations } from "@trigger.dev/core/v3";
import { type TaskRun } from "@trigger.dev/database";
import { nanoid } from "nanoid";
@@ -46,7 +55,7 @@ interface Transaction<T = any> {
}
export type RunsReplicationServiceOptions = {
clickhouse: ClickHouse;
clickhouseFactory: ClickhouseFactory;
pgConnectionUrl: string;
serviceName: string;
slotName: string;
@@ -560,16 +569,50 @@ export class RunsReplicationService {
const flushStartTime = performance.now();
await startSpan(this._tracer, "flushBatch", async (span) => {
const preparedInserts = await startSpan(this._tracer, "prepare_inserts", async (span) => {
const preparedInserts = await startSpan(this._tracer, "prepare_inserts", async () => {
return await Promise.all(batch.map(this.#prepareRunInserts.bind(this)));
});
const taskRunInserts = preparedInserts
.map(({ taskRunInsert }) => taskRunInsert)
.filter((x): x is TaskRunInsertArray => Boolean(x))
// batch inserts in clickhouse are more performant if the items
// are pre-sorted by the primary key
.sort((a, b) => {
const routeCache = new Map<string, ClickHouse>();
const groups = new Map<
ClickHouse,
{ taskRunInserts: TaskRunInsertArray[]; payloadInserts: PayloadInsertArray[] }
>();
for (let i = 0; i < batch.length; i++) {
const batchedRun = batch[i]!;
const prep = preparedInserts[i]!;
const { run } = batchedRun;
if (!run.organizationId || !run.environmentType) {
continue;
}
let client = routeCache.get(run.organizationId);
if (!client) {
client = this.options.clickhouseFactory.getClickhouseForOrganizationSync(
run.organizationId,
"replication"
);
routeCache.set(run.organizationId, client);
}
let group = groups.get(client);
if (!group) {
group = { taskRunInserts: [], payloadInserts: [] };
groups.set(client, group);
}
if (prep.taskRunInsert) {
group.taskRunInserts.push(prep.taskRunInsert);
}
if (prep.payloadInsert) {
group.payloadInserts.push(prep.payloadInsert);
}
}
const sortTaskRunInserts = (rows: TaskRunInsertArray[]) =>
rows.sort((a, b) => {
const aOrgId = getTaskRunField(a, "organization_id");
const bOrgId = getTaskRunField(b, "organization_id");
if (aOrgId !== bOrgId) {
@@ -596,41 +639,61 @@ export class RunsReplicationService {
return aRunId < bRunId ? -1 : 1;
});
const payloadInserts = preparedInserts
.map(({ payloadInsert }) => payloadInsert)
.filter((x): x is PayloadInsertArray => Boolean(x))
// batch inserts in clickhouse are more performant if the items
// are pre-sorted by the primary key
.sort((a, b) => {
const sortPayloadInserts = (rows: PayloadInsertArray[]) =>
rows.sort((a, b) => {
const aRunId = getPayloadField(a, "run_id");
const bRunId = getPayloadField(b, "run_id");
if (aRunId === bRunId) return 0;
return aRunId < bRunId ? -1 : 1;
});
span.setAttribute("task_run_inserts", taskRunInserts.length);
span.setAttribute("payload_inserts", payloadInserts.length);
const combinedTaskRunInserts: TaskRunInsertArray[] = [];
const combinedPayloadInserts: PayloadInsertArray[] = [];
let taskRunError: Error | null = null;
let payloadError: Error | null = null;
for (const [clickhouse, group] of groups) {
sortTaskRunInserts(group.taskRunInserts);
sortPayloadInserts(group.payloadInserts);
combinedTaskRunInserts.push(...group.taskRunInserts);
combinedPayloadInserts.push(...group.payloadInserts);
const [trErr] = await this.#insertWithRetry(
(attempt) => this.#insertTaskRunInserts(clickhouse, group.taskRunInserts, attempt),
"task run inserts",
flushId
);
if (trErr && !taskRunError) {
taskRunError = trErr;
}
const [plErr] = await this.#insertWithRetry(
(attempt) => this.#insertPayloadInserts(clickhouse, group.payloadInserts, attempt),
"payload inserts",
flushId
);
if (plErr && !payloadError) {
payloadError = plErr;
}
if (!trErr) {
this._taskRunsInsertedCounter.add(group.taskRunInserts.length);
}
if (!plErr) {
this._payloadsInsertedCounter.add(group.payloadInserts.length);
}
}
span.setAttribute("task_run_inserts", combinedTaskRunInserts.length);
span.setAttribute("payload_inserts", combinedPayloadInserts.length);
this.logger.debug("Flushing inserts", {
flushId,
taskRunInserts: taskRunInserts.length,
payloadInserts: payloadInserts.length,
taskRunInserts: combinedTaskRunInserts.length,
payloadInserts: combinedPayloadInserts.length,
clickhouseGroups: groups.size,
});
// Insert task runs and payloads with retry logic for connection errors
const [taskRunError, taskRunResult] = await this.#insertWithRetry(
(attempt) => this.#insertTaskRunInserts(taskRunInserts, attempt),
"task run inserts",
flushId
);
const [payloadError, payloadResult] = await this.#insertWithRetry(
(attempt) => this.#insertPayloadInserts(payloadInserts, attempt),
"payload inserts",
flushId
);
// Log any errors that occurred
if (taskRunError) {
this.logger.error("Error inserting task run inserts", {
error: taskRunError,
@@ -649,27 +712,22 @@ export class RunsReplicationService {
this.logger.debug("Flushed inserts", {
flushId,
taskRunInserts: taskRunInserts.length,
payloadInserts: payloadInserts.length,
taskRunInserts: combinedTaskRunInserts.length,
payloadInserts: combinedPayloadInserts.length,
});
this.events.emit("batchFlushed", { flushId, taskRunInserts, payloadInserts });
this.events.emit("batchFlushed", {
flushId,
taskRunInserts: combinedTaskRunInserts,
payloadInserts: combinedPayloadInserts,
});
// Record metrics
const flushDurationMs = performance.now() - flushStartTime;
const hasErrors = taskRunError !== null || payloadError !== null;
this._batchSizeHistogram.record(batch.length);
this._flushDurationHistogram.record(flushDurationMs);
this._batchesFlushedCounter.add(1, { success: !hasErrors });
if (!taskRunError) {
this._taskRunsInsertedCounter.add(taskRunInserts.length);
}
if (!payloadError) {
this._payloadsInsertedCounter.add(payloadInserts.length);
}
});
}
@@ -770,10 +828,17 @@ export class RunsReplicationService {
};
}
async #insertTaskRunInserts(taskRunInserts: TaskRunInsertArray[], attempt: number) {
async #insertTaskRunInserts(
clickhouse: ClickHouse,
taskRunInserts: TaskRunInsertArray[],
attempt: number
) {
if (taskRunInserts.length === 0) {
return;
}
return await startSpan(this._tracer, "insertTaskRunsInserts", async (span) => {
const [insertError, insertResult] =
await this.options.clickhouse.taskRuns.insertCompactArrays(taskRunInserts, {
await clickhouse.taskRuns.insertCompactArrays(taskRunInserts, {
params: {
clickhouse_settings: this.#getClickhouseInsertSettings(),
},
@@ -793,10 +858,17 @@ export class RunsReplicationService {
});
}
async #insertPayloadInserts(payloadInserts: PayloadInsertArray[], attempt: number) {
async #insertPayloadInserts(
clickhouse: ClickHouse,
payloadInserts: PayloadInsertArray[],
attempt: number
) {
if (payloadInserts.length === 0) {
return;
}
return await startSpan(this._tracer, "insertPayloadInserts", async (span) => {
const [insertError, insertResult] =
await this.options.clickhouse.taskRuns.insertPayloadsCompactArrays(payloadInserts, {
await clickhouse.taskRuns.insertPayloadsCompactArrays(payloadInserts, {
params: {
clickhouse_settings: this.#getClickhouseInsertSettings(),
},
@@ -860,12 +932,13 @@ export class RunsReplicationService {
const errorData = { data: run.error };
// Calculate error fingerprint for failed runs
const errorFingerprint = (
const errorFingerprint =
!this._disableErrorFingerprinting &&
['SYSTEM_FAILURE', 'CRASHED', 'INTERRUPTED', 'COMPLETED_WITH_ERRORS', 'TIMED_OUT'].includes(run.status)
)
? calculateErrorFingerprint(run.error)
: '';
["SYSTEM_FAILURE", "CRASHED", "INTERRUPTED", "COMPLETED_WITH_ERRORS", "TIMED_OUT"].includes(
run.status
)
? calculateErrorFingerprint(run.error)
: "";
const annotations = this.#parseAnnotations(run.annotations);
@@ -979,7 +1052,6 @@ export class RunsReplicationService {
return { data: parsedData };
}
}
export type ConcurrentFlushSchedulerConfig<T> = {
@@ -1,9 +1,10 @@
import { ClickHouse } from "@internal/clickhouse";
import invariant from "tiny-invariant";
import { env } from "~/env.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { singleton } from "~/utils/singleton";
import { meter, provider } from "~/v3/tracer.server";
import { SessionsReplicationService } from "./sessionsReplicationService.server";
import { signalsEmitter } from "./signals.server";
export const sessionsReplicationInstance = singleton(
"sessionsReplicationInstance",
@@ -21,22 +22,8 @@ function initializeSessionsReplicationInstance() {
console.log("🗃️ Sessions replication service enabled");
const clickhouse = new ClickHouse({
url: env.SESSION_REPLICATION_CLICKHOUSE_URL,
name: "sessions-replication",
keepAlive: {
enabled: env.SESSION_REPLICATION_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.SESSION_REPLICATION_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.SESSION_REPLICATION_CLICKHOUSE_LOG_LEVEL,
compression: {
request: true,
},
maxOpenConnections: env.SESSION_REPLICATION_MAX_OPEN_CONNECTIONS,
});
const service = new SessionsReplicationService({
clickhouse: clickhouse,
clickhouseFactory,
pgConnectionUrl: DATABASE_URL,
serviceName: "sessions-replication",
slotName: env.SESSION_REPLICATION_SLOT_NAME,
@@ -68,5 +55,34 @@ function initializeSessionsReplicationInstance() {
insertStrategy: env.SESSION_REPLICATION_INSERT_STRATEGY,
});
if (env.SESSION_REPLICATION_ENABLED === "1") {
// Gate start() on the org data-stores registry being loaded. Starting earlier would
// race the registry load — sync factory lookups would return `null` and route org-scoped
// sessions to the default ClickHouse, writing them to the wrong cluster.
clickhouseFactory
.isReady()
.then(() => service.start())
.then(() => {
console.log("🗃️ Sessions replication service started");
})
.catch((error) => {
console.error("🗃️ Sessions replication service failed to start", {
error,
});
});
// SIGTERM/SIGINT fire during process teardown; wrap the async shutdown so an
// unhandled rejection doesn't bubble past process exit.
const shutdownSessionsReplication = () => {
service.shutdown().catch((error) => {
console.error("🗃️ Sessions replication service shutdown error", {
error,
});
});
};
signalsEmitter.on("SIGTERM", shutdownSessionsReplication);
signalsEmitter.on("SIGINT", shutdownSessionsReplication);
}
return service;
}
@@ -22,6 +22,7 @@ import { Logger, type LogLevel } from "@trigger.dev/core/logger";
import { tryCatch } from "@trigger.dev/core/utils";
import { type Session } from "@trigger.dev/database";
import EventEmitter from "node:events";
import type { ClickhouseFactory } from "~/services/clickhouse/clickhouseFactory.server";
import { ConcurrentFlushScheduler } from "./runsReplicationService.server";
interface TransactionEvent<T = any> {
@@ -40,7 +41,7 @@ interface Transaction<T = any> {
}
export type SessionsReplicationServiceOptions = {
clickhouse: ClickHouse;
clickhouseFactory: ClickhouseFactory;
pgConnectionUrl: string;
serviceName: string;
slotName: string;
@@ -537,11 +538,38 @@ export class SessionsReplicationService {
const flushStartTime = performance.now();
await startSpan(this._tracer, "flushBatch", async (span) => {
const sessionInserts = batch
.map((item) => toSessionInsertArray(item.session, item._version, item.event === "delete"))
// batch inserts in clickhouse are more performant if the items
// are pre-sorted by the primary key
.sort((a, b) => {
const routeCache = new Map<string, ClickHouse>();
const groups = new Map<ClickHouse, { sessionInserts: SessionInsertArray[] }>();
for (const item of batch) {
if (!item.session.organizationId) {
continue;
}
let client = routeCache.get(item.session.organizationId);
if (!client) {
client = this.options.clickhouseFactory.getClickhouseForOrganizationSync(
item.session.organizationId,
"sessions_replication"
);
routeCache.set(item.session.organizationId, client);
}
let group = groups.get(client);
if (!group) {
group = { sessionInserts: [] };
groups.set(client, group);
}
group.sessionInserts.push(
toSessionInsertArray(item.session, item._version, item.event === "delete")
);
}
// batch inserts in clickhouse are more performant if the items
// are pre-sorted by the primary key
const sortSessionInserts = (rows: SessionInsertArray[]) =>
rows.sort((a, b) => {
const aOrgId = getSessionField(a, "organization_id");
const bOrgId = getSessionField(b, "organization_id");
if (aOrgId !== bOrgId) {
@@ -568,19 +596,37 @@ export class SessionsReplicationService {
return aSessionId < bSessionId ? -1 : 1;
});
span.setAttribute("session_inserts", sessionInserts.length);
const combinedSessionInserts: SessionInsertArray[] = [];
let sessionError: Error | null = null;
// Sequential per-group flush — matches runsReplicationService for the same reason
// (parallel writes have hit Linux net.ipv4.tcp_wmem buffer pressure at high throughput).
for (const [clickhouse, group] of groups) {
sortSessionInserts(group.sessionInserts);
combinedSessionInserts.push(...group.sessionInserts);
const [insErr] = await this.#insertWithRetry(
(attempt) => this.#insertSessionInserts(clickhouse, group.sessionInserts, attempt),
"session inserts",
flushId
);
if (insErr && !sessionError) {
sessionError = insErr;
}
if (!insErr) {
this._sessionsInsertedCounter.add(group.sessionInserts.length);
}
}
span.setAttribute("session_inserts", combinedSessionInserts.length);
this.logger.debug("Flushing inserts", {
flushId,
sessionInserts: sessionInserts.length,
sessionInserts: combinedSessionInserts.length,
clickhouseGroups: groups.size,
});
const [sessionError, sessionResult] = await this.#insertWithRetry(
(attempt) => this.#insertSessionInserts(sessionInserts, attempt),
"session inserts",
flushId
);
if (sessionError) {
this.logger.error("Error inserting session inserts", {
error: sessionError,
@@ -591,22 +637,17 @@ export class SessionsReplicationService {
this.logger.debug("Flushed inserts", {
flushId,
sessionInserts: sessionInserts.length,
sessionInserts: combinedSessionInserts.length,
});
this.events.emit("batchFlushed", { flushId, sessionInserts });
this.events.emit("batchFlushed", { flushId, sessionInserts: combinedSessionInserts });
// Record metrics
const flushDurationMs = performance.now() - flushStartTime;
const hasErrors = sessionError !== null;
this._batchSizeHistogram.record(batch.length);
this._flushDurationHistogram.record(flushDurationMs);
this._batchesFlushedCounter.add(1, { success: !hasErrors });
if (!sessionError) {
this._sessionsInsertedCounter.add(sessionInserts.length);
}
});
}
@@ -706,14 +747,23 @@ export class SessionsReplicationService {
};
}
async #insertSessionInserts(sessionInserts: SessionInsertArray[], attempt: number) {
async #insertSessionInserts(
clickhouse: ClickHouse,
sessionInserts: SessionInsertArray[],
attempt: number
) {
if (sessionInserts.length === 0) {
return;
}
return await startSpan(this._tracer, "insertSessionInserts", async (span) => {
const [insertError, insertResult] =
await this.options.clickhouse.sessions.insertCompactArrays(sessionInserts, {
const [insertError, insertResult] = await clickhouse.sessions.insertCompactArrays(
sessionInserts,
{
params: {
clickhouse_settings: this.#getClickhouseInsertSettings(),
},
});
}
);
if (insertError) {
this.logger.error("Error inserting session inserts attempt", {
@@ -1,6 +1,7 @@
import type {
ClickHouse,
LlmMetricsV1Input,
MetricsV1Input,
TaskEventDetailedSummaryV1Result,
TaskEventDetailsV1Result,
TaskEventSummaryV1Result,
@@ -96,6 +97,10 @@ export type ClickhouseEventRepositoryConfig = {
llmMetricsFlushInterval?: number;
llmMetricsMaxBatchSize?: number;
llmMetricsMaxConcurrency?: number;
/** OTLP / task metrics_v1 flush scheduler config */
otlpMetricsBatchSize?: number;
otlpMetricsFlushInterval?: number;
otlpMetricsMaxConcurrency?: number;
};
/**
@@ -107,6 +112,7 @@ export class ClickhouseEventRepository implements IEventRepository {
private _config: ClickhouseEventRepositoryConfig;
private readonly _flushScheduler: DynamicFlushScheduler<TaskEventV1Input | TaskEventV2Input>;
private readonly _llmMetricsFlushScheduler: DynamicFlushScheduler<LlmMetricsV1Input>;
private readonly _otlpMetricsFlushScheduler: DynamicFlushScheduler<MetricsV1Input>;
private _tracer: Tracer;
private _version: "v1" | "v2";
/**
@@ -149,6 +155,15 @@ export class ClickhouseEventRepository implements IEventRepository {
memoryPressureThreshold: config.llmMetricsMaxBatchSize ?? 10000,
loadSheddingEnabled: false,
});
this._otlpMetricsFlushScheduler = new DynamicFlushScheduler({
batchSize: config.otlpMetricsBatchSize ?? 10000,
flushInterval: config.otlpMetricsFlushInterval ?? 1000,
callback: this.#flushOtelMetricsBatch.bind(this),
minConcurrency: 1,
maxConcurrency: config.otlpMetricsMaxConcurrency ?? 3,
loadSheddingEnabled: false,
});
}
get version() {
@@ -376,24 +391,42 @@ export class ClickhouseEventRepository implements IEventRepository {
typeof retryError === "object" && retryError !== null && "message" in retryError
? String((retryError as { message?: unknown }).message ?? "")
: String(retryError);
logger.error(
"Dropped batch after sanitize-retry still hit ClickHouse JSON parse error",
{
flushId,
contextLabel,
batchSize: rows.length,
permanentlyDroppedBatches: this._permanentlyDroppedBatches,
sampleRow: JSON.stringify(rows[0] ?? null).slice(0, 1024),
firstError: firstMessage.split("\n")[0],
retryError: retryMessage.split("\n")[0],
}
);
logger.error("Dropped batch after sanitize-retry still hit ClickHouse JSON parse error", {
flushId,
contextLabel,
batchSize: rows.length,
permanentlyDroppedBatches: this._permanentlyDroppedBatches,
sampleRow: JSON.stringify(rows[0] ?? null).slice(0, 1024),
firstError: firstMessage.split("\n")[0],
retryError: retryMessage.split("\n")[0],
});
return { kind: "dropped" };
}
}
}
async #flushOtelMetricsBatch(flushId: string, rows: MetricsV1Input[]) {
await startSpan(this._tracer, "flushOtelMetricsBatch", async (span) => {
span.setAttribute("flush_id", flushId);
span.setAttribute("row_count", rows.length);
const [insertError] = await this._clickhouse.metrics.insert(rows, {
params: {
clickhouse_settings: this.#getClickhouseInsertSettings(),
},
});
if (insertError) {
throw insertError;
}
logger.info("ClickhouseEventRepository.flushOtelMetricsBatch Inserted OTLP metrics batch", {
rows: rows.length,
});
});
}
#createLlmMetricsInput(event: CreateEventInput): LlmMetricsV1Input {
const llmMetrics = event._llmMetrics!;
@@ -452,7 +485,7 @@ export class ClickhouseEventRepository implements IEventRepository {
await tracePubSub.publish(events.map((e) => e.trace_id));
}
async insertMany(events: CreateEventInput[]): Promise<void> {
insertMany(events: CreateEventInput[]): void {
this.addToBatch(events.flatMap((event) => this.createEventToTaskEventV1Input(event)));
// Dual-write LLM metrics records for spans with cost enrichment
@@ -469,6 +502,11 @@ export class ClickhouseEventRepository implements IEventRepository {
this.insertMany(events);
}
insertManyMetrics(rows: MetricsV1Input[]): void {
if (rows.length === 0) return;
this._otlpMetricsFlushScheduler.addToBatch(rows);
}
private createEventToTaskEventV1Input(event: CreateEventInput): TaskEventV1Input[] {
return [
{
@@ -1,112 +0,0 @@
import { ClickHouse } from "@internal/clickhouse";
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
import { ClickhouseEventRepository } from "./clickhouseEventRepository.server";
export const clickhouseEventRepository = singleton(
"clickhouseEventRepository",
initializeClickhouseRepository
);
export const clickhouseEventRepositoryV2 = singleton(
"clickhouseEventRepositoryV2",
initializeClickhouseRepositoryV2
);
function getClickhouseClient() {
if (!env.EVENTS_CLICKHOUSE_URL) {
throw new Error("EVENTS_CLICKHOUSE_URL is not set");
}
const url = new URL(env.EVENTS_CLICKHOUSE_URL);
url.searchParams.delete("secure");
return new ClickHouse({
url: url.toString(),
name: "task-events",
keepAlive: {
enabled: env.EVENTS_CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.EVENTS_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.EVENTS_CLICKHOUSE_LOG_LEVEL,
compression: {
request: env.EVENTS_CLICKHOUSE_COMPRESSION_REQUEST === "1",
},
maxOpenConnections: env.EVENTS_CLICKHOUSE_MAX_OPEN_CONNECTIONS,
});
}
function initializeClickhouseRepository() {
if (!env.EVENTS_CLICKHOUSE_URL) {
throw new Error("EVENTS_CLICKHOUSE_URL is not set");
}
const url = new URL(env.EVENTS_CLICKHOUSE_URL);
url.searchParams.delete("secure");
const safeUrl = new URL(url.toString());
safeUrl.password = "redacted";
console.log("🗃️ Initializing Clickhouse event repository (v1)", { url: safeUrl.toString() });
const clickhouse = getClickhouseClient();
const repository = new ClickhouseEventRepository({
clickhouse: clickhouse,
batchSize: env.EVENTS_CLICKHOUSE_BATCH_SIZE,
flushInterval: env.EVENTS_CLICKHOUSE_FLUSH_INTERVAL_MS,
maximumTraceSummaryViewCount: env.EVENTS_CLICKHOUSE_MAX_TRACE_SUMMARY_VIEW_COUNT,
maximumTraceDetailedSummaryViewCount:
env.EVENTS_CLICKHOUSE_MAX_TRACE_DETAILED_SUMMARY_VIEW_COUNT,
maximumLiveReloadingSetting: env.EVENTS_CLICKHOUSE_MAX_LIVE_RELOADING_SETTING,
insertStrategy: env.EVENTS_CLICKHOUSE_INSERT_STRATEGY,
waitForAsyncInsert: env.EVENTS_CLICKHOUSE_WAIT_FOR_ASYNC_INSERT === "1",
asyncInsertMaxDataSize: env.EVENTS_CLICKHOUSE_ASYNC_INSERT_MAX_DATA_SIZE,
asyncInsertBusyTimeoutMs: env.EVENTS_CLICKHOUSE_ASYNC_INSERT_BUSY_TIMEOUT_MS,
startTimeMaxAgeMs: env.EVENTS_CLICKHOUSE_START_TIME_MAX_AGE_MS,
llmMetricsBatchSize: env.LLM_METRICS_BATCH_SIZE,
llmMetricsFlushInterval: env.LLM_METRICS_FLUSH_INTERVAL_MS,
llmMetricsMaxBatchSize: env.LLM_METRICS_MAX_BATCH_SIZE,
llmMetricsMaxConcurrency: env.LLM_METRICS_MAX_CONCURRENCY,
version: "v1",
});
return repository;
}
function initializeClickhouseRepositoryV2() {
if (!env.EVENTS_CLICKHOUSE_URL) {
throw new Error("EVENTS_CLICKHOUSE_URL is not set");
}
const url = new URL(env.EVENTS_CLICKHOUSE_URL);
url.searchParams.delete("secure");
const safeUrl = new URL(url.toString());
safeUrl.password = "redacted";
console.log("🗃️ Initializing Clickhouse event repository (v2)", { url: safeUrl.toString() });
const clickhouse = getClickhouseClient();
const repository = new ClickhouseEventRepository({
clickhouse: clickhouse,
batchSize: env.EVENTS_CLICKHOUSE_BATCH_SIZE,
flushInterval: env.EVENTS_CLICKHOUSE_FLUSH_INTERVAL_MS,
maximumTraceSummaryViewCount: env.EVENTS_CLICKHOUSE_MAX_TRACE_SUMMARY_VIEW_COUNT,
maximumTraceDetailedSummaryViewCount:
env.EVENTS_CLICKHOUSE_MAX_TRACE_DETAILED_SUMMARY_VIEW_COUNT,
maximumLiveReloadingSetting: env.EVENTS_CLICKHOUSE_MAX_LIVE_RELOADING_SETTING,
insertStrategy: env.EVENTS_CLICKHOUSE_INSERT_STRATEGY,
waitForAsyncInsert: env.EVENTS_CLICKHOUSE_WAIT_FOR_ASYNC_INSERT === "1",
asyncInsertMaxDataSize: env.EVENTS_CLICKHOUSE_ASYNC_INSERT_MAX_DATA_SIZE,
asyncInsertBusyTimeoutMs: env.EVENTS_CLICKHOUSE_ASYNC_INSERT_BUSY_TIMEOUT_MS,
llmMetricsBatchSize: env.LLM_METRICS_BATCH_SIZE,
llmMetricsFlushInterval: env.LLM_METRICS_FLUSH_INTERVAL_MS,
llmMetricsMaxBatchSize: env.LLM_METRICS_MAX_BATCH_SIZE,
llmMetricsMaxConcurrency: env.LLM_METRICS_MAX_CONCURRENCY,
version: "v2",
});
return repository;
}
@@ -18,6 +18,7 @@ import {
unflattenAttributes,
} from "@trigger.dev/core/v3";
import { serializeTraceparent } from "@trigger.dev/core/v3/isomorphic";
import type { MetricsV1Input } from "@internal/clickhouse";
import { Prisma, TaskEvent, TaskEventKind } from "@trigger.dev/database";
import { nanoid } from "nanoid";
import { Gauge } from "prom-client";
@@ -151,7 +152,7 @@ export class EventRepository implements IEventRepository {
await this.#flushBatch(nanoid(), [this.#createableEventToPrismaEvent(event)]);
}
async insertMany(events: CreateEventInput[]) {
insertMany(events: CreateEventInput[]) {
this._flushScheduler.addToBatch(events.map(this.#createableEventToPrismaEvent));
}
@@ -159,6 +160,8 @@ export class EventRepository implements IEventRepository {
await this.#flushBatchWithReturn(nanoid(), events.map(this.#createableEventToPrismaEvent));
}
insertManyMetrics(_rows: MetricsV1Input[]): void {}
async completeSuccessfulRunEvent({ run, endTime }: { run: CompleteableTaskRun; endTime?: Date }) {
const startTime = convertDateToNanoseconds(run.createdAt);
@@ -14,6 +14,7 @@ import type {
TaskEventStatus,
TaskRun,
} from "@trigger.dev/database";
import type { MetricsV1Input } from "@internal/clickhouse";
import type { DetailedTraceEvent, TaskEventStoreTable } from "../taskEventStore.server";
export type { ExceptionEventProperties };
@@ -345,8 +346,9 @@ export type TraceDetailedSummary = {
export interface IEventRepository {
maximumLiveReloadingSetting: number;
// Event insertion methods
insertMany(events: CreateEventInput[]): Promise<void>;
insertMany(events: CreateEventInput[]): void;
insertManyImmediate(events: CreateEventInput[]): Promise<void>;
insertManyMetrics(rows: MetricsV1Input[]): void;
// Run event completion methods
completeSuccessfulRunEvent(params: { run: CompleteableTaskRun; endTime?: Date }): Promise<void>;
@@ -1,29 +1,12 @@
import { env } from "~/env.server";
import { eventRepository } from "./eventRepository.server";
import {
clickhouseEventRepository,
clickhouseEventRepositoryV2,
} from "./clickhouseEventRepositoryInstance.server";
import { IEventRepository, TraceEventOptions } from "./eventRepository.types";
import { type IEventRepository, type TraceEventOptions } from "./eventRepository.types";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { FEATURE_FLAG } from "../featureFlags";
import { flag } from "../featureFlags.server";
import { getTaskEventStore } from "../taskEventStore.server";
export function resolveEventRepositoryForStore(store: string | undefined): IEventRepository {
const taskEventStore = store ?? env.EVENT_REPOSITORY_DEFAULT_STORE;
if (taskEventStore === "clickhouse_v2") {
return clickhouseEventRepositoryV2;
}
if (taskEventStore === "clickhouse") {
return clickhouseEventRepository;
}
return eventRepository;
}
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
export const EVENT_STORE_TYPES = {
POSTGRES: "postgres",
@@ -33,6 +16,50 @@ export const EVENT_STORE_TYPES = {
export type EventStoreType = (typeof EVENT_STORE_TYPES)[keyof typeof EVENT_STORE_TYPES];
/**
* Resolve the event repository for a run's persisted `taskEventStore` value and org.
* Postgres-backed runs use the Prisma `eventRepository`; ClickHouse-backed runs use
* `clickhouseFactory.getEventRepositoryForOrganizationSync`.
*
* Intentionally NOT exported. Sync resolution can race the org data-stores
* registry load and silently route writes to the default ClickHouse instead of
* the org's configured override. Hot paths that genuinely cannot afford to await
* (OTEL exporter, replication services) call `clickhouseFactory.getEvent…Sync`
* directly and gate startup on `clickhouseFactory.isReady()`. Everything else
* should use {@link getEventRepositoryForStore}, the async variant below.
*/
function resolveEventRepositoryForStore(
store: string,
organizationId: string
): IEventRepository {
if (store === EVENT_STORE_TYPES.CLICKHOUSE || store === EVENT_STORE_TYPES.CLICKHOUSE_V2) {
return clickhouseFactory.getEventRepositoryForOrganizationSync(store, organizationId)
.repository;
}
return eventRepository;
}
/**
* Async variant of {@link resolveEventRepositoryForStore}. Awaits the factory's
* registry readiness before returning the ClickHouse event repository; for
* non-ClickHouse stores (e.g. the "taskEvent" DB default for Postgres-backed
* runs) it returns the Prisma event repository without ever touching the
* factory so the factory never needs to know about Postgres.
*/
export async function getEventRepositoryForStore(
store: string,
organizationId: string
): Promise<IEventRepository> {
if (store !== EVENT_STORE_TYPES.CLICKHOUSE && store !== EVENT_STORE_TYPES.CLICKHOUSE_V2) {
return eventRepository;
}
const { repository } = await clickhouseFactory.getEventRepositoryForOrganization(
store,
organizationId
);
return repository;
}
export async function getConfiguredEventRepository(
organizationId: string
): Promise<{ repository: IEventRepository; store: EventStoreType }> {
@@ -59,62 +86,79 @@ export async function getConfiguredEventRepository(
);
if (taskEventStore === EVENT_STORE_TYPES.CLICKHOUSE_V2) {
return { repository: clickhouseEventRepositoryV2, store: EVENT_STORE_TYPES.CLICKHOUSE_V2 };
const { repository: resolvedRepository } =
await clickhouseFactory.getEventRepositoryForOrganization(taskEventStore, organizationId);
return { repository: resolvedRepository, store: EVENT_STORE_TYPES.CLICKHOUSE_V2 };
}
if (taskEventStore === EVENT_STORE_TYPES.CLICKHOUSE) {
return { repository: clickhouseEventRepository, store: EVENT_STORE_TYPES.CLICKHOUSE };
const { repository: resolvedRepository } =
await clickhouseFactory.getEventRepositoryForOrganization(taskEventStore, organizationId);
return { repository: resolvedRepository, store: EVENT_STORE_TYPES.CLICKHOUSE };
}
return { repository: eventRepository, store: EVENT_STORE_TYPES.POSTGRES };
}
export async function getEventRepository(
organizationId: string,
featureFlags: Record<string, unknown> | undefined,
parentStore: string | undefined
): Promise<{ repository: IEventRepository; store: string }> {
if (typeof parentStore === "string") {
if (parentStore === "clickhouse_v2") {
return { repository: clickhouseEventRepositoryV2, store: "clickhouse_v2" };
const taskEventStore = parentStore ?? (await resolveTaskEventRepositoryFlag(featureFlags));
// Non-ClickHouse stores (e.g. the "taskEvent" DB default for Postgres-backed
// runs, or the legacy "postgres" value) resolve to the Prisma event repo.
if (
taskEventStore !== EVENT_STORE_TYPES.CLICKHOUSE &&
taskEventStore !== EVENT_STORE_TYPES.CLICKHOUSE_V2
) {
return { repository: eventRepository, store: getTaskEventStore() };
}
const { repository: resolvedRepository } =
await clickhouseFactory.getEventRepositoryForOrganization(taskEventStore, organizationId);
switch (taskEventStore) {
case EVENT_STORE_TYPES.CLICKHOUSE_V2: {
return { repository: resolvedRepository, store: EVENT_STORE_TYPES.CLICKHOUSE_V2 };
}
if (parentStore === "clickhouse") {
return { repository: clickhouseEventRepository, store: "clickhouse" };
} else {
case EVENT_STORE_TYPES.CLICKHOUSE: {
return { repository: resolvedRepository, store: EVENT_STORE_TYPES.CLICKHOUSE };
}
default: {
return { repository: eventRepository, store: getTaskEventStore() };
}
}
const taskEventRepository = await resolveTaskEventRepositoryFlag(featureFlags);
if (taskEventRepository === "clickhouse_v2") {
return { repository: clickhouseEventRepositoryV2, store: "clickhouse_v2" };
}
if (taskEventRepository === "clickhouse") {
return { repository: clickhouseEventRepository, store: "clickhouse" };
}
return { repository: eventRepository, store: getTaskEventStore() };
}
export async function getV3EventRepository(
organizationId: string,
parentStore: string | undefined
): Promise<{ repository: IEventRepository; store: string }> {
if (typeof parentStore === "string") {
if (parentStore === "clickhouse_v2") {
return { repository: clickhouseEventRepositoryV2, store: "clickhouse_v2" };
}
if (parentStore === "clickhouse") {
return { repository: clickhouseEventRepository, store: "clickhouse" };
} else {
return { repository: eventRepository, store: getTaskEventStore() };
// Support legacy Postgres store for self-hosters and runs persisted with a
// non-ClickHouse store — fall back to the Prisma-based event repository.
if (
parentStore !== EVENT_STORE_TYPES.CLICKHOUSE &&
parentStore !== EVENT_STORE_TYPES.CLICKHOUSE_V2
) {
return { repository: eventRepository, store: parentStore };
}
const { repository: resolvedRepository } =
await clickhouseFactory.getEventRepositoryForOrganization(parentStore, organizationId);
return { repository: resolvedRepository, store: parentStore };
}
if (env.EVENT_REPOSITORY_DEFAULT_STORE === "clickhouse_v2") {
return { repository: clickhouseEventRepositoryV2, store: "clickhouse_v2" };
const { repository: resolvedRepository } =
await clickhouseFactory.getEventRepositoryForOrganization("clickhouse_v2", organizationId);
return { repository: resolvedRepository, store: "clickhouse_v2" };
} else if (env.EVENT_REPOSITORY_DEFAULT_STORE === "clickhouse") {
return { repository: clickhouseEventRepository, store: "clickhouse" };
const { repository: resolvedRepository } =
await clickhouseFactory.getEventRepositoryForOrganization("clickhouse", organizationId);
return { repository: resolvedRepository, store: "clickhouse" };
} else {
return { repository: eventRepository, store: getTaskEventStore() };
}
@@ -203,7 +247,10 @@ async function recordRunEvent(
};
}
const $eventRepository = resolveEventRepositoryForStore(foundRun.taskEventStore);
const $eventRepository = await getEventRepositoryForStore(
foundRun.taskEventStore,
foundRun.runtimeEnvironment.organizationId
);
const { attributes, startTime, ...optionsRest } = options;
+89 -71
View File
@@ -20,15 +20,10 @@ import {
} from "@trigger.dev/otlp-importer";
import type { MetricsV1Input } from "@internal/clickhouse";
import { logger } from "~/services/logger.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { DynamicFlushScheduler } from "./dynamicFlushScheduler.server";
import { ClickhouseEventRepository } from "./eventRepository/clickhouseEventRepository.server";
import {
clickhouseEventRepository,
clickhouseEventRepositoryV2,
} from "./eventRepository/clickhouseEventRepositoryInstance.server";
import type { ClickhouseFactory } from "~/services/clickhouse/clickhouseFactory.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { generateSpanId } from "./eventRepository/common.server";
import { EventRepository, eventRepository } from "./eventRepository/eventRepository.server";
import type {
CreatableEventKind,
CreatableEventStatus,
@@ -41,18 +36,23 @@ import { waitForLlmPricingReady } from "./llmPricingRegistry.server";
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
type OTLPExporterConfig = {
clickhouseFactory: ClickhouseFactory;
verbose: boolean;
spanAttributeValueLengthLimit: number;
};
class OTLPExporter {
private _tracer: Tracer;
private readonly _clickhouseFactory: ClickhouseFactory;
private readonly _verbose: boolean;
private readonly _spanAttributeValueLengthLimit: number;
constructor(
private readonly _eventRepository: EventRepository,
private readonly _clickhouseEventRepository: ClickhouseEventRepository,
private readonly _clickhouseEventRepositoryV2: ClickhouseEventRepository,
private readonly _metricsFlushScheduler: DynamicFlushScheduler<MetricsV1Input>,
private readonly _verbose: boolean,
private readonly _spanAttributeValueLengthLimit: number
) {
constructor(config: OTLPExporterConfig) {
this._tracer = trace.getTracer("otlp-exporter");
this._clickhouseFactory = config.clickhouseFactory;
this._verbose = config.verbose;
this._spanAttributeValueLengthLimit = config.spanAttributeValueLengthLimit;
}
async exportTraces(request: ExportTraceServiceRequest): Promise<ExportTraceServiceResponse> {
@@ -73,23 +73,16 @@ class OTLPExporter {
});
}
async exportMetrics(
request: ExportMetricsServiceRequest
): Promise<ExportMetricsServiceResponse> {
async exportMetrics(request: ExportMetricsServiceRequest): Promise<ExportMetricsServiceResponse> {
return await startSpan(this._tracer, "exportMetrics", async (span) => {
const rows = this.#filterResourceMetrics(request.resourceMetrics).flatMap(
(resourceMetrics) => {
return convertMetricsToClickhouseRows(
resourceMetrics,
this._spanAttributeValueLengthLimit
);
}
const rows = this.#filterResourceMetrics(request.resourceMetrics).flatMap((resourceMetrics) =>
convertMetricsToClickhouseRows(resourceMetrics, this._spanAttributeValueLengthLimit)
);
span.setAttribute("metric_row_count", rows.length);
if (rows.length > 0) {
this._metricsFlushScheduler.addToBatch(rows);
await this.#exportMetricRows(rows);
}
return ExportMetricsServiceResponse.create();
@@ -117,40 +110,73 @@ class OTLPExporter {
async #exportEvents(
eventsWithStores: { events: Array<CreateEventInput>; taskEventStore: string }[]
) {
const eventsGroupedByStore = eventsWithStores.reduce((acc, { events, taskEventStore }) => {
acc[taskEventStore] = acc[taskEventStore] || [];
acc[taskEventStore].push(...events);
return acc;
}, {} as Record<string, Array<CreateEventInput>>);
await waitForLlmPricingReady();
// Group by unique event repositories
const routeCache = new Map<string, { key: string; repository: IEventRepository }>();
const groups = new Map<string, { repository: IEventRepository; events: CreateEventInput[] }>();
for (const { events, taskEventStore } of eventsWithStores) {
for (const event of events) {
const routeKey = `${event.organizationId}\0${taskEventStore}`;
let resolved = routeCache.get(routeKey);
if (!resolved) {
resolved = this._clickhouseFactory.getEventRepositoryForOrganizationSync(
taskEventStore,
event.organizationId
);
routeCache.set(routeKey, resolved);
}
let group = groups.get(resolved.key);
if (!group) {
group = { repository: resolved.repository, events: [] };
groups.set(resolved.key, group);
}
group.events.push(event);
}
}
let eventCount = 0;
for (const [store, events] of Object.entries(eventsGroupedByStore)) {
const eventRepository = this.#getEventRepositoryForStore(store);
await waitForLlmPricingReady();
for (const [repoKey, { repository, events }] of groups) {
const enrichedEvents = enrichCreatableEvents(events);
this.#logEventsVerbose(enrichedEvents, `exportEvents ${store}`);
this.#logEventsVerbose(enrichedEvents, `exportEvents ${repoKey}`);
eventCount += enrichedEvents.length;
await eventRepository.insertMany(enrichedEvents);
repository.insertMany(enrichedEvents);
}
return eventCount;
}
#getEventRepositoryForStore(store: string): IEventRepository {
if (store === "clickhouse") {
return this._clickhouseEventRepository;
async #exportMetricRows(rows: MetricsV1Input[]): Promise<void> {
const routeCache = new Map<string, { key: string; repository: IEventRepository }>();
const groups = new Map<string, { repository: IEventRepository; rows: MetricsV1Input[] }>();
for (const row of rows) {
const routeKey = row.organization_id;
let resolved = routeCache.get(routeKey);
if (!resolved) {
resolved = this._clickhouseFactory.getEventRepositoryForOrganizationSync(
"clickhouse_v2",
row.organization_id
);
routeCache.set(routeKey, resolved);
}
let group = groups.get(resolved.key);
if (!group) {
group = { repository: resolved.repository, rows: [] };
groups.set(resolved.key, group);
}
group.rows.push(row);
}
if (store === "clickhouse_v2") {
return this._clickhouseEventRepositoryV2;
for (const [, { repository, rows: groupedRows }] of groups) {
repository.insertManyMetrics(groupedRows);
}
return this._eventRepository;
}
#logEventsVerbose(events: CreateEventInput[], prefix: string) {
@@ -392,7 +418,10 @@ function convertSpansToCreateableEvents(
SemanticInternalAttributes.METADATA
);
const runTags = extractArrayAttribute(span.attributes ?? [], SemanticInternalAttributes.RUN_TAGS);
const runTags = extractArrayAttribute(
span.attributes ?? [],
SemanticInternalAttributes.RUN_TAGS
);
const properties =
truncateAttributes(
@@ -463,7 +492,10 @@ function floorToTenSecondBucket(timeUnixNano: bigint | number): string {
const flooredMs = Math.floor(epochMs / 10_000) * 10_000;
const date = new Date(flooredMs);
// Format as ClickHouse DateTime: YYYY-MM-DD HH:MM:SS
return date.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "");
return date
.toISOString()
.replace("T", " ")
.replace(/\.\d{3}Z$/, "");
}
function convertMetricsToClickhouseRows(
@@ -583,8 +615,7 @@ function resolveDataPointContext(
attributes: Record<string, unknown>;
} {
const runId =
resourceCtx.runId ??
extractStringAttribute(dpAttributes, SemanticInternalAttributes.RUN_ID);
resourceCtx.runId ?? extractStringAttribute(dpAttributes, SemanticInternalAttributes.RUN_ID);
const taskSlug =
resourceCtx.taskSlug ??
extractStringAttribute(dpAttributes, SemanticInternalAttributes.TASK_SLUG);
@@ -1171,26 +1202,13 @@ function hasUnpairedSurrogateAtEnd(str: string): boolean {
export const otlpExporter = singleton("otlpExporter", initializeOTLPExporter);
function initializeOTLPExporter() {
const metricsFlushScheduler = new DynamicFlushScheduler<MetricsV1Input>({
batchSize: env.METRICS_CLICKHOUSE_BATCH_SIZE,
flushInterval: env.METRICS_CLICKHOUSE_FLUSH_INTERVAL_MS,
callback: async (_flushId, batch) => {
await clickhouseClient.metrics.insert(batch);
},
minConcurrency: 1,
maxConcurrency: env.METRICS_CLICKHOUSE_MAX_CONCURRENCY,
loadSheddingEnabled: false,
});
return new OTLPExporter(
eventRepository,
clickhouseEventRepository,
clickhouseEventRepositoryV2,
metricsFlushScheduler,
process.env.OTLP_EXPORTER_VERBOSE === "1",
process.env.SERVER_OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT
async function initializeOTLPExporter() {
await clickhouseFactory.isReady();
return new OTLPExporter({
clickhouseFactory,
verbose: process.env.OTLP_EXPORTER_VERBOSE === "1",
spanAttributeValueLengthLimit: process.env.SERVER_OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT
? parseInt(process.env.SERVER_OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT, 10)
: 8192
);
}
: 8192,
});
}
+282 -244
View File
@@ -17,17 +17,14 @@ import { QueueSizeLimitExceededError } from "~/v3/services/common.server";
import { TriggerTaskService } from "~/v3/services/triggerTask.server";
import { tracer } from "~/v3/tracer.server";
import { createExceptionPropertiesFromError } from "./eventRepository/common.server";
import {
recordRunDebugLog,
resolveEventRepositoryForStore,
} from "./eventRepository/index.server";
import { getEventRepositoryForStore, recordRunDebugLog } from "./eventRepository/index.server";
import { roomFromFriendlyRunId, socketIo } from "./handleSocketIo.server";
import { engine } from "./runEngine.server";
import { PerformTaskRunAlertsService } from "./services/alerts/performTaskRunAlerts.server";
import { TaskRunErrorCodes } from "@trigger.dev/core/v3";
export function registerRunEngineEventBusHandlers() {
engine.eventBus.on("runSucceeded", async ({ time, run }) => {
engine.eventBus.on("runSucceeded", async ({ time, run, organization }) => {
const [taskRunError, taskRun] = await tryCatch(
$replica.taskRun.findFirstOrThrow({
where: {
@@ -60,7 +57,10 @@ export function registerRunEngineEventBusHandlers() {
return;
}
const eventRepository = resolveEventRepositoryForStore(run.taskEventStore);
const eventRepository = await getEventRepositoryForStore(
run.taskEventStore,
taskRun.organizationId ?? organization.id
);
const [completeSuccessfulRunEventError] = await tryCatch(
eventRepository.completeSuccessfulRunEvent({
@@ -91,7 +91,7 @@ export function registerRunEngineEventBusHandlers() {
});
// Handle events
engine.eventBus.on("runFailed", async ({ time, run }) => {
engine.eventBus.on("runFailed", async ({ time, run, organization }) => {
const sanitizedError = sanitizeError(run.error);
const exception = createExceptionPropertiesFromError(sanitizedError);
@@ -127,7 +127,10 @@ export function registerRunEngineEventBusHandlers() {
return;
}
const eventRepository = resolveEventRepositoryForStore(taskRun.taskEventStore);
const eventRepository = await getEventRepositoryForStore(
run.taskEventStore,
taskRun.organizationId ?? organization.id
);
const [completeFailedRunEventError] = await tryCatch(
eventRepository.completeFailedRunEvent({
@@ -181,7 +184,17 @@ export function registerRunEngineEventBusHandlers() {
return;
}
const eventRepository = resolveEventRepositoryForStore(taskRun.taskEventStore);
if (!taskRun.organizationId) {
logger.error("[runAttemptFailed] Task run has no organization id", {
runId: run.id,
});
return;
}
const eventRepository = await getEventRepositoryForStore(
run.taskEventStore,
taskRun.organizationId
);
const [createAttemptFailedRunEventError] = await tryCatch(
eventRepository.createAttemptFailedRunEvent({
@@ -282,7 +295,17 @@ export function registerRunEngineEventBusHandlers() {
return;
}
const eventRepository = resolveEventRepositoryForStore(blockedRun.taskEventStore);
if (!blockedRun.organizationId) {
logger.error("[cachedRunCompleted] Blocked run has no organization id", {
blockedRunId,
});
return;
}
const eventRepository = await getEventRepositoryForStore(
blockedRun.taskEventStore,
blockedRun.organizationId
);
const [completeCachedRunEventError] = await tryCatch(
eventRepository.completeCachedRunEvent({
@@ -305,7 +328,7 @@ export function registerRunEngineEventBusHandlers() {
}
);
engine.eventBus.on("runExpired", async ({ time, run }) => {
engine.eventBus.on("runExpired", async ({ time, run, organization }) => {
if (!run.ttl) {
return;
}
@@ -342,7 +365,10 @@ export function registerRunEngineEventBusHandlers() {
return;
}
const eventRepository = resolveEventRepositoryForStore(taskRun.taskEventStore);
const eventRepository = await getEventRepositoryForStore(
taskRun.taskEventStore,
taskRun.organizationId ?? organization.id
);
const [completeExpiredRunEventError] = await tryCatch(
eventRepository.completeExpiredRunEvent({
@@ -360,7 +386,7 @@ export function registerRunEngineEventBusHandlers() {
}
});
engine.eventBus.on("runCancelled", async ({ time, run }) => {
engine.eventBus.on("runCancelled", async ({ time, run, organization }) => {
const [taskRunError, taskRun] = await tryCatch(
$replica.taskRun.findFirstOrThrow({
where: {
@@ -393,7 +419,10 @@ export function registerRunEngineEventBusHandlers() {
return;
}
const eventRepository = resolveEventRepositoryForStore(taskRun.taskEventStore);
const eventRepository = await getEventRepositoryForStore(
taskRun.taskEventStore,
taskRun.organizationId ?? organization.id
);
const error = createJsonErrorObject(run.error);
@@ -413,46 +442,53 @@ export function registerRunEngineEventBusHandlers() {
}
});
engine.eventBus.on("runRetryScheduled", async ({ time, run, environment, retryAt }) => {
try {
if (retryAt && time && time >= retryAt) {
return;
}
engine.eventBus.on(
"runRetryScheduled",
async ({ time, run, environment, retryAt, organization }) => {
try {
if (retryAt && time && time >= retryAt) {
return;
}
let retryMessage = `Retry ${typeof run.attemptNumber === "number" ? `#${run.attemptNumber - 1}` : ""
let retryMessage = `Retry ${
typeof run.attemptNumber === "number" ? `#${run.attemptNumber - 1}` : ""
} delay`;
if (run.nextMachineAfterOOM) {
retryMessage += ` after OOM`;
if (run.nextMachineAfterOOM) {
retryMessage += ` after OOM`;
}
const eventRepository = await getEventRepositoryForStore(
run.taskEventStore ?? "taskEvent",
organization.id
);
await eventRepository.recordEvent(retryMessage, {
startTime: BigInt(time.getTime() * 1000000),
taskSlug: run.taskIdentifier,
environment,
attributes: {
properties: {
retryAt: retryAt.toISOString(),
nextMachine: run.nextMachineAfterOOM,
},
runId: run.friendlyId,
style: {
icon: "schedule-attempt",
},
},
context: run.traceContext as Record<string, string | undefined>,
endTime: retryAt,
});
} catch (error) {
logger.error("[runRetryScheduled] Failed to record retry event", {
error: error instanceof Error ? error.message : error,
runId: run.id,
spanId: run.spanId,
});
}
const eventRepository = resolveEventRepositoryForStore(run.taskEventStore);
await eventRepository.recordEvent(retryMessage, {
startTime: BigInt(time.getTime() * 1000000),
taskSlug: run.taskIdentifier,
environment,
attributes: {
properties: {
retryAt: retryAt.toISOString(),
nextMachine: run.nextMachineAfterOOM,
},
runId: run.friendlyId,
style: {
icon: "schedule-attempt",
},
},
context: run.traceContext as Record<string, string | undefined>,
endTime: retryAt,
});
} catch (error) {
logger.error("[runRetryScheduled] Failed to record retry event", {
error: error instanceof Error ? error.message : error,
runId: run.id,
spanId: run.spanId,
});
}
});
);
engine.eventBus.on("runAttemptStarted", async ({ time, run, organization }) => {
try {
@@ -485,10 +521,10 @@ export function registerRunEngineEventBusHandlers() {
error:
e instanceof Error
? {
name: e.name,
message: e.message,
stack: e.stack,
}
name: e.name,
message: e.message,
stack: e.stack,
}
: e,
});
} else {
@@ -497,10 +533,10 @@ export function registerRunEngineEventBusHandlers() {
error:
e instanceof Error
? {
name: e.name,
message: e.message,
stack: e.stack,
}
name: e.name,
message: e.message,
stack: e.stack,
}
: e,
});
}
@@ -658,121 +694,200 @@ const QUEUE_SIZE_LIMIT_EXCEEDED_ERROR_CODE = "QUEUE_SIZE_LIMIT_EXCEEDED";
*/
export function setupBatchQueueCallbacks() {
// Item processing callback - creates a run for each batch item
engine.setBatchProcessItemCallback(async ({ batchId, friendlyId, itemIndex, item, meta, attempt, isFinalAttempt }) => {
return tracer.startActiveSpan(
"batch.processItem",
{
kind: SpanKind.INTERNAL,
attributes: {
"batch.id": friendlyId,
"batch.item_index": itemIndex,
"batch.task": item.task,
"batch.environment_id": meta.environmentId,
"batch.parent_run_id": meta.parentRunId ?? "",
"batch.attempt": attempt,
"batch.is_final_attempt": isFinalAttempt,
engine.setBatchProcessItemCallback(
async ({ batchId, friendlyId, itemIndex, item, meta, attempt, isFinalAttempt }) => {
return tracer.startActiveSpan(
"batch.processItem",
{
kind: SpanKind.INTERNAL,
attributes: {
"batch.id": friendlyId,
"batch.item_index": itemIndex,
"batch.task": item.task,
"batch.environment_id": meta.environmentId,
"batch.parent_run_id": meta.parentRunId ?? "",
"batch.attempt": attempt,
"batch.is_final_attempt": isFinalAttempt,
},
},
},
async (span) => {
const triggerFailedTaskService = new TriggerFailedTaskService({
prisma,
engine,
replicaPrisma: $replica,
});
async (span) => {
const triggerFailedTaskService = new TriggerFailedTaskService({
prisma,
engine,
replicaPrisma: $replica,
});
// Check for pre-marked error items (e.g. oversized payloads)
const itemError = item.options?.__error as string | undefined;
if (itemError) {
const errorCode = (item.options?.__errorCode as string) ?? "ITEM_ERROR";
// Check for pre-marked error items (e.g. oversized payloads)
const itemError = item.options?.__error as string | undefined;
if (itemError) {
const errorCode = (item.options?.__errorCode as string) ?? "ITEM_ERROR";
let environment: AuthenticatedEnvironment | undefined;
try {
environment = (await findEnvironmentById(meta.environmentId)) ?? undefined;
} catch {
// Best-effort environment lookup
}
if (environment) {
const failedRunId = await triggerFailedTaskService.call({
taskId: item.task,
environment,
payload: item.payload ?? "{}",
payloadType: item.payloadType as string,
errorMessage: itemError,
errorCode: errorCode as TaskRunErrorCodes,
parentRunId: meta.parentRunId,
resumeParentOnCompletion: meta.resumeParentOnCompletion,
batch: { id: batchId, index: itemIndex },
traceContext: meta.traceContext as Record<string, unknown> | undefined,
spanParentAsLink: meta.spanParentAsLink,
});
if (failedRunId) {
span.setAttribute("batch.result.pre_failed", true);
span.setAttribute("batch.result.run_id", failedRunId);
span.end();
return { success: true as const, runId: failedRunId };
}
}
// Fallback if TriggerFailedTaskService or environment lookup fails
span.end();
return { success: false as const, error: itemError, errorCode };
}
let environment: AuthenticatedEnvironment | undefined;
try {
environment = (await findEnvironmentById(meta.environmentId)) ?? undefined;
} catch {
// Best-effort environment lookup
}
if (environment) {
const failedRunId = await triggerFailedTaskService.call({
taskId: item.task,
environment,
payload: item.payload ?? "{}",
payloadType: item.payloadType as string,
errorMessage: itemError,
errorCode: errorCode as TaskRunErrorCodes,
parentRunId: meta.parentRunId,
resumeParentOnCompletion: meta.resumeParentOnCompletion,
batch: { id: batchId, index: itemIndex },
traceContext: meta.traceContext as Record<string, unknown> | undefined,
spanParentAsLink: meta.spanParentAsLink,
});
if (failedRunId) {
span.setAttribute("batch.result.pre_failed", true);
span.setAttribute("batch.result.run_id", failedRunId);
if (!environment) {
span.setAttribute("batch.result.error", "Environment not found");
span.end();
return { success: true as const, runId: failedRunId };
return {
success: false as const,
error: "Environment not found",
errorCode: "ENVIRONMENT_NOT_FOUND",
};
}
}
// Fallback if TriggerFailedTaskService or environment lookup fails
span.end();
return { success: false as const, error: itemError, errorCode };
}
const triggerTaskService = new TriggerTaskService();
let environment: AuthenticatedEnvironment | undefined;
try {
environment = (await findEnvironmentById(meta.environmentId)) ?? undefined;
// Normalize payload - for application/store (R2 paths), this passes through as-is
const payload = normalizePayload(item.payload, item.payloadType);
if (!environment) {
span.setAttribute("batch.result.error", "Environment not found");
span.end();
return {
success: false as const,
error: "Environment not found",
errorCode: "ENVIRONMENT_NOT_FOUND",
};
}
const triggerTaskService = new TriggerTaskService();
// Normalize payload - for application/store (R2 paths), this passes through as-is
const payload = normalizePayload(item.payload, item.payloadType);
const result = await triggerTaskService.call(
item.task,
environment,
{
payload,
options: {
...(item.options as Record<string, unknown>),
payloadType: item.payloadType,
parentRunId: meta.parentRunId,
resumeParentOnCompletion: meta.resumeParentOnCompletion,
parentBatch: batchId,
const result = await triggerTaskService.call(
item.task,
environment,
{
payload,
options: {
...(item.options as Record<string, unknown>),
payloadType: item.payloadType,
parentRunId: meta.parentRunId,
resumeParentOnCompletion: meta.resumeParentOnCompletion,
parentBatch: batchId,
},
},
},
{
triggerVersion: meta.triggerVersion,
traceContext: meta.traceContext as Record<string, unknown> | undefined,
spanParentAsLink: meta.spanParentAsLink,
batchId,
batchIndex: itemIndex,
realtimeStreamsVersion: meta.realtimeStreamsVersion,
planType: meta.planType,
triggerSource: meta.parentRunId ? "sdk" : meta.triggerSource ?? "api",
triggerAction: "trigger",
},
"V2"
);
{
triggerVersion: meta.triggerVersion,
traceContext: meta.traceContext as Record<string, unknown> | undefined,
spanParentAsLink: meta.spanParentAsLink,
batchId,
batchIndex: itemIndex,
realtimeStreamsVersion: meta.realtimeStreamsVersion,
planType: meta.planType,
triggerSource: meta.parentRunId ? "sdk" : meta.triggerSource ?? "api",
triggerAction: "trigger",
},
"V2"
);
if (result) {
span.setAttribute("batch.result.run_id", result.run.friendlyId);
span.end();
return { success: true as const, runId: result.run.friendlyId };
} else {
logger.error("[BatchQueue] TriggerTaskService returned undefined", {
if (result) {
span.setAttribute("batch.result.run_id", result.run.friendlyId);
span.end();
return { success: true as const, runId: result.run.friendlyId };
} else {
logger.error("[BatchQueue] TriggerTaskService returned undefined", {
batchId,
friendlyId,
itemIndex,
task: item.task,
environmentId: meta.environmentId,
attempt,
isFinalAttempt,
});
span.setAttribute("batch.result.error", "TriggerTaskService returned undefined");
// Only create a pre-failed run on the final attempt; otherwise let the retry mechanism handle it
if (isFinalAttempt) {
const failedRunId = await triggerFailedTaskService.call({
taskId: item.task,
environment,
payload: item.payload,
payloadType: item.payloadType as string,
errorMessage: "TriggerTaskService returned undefined",
parentRunId: meta.parentRunId,
resumeParentOnCompletion: meta.resumeParentOnCompletion,
batch: { id: batchId, index: itemIndex },
options: item.options as Record<string, unknown>,
traceContext: meta.traceContext as Record<string, unknown> | undefined,
spanParentAsLink: meta.spanParentAsLink,
errorCode: TaskRunErrorCodes.BATCH_ITEM_COULD_NOT_TRIGGER,
});
span.end();
if (failedRunId) {
return { success: true as const, runId: failedRunId };
}
} else {
span.end();
}
return {
success: false as const,
error: "TriggerTaskService returned undefined",
errorCode: "TRIGGER_FAILED",
};
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Queue-size-limit rejections are a customer-overload scenario (the
// env's queue is at its configured max). Retrying is pointless — the
// same item will fail again — and creating pre-failed TaskRuns for
// every item of every retried batch is exactly what chews through
// DB capacity when a noisy tenant fills their queue. Signal the
// BatchQueue to skip retries and skip pre-failed run creation, and
// let the completion callback collapse the per-item errors into a
// single summary row.
if (error instanceof QueueSizeLimitExceededError) {
logger.warn("[BatchQueue] Batch item rejected: queue size limit reached", {
batchId,
friendlyId,
itemIndex,
task: item.task,
environmentId: meta.environmentId,
maximumSize: error.maximumSize,
});
span.setAttribute("batch.result.error", errorMessage);
span.setAttribute("batch.result.errorCode", QUEUE_SIZE_LIMIT_EXCEEDED_ERROR_CODE);
span.setAttribute("batch.result.skipRetries", true);
span.end();
return {
success: false as const,
error: errorMessage,
errorCode: QUEUE_SIZE_LIMIT_EXCEEDED_ERROR_CODE,
skipRetries: true,
};
}
logger.error("[BatchQueue] Failed to trigger batch item", {
batchId,
friendlyId,
itemIndex,
@@ -780,18 +895,20 @@ export function setupBatchQueueCallbacks() {
environmentId: meta.environmentId,
attempt,
isFinalAttempt,
error,
});
span.setAttribute("batch.result.error", "TriggerTaskService returned undefined");
span.setAttribute("batch.result.error", errorMessage);
span.recordException(error instanceof Error ? error : new Error(String(error)));
// Only create a pre-failed run on the final attempt; otherwise let the retry mechanism handle it
if (isFinalAttempt) {
if (isFinalAttempt && environment) {
const failedRunId = await triggerFailedTaskService.call({
taskId: item.task,
environment,
payload: item.payload,
payloadType: item.payloadType as string,
errorMessage: "TriggerTaskService returned undefined",
errorMessage,
parentRunId: meta.parentRunId,
resumeParentOnCompletion: meta.resumeParentOnCompletion,
batch: { id: batchId, index: itemIndex },
@@ -810,95 +927,16 @@ export function setupBatchQueueCallbacks() {
span.end();
}
return {
success: false as const,
error: "TriggerTaskService returned undefined",
errorCode: "TRIGGER_FAILED",
};
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Queue-size-limit rejections are a customer-overload scenario (the
// env's queue is at its configured max). Retrying is pointless — the
// same item will fail again — and creating pre-failed TaskRuns for
// every item of every retried batch is exactly what chews through
// DB capacity when a noisy tenant fills their queue. Signal the
// BatchQueue to skip retries and skip pre-failed run creation, and
// let the completion callback collapse the per-item errors into a
// single summary row.
if (error instanceof QueueSizeLimitExceededError) {
logger.warn("[BatchQueue] Batch item rejected: queue size limit reached", {
batchId,
friendlyId,
itemIndex,
task: item.task,
environmentId: meta.environmentId,
maximumSize: error.maximumSize,
});
span.setAttribute("batch.result.error", errorMessage);
span.setAttribute("batch.result.errorCode", QUEUE_SIZE_LIMIT_EXCEEDED_ERROR_CODE);
span.setAttribute("batch.result.skipRetries", true);
span.end();
return {
success: false as const,
error: errorMessage,
errorCode: QUEUE_SIZE_LIMIT_EXCEEDED_ERROR_CODE,
skipRetries: true,
errorCode: "TRIGGER_ERROR",
};
}
logger.error("[BatchQueue] Failed to trigger batch item", {
batchId,
friendlyId,
itemIndex,
task: item.task,
environmentId: meta.environmentId,
attempt,
isFinalAttempt,
error,
});
span.setAttribute("batch.result.error", errorMessage);
span.recordException(error instanceof Error ? error : new Error(String(error)));
// Only create a pre-failed run on the final attempt; otherwise let the retry mechanism handle it
if (isFinalAttempt && environment) {
const failedRunId = await triggerFailedTaskService.call({
taskId: item.task,
environment,
payload: item.payload,
payloadType: item.payloadType as string,
errorMessage,
parentRunId: meta.parentRunId,
resumeParentOnCompletion: meta.resumeParentOnCompletion,
batch: { id: batchId, index: itemIndex },
options: item.options as Record<string, unknown>,
traceContext: meta.traceContext as Record<string, unknown> | undefined,
spanParentAsLink: meta.spanParentAsLink,
errorCode: TaskRunErrorCodes.BATCH_ITEM_COULD_NOT_TRIGGER,
});
span.end();
if (failedRunId) {
return { success: true as const, runId: failedRunId };
}
} else {
span.end();
}
return {
success: false as const,
error: errorMessage,
errorCode: "TRIGGER_ERROR",
};
}
}
);
});
);
}
);
// Batch completion callback - updates Postgres with results
engine.setBatchCompletionCallback(async (result: CompleteBatchResult) => {
@@ -7,7 +7,7 @@ import {
} from "@trigger.dev/database";
import { $replica, prisma } from "~/db.server";
import { ErrorAlertConfig } from "~/models/projectAlert.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { logger } from "~/services/logger.server";
import { alertsWorker } from "~/v3/alertsWorker.server";
@@ -45,8 +45,7 @@ const DEFAULT_INTERVAL_MS = 300_000;
export class ErrorAlertEvaluator {
constructor(
protected readonly _prisma: PrismaClientOrTransaction = prisma,
protected readonly _replica: PrismaClientOrTransaction = $replica,
protected readonly _clickhouse: ClickHouse = clickhouseClient
protected readonly _replica: PrismaClientOrTransaction = $replica
) {}
async evaluate(projectId: string, scheduledAt: number): Promise<void> {
@@ -245,10 +244,7 @@ export class ErrorAlertEvaluator {
}
}
if (
state.ignoredUntilTotalOccurrences != null &&
state.ignoredAtOccurrenceCount != null
) {
if (state.ignoredUntilTotalOccurrences != null && state.ignoredAtOccurrenceCount != null) {
const occurrencesSinceIgnored =
context.totalOccurrenceCount - Number(state.ignoredAtOccurrenceCount);
if (occurrencesSinceIgnored >= state.ignoredUntilTotalOccurrences) {
@@ -335,7 +331,11 @@ export class ErrorAlertEvaluator {
envIds: string[],
scheduledAt: number
): Promise<ActiveErrorsSinceQueryResult[]> {
const qb = this._clickhouse.errors.activeErrorsSinceQueryBuilder();
const queryClickhouse = await clickhouseFactory.getClickhouseForOrganization(
organizationId,
"query"
);
const qb = queryClickhouse.errors.activeErrorsSinceQueryBuilder();
qb.where("organization_id = {organizationId: String}", { organizationId });
qb.where("project_id = {projectId: String}", { projectId });
qb.where("environment_id IN {envIds: Array(String)}", { envIds });
@@ -389,7 +389,11 @@ export class ErrorAlertEvaluator {
occurrences_since: number;
}>
> {
const qb = this._clickhouse.errors.occurrenceCountsSinceQueryBuilder();
const queryClickhouse = await clickhouseFactory.getClickhouseForOrganization(
organizationId,
"query"
);
const qb = queryClickhouse.errors.occurrenceCountsSinceQueryBuilder();
qb.where("organization_id = {organizationId: String}", { organizationId });
qb.where("project_id = {projectId: String}", { projectId });
qb.where("environment_id IN {envIds: Array(String)}", { envIds });
@@ -7,7 +7,7 @@ import {
} from "@trigger.dev/database";
import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server";
import { type CreateBulkActionPayload } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import {
parseRunListInputOptions,
type RunListInputFilters,
@@ -38,8 +38,9 @@ export class BulkActionService extends BaseService {
const filters = await getFilters(payload, request);
// Count the runs that will be affected by the bulk action
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(organizationId, "standard");
const runsRepository = new RunsRepository({
clickhouse: clickhouseClient,
clickhouse,
prisma: this._replica as PrismaClient,
});
const count = await runsRepository.countRuns({
@@ -147,8 +148,9 @@ export class BulkActionService extends BaseService {
...rawParams,
});
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(group.project.organizationId, "standard");
const runsRepository = new RunsRepository({
clickhouse: clickhouseClient,
clickhouse,
prisma: this._replica as PrismaClient,
});
@@ -10,7 +10,7 @@ import { CancelTaskAttemptDependenciesService } from "./cancelTaskAttemptDepende
import { CancelableTaskRun } from "./cancelTaskRun.server";
import { FinalizeTaskRunService } from "./finalizeTaskRun.server";
import { tryCatch } from "@trigger.dev/core/utils";
import { resolveEventRepositoryForStore } from "../eventRepository/index.server";
import { getEventRepositoryForStore } from "../eventRepository/index.server";
type ExtendedTaskRun = Prisma.TaskRunGetPayload<{
include: {
@@ -101,7 +101,10 @@ export class CancelTaskRunServiceV1 extends BaseService {
},
});
const eventRepository = resolveEventRepositoryForStore(cancelledTaskRun.taskEventStore);
const eventRepository = await getEventRepositoryForStore(
cancelledTaskRun.taskEventStore,
cancelledTaskRun.runtimeEnvironment.organizationId
);
const [cancelRunEventError] = await tryCatch(
eventRepository.cancelRunEvent({
@@ -31,7 +31,7 @@ import { CancelAttemptService } from "./cancelAttempt.server";
import { CreateCheckpointService } from "./createCheckpoint.server";
import { FinalizeTaskRunService } from "./finalizeTaskRun.server";
import { RetryAttemptService } from "./retryAttempt.server";
import { resolveEventRepositoryForStore } from "../eventRepository/index.server";
import { getEventRepositoryForStore } from "../eventRepository/index.server";
type FoundAttempt = Awaited<ReturnType<typeof findAttempt>>;
@@ -163,7 +163,10 @@ export class CompleteAttemptService extends BaseService {
env,
});
const eventRepository = resolveEventRepositoryForStore(taskRunAttempt.taskRun.taskEventStore);
const eventRepository = await getEventRepositoryForStore(
taskRunAttempt.taskRun.taskEventStore,
taskRunAttempt.taskRun.organizationId ?? ""
);
const [completeSuccessfulRunEventError] = await tryCatch(
eventRepository.completeSuccessfulRunEvent({
@@ -316,7 +319,10 @@ export class CompleteAttemptService extends BaseService {
exitRun(taskRunAttempt.taskRunId);
}
const eventRepository = resolveEventRepositoryForStore(taskRunAttempt.taskRun.taskEventStore);
const eventRepository = await getEventRepositoryForStore(
taskRunAttempt.taskRun.taskEventStore,
taskRunAttempt.taskRun.organizationId ?? ""
);
const [completeFailedRunEventError] = await tryCatch(
eventRepository.completeFailedRunEvent({
@@ -538,7 +544,10 @@ export class CompleteAttemptService extends BaseService {
}) {
const retryAt = new Date(executionRetry.timestamp);
const eventRepository = resolveEventRepositoryForStore(taskRunAttempt.taskRun.taskEventStore);
const eventRepository = await getEventRepositoryForStore(
taskRunAttempt.taskRun.taskEventStore,
taskRunAttempt.taskRun.organizationId ?? ""
);
// Retry the task run
await eventRepository.recordEvent(
@@ -7,7 +7,7 @@ import { FailedTaskRunRetryHelper } from "../failedTaskRun.server";
import { CRASHABLE_ATTEMPT_STATUSES, isCrashableRunStatus } from "../taskStatus";
import { BaseService } from "./baseService.server";
import { FinalizeTaskRunService } from "./finalizeTaskRun.server";
import { resolveEventRepositoryForStore } from "../eventRepository/index.server";
import { getEventRepositoryForStore } from "../eventRepository/index.server";
export type CrashTaskRunServiceOptions = {
reason?: string;
@@ -120,7 +120,10 @@ export class CrashTaskRunService extends BaseService {
},
});
const eventRepository = resolveEventRepositoryForStore(crashedTaskRun.taskEventStore);
const eventRepository = await getEventRepositoryForStore(
crashedTaskRun.taskEventStore,
crashedTaskRun.runtimeEnvironment.organizationId
);
const [createAttemptFailedEventError] = await tryCatch(
eventRepository.completeFailedRunEvent({
@@ -4,7 +4,7 @@ import { commonWorker } from "../commonWorker.server";
import { BaseService } from "./baseService.server";
import { FinalizeTaskRunService } from "./finalizeTaskRun.server";
import { tryCatch } from "@trigger.dev/core/utils";
import { resolveEventRepositoryForStore } from "../eventRepository/index.server";
import { getEventRepositoryForStore } from "../eventRepository/index.server";
export class ExpireEnqueuedRunService extends BaseService {
public static async ack(runId: string, tx?: PrismaClientOrTransaction) {
@@ -78,7 +78,10 @@ export class ExpireEnqueuedRunService extends BaseService {
},
});
const eventRepository = resolveEventRepositoryForStore(run.taskEventStore);
const eventRepository = await getEventRepositoryForStore(
run.taskEventStore,
run.runtimeEnvironment.organization.id
);
if (run.ttl) {
const [completeExpiredRunEventError] = await tryCatch(
@@ -294,6 +294,7 @@ export class TriggerTaskServiceV1 extends BaseService {
: undefined;
const { repository, store } = await getV3EventRepository(
environment.organization.id,
dependentAttempt?.taskRun.taskEventStore ??
parentAttempt?.taskRun.taskEventStore ??
dependentBatchRun?.dependentTaskAttempt?.taskRun.taskEventStore
+130
View File
@@ -0,0 +1,130 @@
import { describe, expect } from "vitest";
vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} }));
import { postgresTest } from "@internal/testcontainers";
import { OrganizationDataStoresRegistry } from "~/services/dataStores/organizationDataStoresRegistry.server";
import { ClickhouseConnectionSchema } from "~/services/clickhouse/clickhouseSecretSchemas.server";
import { ClickhouseFactory } from "~/services/clickhouse/clickhouseFactory.server";
vi.setConfig({ testTimeout: 60_000 });
const TEST_URL = "https://default:password@ch-org.example.com:8443";
const TEST_URL_2 = "https://default:password@ch-other.example.com:8443";
describe("ClickHouse Factory", () => {
postgresTest(
"returns default client when org has no data store",
async ({ prisma }) => {
const registry = new OrganizationDataStoresRegistry(prisma);
await registry.loadFromDatabase();
const factory = new ClickhouseFactory(registry);
const client = await factory.getClickhouseForOrganization("org-no-store", "standard");
expect(client).toBeDefined();
}
);
postgresTest(
"returns org-specific client when a data store is configured",
async ({ prisma }) => {
const registry = new OrganizationDataStoresRegistry(prisma);
await registry.addDataStore({
key: "factory-store",
kind: "CLICKHOUSE",
organizationIds: ["org-custom"],
config: ClickhouseConnectionSchema.parse({ url: TEST_URL }),
});
await registry.loadFromDatabase();
const factory = new ClickhouseFactory(registry);
const client = await factory.getClickhouseForOrganization("org-custom", "standard");
expect(client).toBeDefined();
// Default client is a different instance from the org-specific one
const defaultClient = await factory.getClickhouseForOrganization("org-no-store", "standard");
expect(client).not.toBe(defaultClient);
}
);
postgresTest(
"two orgs sharing the same data store get the same cached client",
async ({ prisma }) => {
const registry = new OrganizationDataStoresRegistry(prisma);
await registry.addDataStore({
key: "shared-factory-store",
kind: "CLICKHOUSE",
organizationIds: ["org-shared-1", "org-shared-2"],
config: ClickhouseConnectionSchema.parse({ url: TEST_URL }),
});
await registry.loadFromDatabase();
const factory = new ClickhouseFactory(registry);
const client1 = await factory.getClickhouseForOrganization("org-shared-1", "standard");
const client2 = await factory.getClickhouseForOrganization("org-shared-2", "standard");
// Same hostname → same cached client instance
expect(client1).toBe(client2);
}
);
postgresTest(
"two data stores with different URLs produce different clients",
async ({ prisma }) => {
const registry = new OrganizationDataStoresRegistry(prisma);
await registry.addDataStore({
key: "store-a",
kind: "CLICKHOUSE",
organizationIds: ["org-a"],
config: ClickhouseConnectionSchema.parse({ url: TEST_URL }),
});
await registry.addDataStore({
key: "store-b",
kind: "CLICKHOUSE",
organizationIds: ["org-b"],
config: ClickhouseConnectionSchema.parse({ url: TEST_URL_2 }),
});
await registry.loadFromDatabase();
const factory = new ClickhouseFactory(registry);
const clientA = await factory.getClickhouseForOrganization("org-a", "standard");
const clientB = await factory.getClickhouseForOrganization("org-b", "standard");
expect(clientA).not.toBe(clientB);
}
);
postgresTest(
"after reload with a deleted store, org falls back to default",
async ({ prisma }) => {
const registry = new OrganizationDataStoresRegistry(prisma);
await registry.addDataStore({
key: "removable-store",
kind: "CLICKHOUSE",
organizationIds: ["org-removable"],
config: ClickhouseConnectionSchema.parse({ url: TEST_URL }),
});
await registry.loadFromDatabase();
const factory = new ClickhouseFactory(registry);
const before = await factory.getClickhouseForOrganization("org-removable", "standard");
const defaultClient = await factory.getClickhouseForOrganization("org-no-store", "standard");
expect(before).not.toBe(defaultClient);
await registry.deleteDataStore({ key: "removable-store", kind: "CLICKHOUSE" });
await registry.reload();
const after = await factory.getClickhouseForOrganization("org-removable", "standard");
expect(after).toBe(defaultClient);
}
);
});
@@ -0,0 +1,230 @@
import { describe, expect } from "vitest";
vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} }));
import { postgresTest } from "@internal/testcontainers";
import { OrganizationDataStoresRegistry } from "~/services/dataStores/organizationDataStoresRegistry.server";
import { ClickhouseConnectionSchema } from "~/services/clickhouse/clickhouseSecretSchemas.server";
vi.setConfig({ testTimeout: 60_000 });
const TEST_URL = "https://default:password@clickhouse.example.com:8443";
const TEST_URL_2 = "https://default:password@clickhouse2.example.com:8443";
describe("OrganizationDataStoresRegistry", () => {
postgresTest("isLoaded is false before loadFromDatabase", async ({ prisma }) => {
const registry = new OrganizationDataStoresRegistry(prisma);
expect(registry.isLoaded).toBe(false);
expect(registry.get("any-org", "CLICKHOUSE")).toBeNull();
});
postgresTest("isLoaded is true after loadFromDatabase", async ({ prisma }) => {
const registry = new OrganizationDataStoresRegistry(prisma);
await registry.loadFromDatabase();
expect(registry.isLoaded).toBe(true);
});
postgresTest("isReady resolves after loadFromDatabase", async ({ prisma }) => {
const registry = new OrganizationDataStoresRegistry(prisma);
let resolved = false;
registry.isReady.then(() => {
resolved = true;
});
await registry.loadFromDatabase();
await registry.isReady;
expect(resolved).toBe(true);
});
postgresTest("get returns null when no data stores exist", async ({ prisma }) => {
const registry = new OrganizationDataStoresRegistry(prisma);
await registry.loadFromDatabase();
expect(registry.get("org-1", "CLICKHOUSE")).toBeNull();
});
postgresTest("addDataStore creates a row and stores the secret", async ({ prisma }) => {
const registry = new OrganizationDataStoresRegistry(prisma);
await registry.addDataStore({
key: "test-store",
kind: "CLICKHOUSE",
organizationIds: ["org-1", "org-2"],
config: ClickhouseConnectionSchema.parse({ url: TEST_URL }),
});
const row = await prisma.organizationDataStore.findFirst({ where: { key: "test-store" } });
expect(row).not.toBeNull();
expect(row?.organizationIds).toEqual(["org-1", "org-2"]);
expect(row?.kind).toBe("CLICKHOUSE");
const secret = await prisma.secretStore.findFirst({
where: { key: "data-store:test-store:clickhouse" },
});
expect(secret).not.toBeNull();
});
postgresTest("loadFromDatabase resolves secrets and makes orgs available via get", async ({ prisma }) => {
const registry = new OrganizationDataStoresRegistry(prisma);
await registry.addDataStore({
key: "hipaa-store",
kind: "CLICKHOUSE",
organizationIds: ["org-hipaa"],
config: ClickhouseConnectionSchema.parse({ url: TEST_URL }),
});
await registry.loadFromDatabase();
const result = registry.get("org-hipaa", "CLICKHOUSE");
expect(result).not.toBeNull();
expect(result?.kind).toBe("CLICKHOUSE");
expect(result?.url).toBe(TEST_URL);
});
postgresTest("get returns null for orgs not in any data store", async ({ prisma }) => {
const registry = new OrganizationDataStoresRegistry(prisma);
await registry.addDataStore({
key: "partial-store",
kind: "CLICKHOUSE",
organizationIds: ["org-a"],
config: ClickhouseConnectionSchema.parse({ url: TEST_URL }),
});
await registry.loadFromDatabase();
expect(registry.get("org-a", "CLICKHOUSE")).not.toBeNull();
expect(registry.get("org-b", "CLICKHOUSE")).toBeNull();
});
postgresTest("multiple orgs can share the same data store", async ({ prisma }) => {
const registry = new OrganizationDataStoresRegistry(prisma);
await registry.addDataStore({
key: "shared-store",
kind: "CLICKHOUSE",
organizationIds: ["org-x", "org-y", "org-z"],
config: ClickhouseConnectionSchema.parse({ url: TEST_URL }),
});
await registry.loadFromDatabase();
const x = registry.get("org-x", "CLICKHOUSE");
const y = registry.get("org-y", "CLICKHOUSE");
const z = registry.get("org-z", "CLICKHOUSE");
expect(x?.url).toBe(TEST_URL);
expect(y?.url).toBe(TEST_URL);
expect(z?.url).toBe(TEST_URL);
});
postgresTest(
"when an org appears in multiple data stores, first row by id asc wins",
async ({ prisma }) => {
const registry = new OrganizationDataStoresRegistry(prisma);
const sharedOrg = "org-dup-overlap";
await registry.addDataStore({
key: "dup-overlap-first",
kind: "CLICKHOUSE",
organizationIds: [sharedOrg],
config: ClickhouseConnectionSchema.parse({ url: TEST_URL }),
});
await registry.addDataStore({
key: "dup-overlap-second",
kind: "CLICKHOUSE",
organizationIds: [sharedOrg],
config: ClickhouseConnectionSchema.parse({ url: TEST_URL_2 }),
});
const [winner] = await prisma.organizationDataStore.findMany({
where: { key: { in: ["dup-overlap-first", "dup-overlap-second"] } },
orderBy: { id: "asc" },
});
expect(winner).toBeDefined();
await registry.loadFromDatabase();
const expectedUrl =
winner!.key === "dup-overlap-first" ? TEST_URL : TEST_URL_2;
expect(registry.get(sharedOrg, "CLICKHOUSE")?.url).toBe(expectedUrl);
}
);
postgresTest("updateDataStore updates organizationIds and rotates the secret", async ({ prisma }) => {
const registry = new OrganizationDataStoresRegistry(prisma);
await registry.addDataStore({
key: "update-store",
kind: "CLICKHOUSE",
organizationIds: ["org-old"],
config: ClickhouseConnectionSchema.parse({ url: TEST_URL }),
});
await registry.updateDataStore({
key: "update-store",
kind: "CLICKHOUSE",
organizationIds: ["org-new-1", "org-new-2"],
config: ClickhouseConnectionSchema.parse({ url: TEST_URL_2 }),
});
const row = await prisma.organizationDataStore.findFirst({ where: { key: "update-store" } });
expect(row?.organizationIds).toEqual(["org-new-1", "org-new-2"]);
await registry.loadFromDatabase();
expect(registry.get("org-new-1", "CLICKHOUSE")?.url).toBe(TEST_URL_2);
expect(registry.get("org-old", "CLICKHOUSE")).toBeNull();
});
postgresTest("reload picks up changes made after initial load", async ({ prisma }) => {
const registry = new OrganizationDataStoresRegistry(prisma);
await registry.loadFromDatabase();
expect(registry.get("org-reload", "CLICKHOUSE")).toBeNull();
await registry.addDataStore({
key: "reload-store",
kind: "CLICKHOUSE",
organizationIds: ["org-reload"],
config: ClickhouseConnectionSchema.parse({ url: TEST_URL }),
});
expect(registry.get("org-reload", "CLICKHOUSE")).toBeNull();
await registry.reload();
expect(registry.get("org-reload", "CLICKHOUSE")?.url).toBe(TEST_URL);
});
postgresTest("deleteDataStore removes the row and its secret", async ({ prisma }) => {
const registry = new OrganizationDataStoresRegistry(prisma);
await registry.addDataStore({
key: "delete-store",
kind: "CLICKHOUSE",
organizationIds: ["org-delete"],
config: ClickhouseConnectionSchema.parse({ url: TEST_URL }),
});
await registry.deleteDataStore({ key: "delete-store", kind: "CLICKHOUSE" });
expect(await prisma.organizationDataStore.findFirst({ where: { key: "delete-store" } })).toBeNull();
expect(await prisma.secretStore.findFirst({ where: { key: "data-store:delete-store:clickhouse" } })).toBeNull();
});
postgresTest("after delete and reload, org no longer has a data store", async ({ prisma }) => {
const registry = new OrganizationDataStoresRegistry(prisma);
await registry.addDataStore({
key: "ephemeral-store",
kind: "CLICKHOUSE",
organizationIds: ["org-ephemeral"],
config: ClickhouseConnectionSchema.parse({ url: TEST_URL }),
});
await registry.loadFromDatabase();
expect(registry.get("org-ephemeral", "CLICKHOUSE")?.url).toBe(TEST_URL);
await registry.deleteDataStore({ key: "ephemeral-store", kind: "CLICKHOUSE" });
await registry.reload();
expect(registry.get("org-ephemeral", "CLICKHOUSE")).toBeNull();
});
});
+2 -1
View File
@@ -12,6 +12,7 @@ import { z } from "zod";
import { RunsBackfillerService } from "~/services/runsBackfiller.server";
import { RunsReplicationService } from "~/services/runsReplicationService.server";
import { createInMemoryTracing } from "./utils/tracing";
import { TestReplicationClickhouseFactory } from "./utils/testReplicationClickhouseFactory";
vi.setConfig({ testTimeout: 60_000 });
@@ -30,7 +31,7 @@ describe("RunsBackfillerService", () => {
const { tracer, exporter } = createInMemoryTracing();
const runsReplicationService = new RunsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: "runs-replication",
slotName: "task_runs_to_clickhouse_v1",
@@ -7,6 +7,7 @@ import path from "node:path";
import { z } from "zod";
import { RunsReplicationService } from "~/services/runsReplicationService.server";
import { createInMemoryTracing, createInMemoryMetrics } from "./utils/tracing";
import { TestReplicationClickhouseFactory } from "./utils/testReplicationClickhouseFactory";
// Extend test timeout for benchmarks
vi.setConfig({ testTimeout: 300_000 }); // 5 minutes
@@ -320,7 +321,7 @@ async function runBenchmark(
// Create and start replication service
const runsReplicationService = new RunsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: `benchmark-${name}`,
slotName: `benchmark_${name.replace(/-/g, "_")}`,
@@ -5,6 +5,7 @@ import { z } from "zod";
import { TaskRunStatus } from "~/database-types";
import { RunsReplicationService } from "~/services/runsReplicationService.server";
import { createInMemoryTracing, createInMemoryMetrics } from "./utils/tracing";
import { TestReplicationClickhouseFactory } from "./utils/testReplicationClickhouseFactory";
import superjson from "superjson";
vi.setConfig({ testTimeout: 60_000 });
@@ -27,7 +28,7 @@ describe("RunsReplicationService (part 1/2)", () => {
const { tracer, exporter } = createInMemoryTracing();
const runsReplicationService = new RunsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: "runs-replication",
slotName: "task_runs_to_clickhouse_v1",
@@ -151,7 +152,7 @@ describe("RunsReplicationService (part 1/2)", () => {
const { tracer, exporter } = createInMemoryTracing();
const runsReplicationService = new RunsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: "runs-replication",
slotName: "task_runs_to_clickhouse_v1",
@@ -289,7 +290,7 @@ describe("RunsReplicationService (part 1/2)", () => {
const { tracer, exporter } = createInMemoryTracing();
const runsReplicationService = new RunsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: "runs-replication",
slotName: "task_runs_to_clickhouse_v1",
@@ -359,7 +360,7 @@ describe("RunsReplicationService (part 1/2)", () => {
});
const runsReplicationService = new RunsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: "runs-replication-batching",
slotName: "task_runs_to_clickhouse_v1",
@@ -463,7 +464,7 @@ describe("RunsReplicationService (part 1/2)", () => {
});
const runsReplicationService = new RunsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: "runs-replication-payload",
slotName: "task_runs_to_clickhouse_v1",
@@ -564,7 +565,7 @@ describe("RunsReplicationService (part 1/2)", () => {
});
const runsReplicationService = new RunsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: "runs-replication-payload",
slotName: "task_runs_to_clickhouse_v1",
@@ -670,7 +671,7 @@ describe("RunsReplicationService (part 1/2)", () => {
});
const runsReplicationService = new RunsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: "runs-replication-update",
slotName: "task_runs_to_clickhouse_v1",
@@ -777,7 +778,7 @@ describe("RunsReplicationService (part 1/2)", () => {
});
const runsReplicationService = new RunsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: "runs-replication-delete",
slotName: "task_runs_to_clickhouse_v1",
@@ -878,7 +879,7 @@ describe("RunsReplicationService (part 1/2)", () => {
// Service A
const runsReplicationServiceA = new RunsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: "runs-replication-shutdown-handover",
slotName: "task_runs_to_clickhouse_v1",
@@ -982,7 +983,7 @@ describe("RunsReplicationService (part 1/2)", () => {
// Service B
const runsReplicationServiceB = new RunsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: "runs-replication-shutdown-handover",
slotName: "task_runs_to_clickhouse_v1",
@@ -1029,7 +1030,7 @@ describe("RunsReplicationService (part 1/2)", () => {
// Service A
const runsReplicationServiceA = new RunsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: "runs-replication-shutdown-after-processed",
slotName: "task_runs_to_clickhouse_v1",
@@ -1131,7 +1132,7 @@ describe("RunsReplicationService (part 1/2)", () => {
// Service B
const runsReplicationServiceB = new RunsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: "runs-replication-shutdown-after-processed",
slotName: "task_runs_to_clickhouse_v1",
@@ -1174,7 +1175,7 @@ describe("RunsReplicationService (part 1/2)", () => {
const metricsHelper = createInMemoryMetrics();
const runsReplicationService = new RunsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: "runs-replication-metrics",
slotName: "task_runs_to_clickhouse_v1",
@@ -6,6 +6,7 @@ import { setTimeout } from "node:timers/promises";
import { z } from "zod";
import { RunsReplicationService } from "~/services/runsReplicationService.server";
import { detectBadJsonStrings } from "~/utils/detectBadJsonStrings";
import { TestReplicationClickhouseFactory } from "./utils/testReplicationClickhouseFactory";
vi.setConfig({ testTimeout: 60_000 });
@@ -23,7 +24,7 @@ describe("RunsReplicationService (part 2/2)", () => {
// Service A
const runsReplicationServiceA = new RunsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: "runs-replication-shutdown-handover",
slotName: "task_runs_to_clickhouse_v1",
@@ -43,7 +44,7 @@ describe("RunsReplicationService (part 2/2)", () => {
// Service A
const runsReplicationServiceB = new RunsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: "runs-replication-shutdown-handover",
slotName: "task_runs_to_clickhouse_v1",
@@ -152,7 +153,7 @@ describe("RunsReplicationService (part 2/2)", () => {
});
const runsReplicationService = new RunsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: "runs-replication-stress-bulk-insert",
slotName: "task_runs_to_clickhouse_v1",
@@ -267,7 +268,7 @@ describe("RunsReplicationService (part 2/2)", () => {
});
const runsReplicationService = new RunsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: "runs-replication-stress-bulk-insert",
slotName: "task_runs_to_clickhouse_v1",
@@ -388,7 +389,7 @@ describe("RunsReplicationService (part 2/2)", () => {
});
const runsReplicationService = new RunsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: "runs-replication-multi-event-tx",
slotName: "task_runs_to_clickhouse_v1",
@@ -522,7 +523,7 @@ describe("RunsReplicationService (part 2/2)", () => {
});
const runsReplicationService = new RunsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: "runs-replication-long-tx",
slotName: "task_runs_to_clickhouse_v1",
@@ -629,7 +630,7 @@ describe("RunsReplicationService (part 2/2)", () => {
});
const runsReplicationService = new RunsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: "runs-replication-stress-bulk-insert",
slotName: "task_runs_to_clickhouse_v1",
@@ -798,7 +799,7 @@ describe("RunsReplicationService (part 2/2)", () => {
});
const runsReplicationService = new RunsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: "runs-replication-merge-batch",
slotName: "task_runs_to_clickhouse_v1",
@@ -923,7 +924,7 @@ describe("RunsReplicationService (part 2/2)", () => {
});
const runsReplicationService = new RunsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: "runs-replication-sorting",
slotName: "task_runs_to_clickhouse_v1",
@@ -1136,7 +1137,7 @@ describe("RunsReplicationService (part 2/2)", () => {
});
const runsReplicationService = new RunsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: "runs-replication-exhaustive",
slotName: "task_runs_to_clickhouse_v1",
@@ -3,6 +3,7 @@ import { containerTest } from "@internal/testcontainers";
import { setTimeout } from "node:timers/promises";
import { z } from "zod";
import { SessionsReplicationService } from "~/services/sessionsReplicationService.server";
import { TestReplicationClickhouseFactory } from "./utils/testReplicationClickhouseFactory";
vi.setConfig({ testTimeout: 60_000 });
@@ -21,7 +22,7 @@ describe("SessionsReplicationService", () => {
});
const service = new SessionsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: "sessions-replication",
slotName: "sessions_to_clickhouse_v1",
@@ -128,7 +129,7 @@ describe("SessionsReplicationService", () => {
});
const service = new SessionsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: postgresContainer.getConnectionUri(),
serviceName: "sessions-replication",
slotName: "sessions_to_clickhouse_v1",
+2 -1
View File
@@ -2,6 +2,7 @@ import { ClickHouse } from "@internal/clickhouse";
import { RedisOptions } from "@internal/redis";
import { PrismaClient } from "~/db.server";
import { RunsReplicationService } from "~/services/runsReplicationService.server";
import { TestReplicationClickhouseFactory } from "./testReplicationClickhouseFactory";
import { afterEach } from "vitest";
export async function setupClickhouseReplication({
@@ -26,7 +27,7 @@ export async function setupClickhouseReplication({
});
const runsReplicationService = new RunsReplicationService({
clickhouse,
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
pgConnectionUrl: databaseUrl,
serviceName: "runs-replication",
slotName: "task_runs_to_clickhouse_v1",
@@ -0,0 +1,32 @@
import type { ClickHouse } from "@internal/clickhouse";
import {
ClickhouseFactory,
type ClientType,
} from "~/services/clickhouse/clickhouseFactory.server";
import type { OrganizationDataStoresRegistry } from "~/services/dataStores/organizationDataStoresRegistry.server";
const testReplicationRegistryStub = {
isLoaded: true,
isReady: Promise.resolve(),
get: () => null,
} as unknown as OrganizationDataStoresRegistry;
/**
* Routes all `replication` and `sessions_replication` clients to a single test ClickHouse;
* other client types use the real factory defaults.
*/
export class TestReplicationClickhouseFactory extends ClickhouseFactory {
constructor(private readonly replicationClient: ClickHouse) {
super(testReplicationRegistryStub);
}
override getClickhouseForOrganizationSync(
organizationId: string,
clientType: ClientType
): ClickHouse {
if (clientType === "replication" || clientType === "sessions_replication") {
return this.replicationClient;
}
return super.getClickhouseForOrganizationSync(organizationId, clientType);
}
}
+2
View File
@@ -39,6 +39,8 @@ export function createInMemoryTracing() {
});
provider.register();
// Use the provider's tracer so spans hit this exporter even when a global
// NodeTracerProvider was already registered (e.g. via tracer.server import chain).
const tracer = provider.getTracer("test-tracer");
return {
@@ -0,0 +1,21 @@
-- CreateEnum
CREATE TYPE "public"."DataStoreKind" AS ENUM ('CLICKHOUSE');
-- CreateTable
CREATE TABLE "public"."OrganizationDataStore" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"organizationIds" TEXT[],
"kind" "public"."DataStoreKind" NOT NULL,
"config" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OrganizationDataStore_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "OrganizationDataStore_key_key" ON "public"."OrganizationDataStore"("key");
-- CreateIndex
CREATE INDEX "OrganizationDataStore_kind_idx" ON "public"."OrganizationDataStore"("kind");
@@ -3153,4 +3153,25 @@ model TaskIdentifier {
@@unique([runtimeEnvironmentId, slug])
@@index([runtimeEnvironmentId, isInLatestDeployment])
}
}
enum DataStoreKind {
CLICKHOUSE
}
/// Defines org-scoped data store overrides (e.g. dedicated ClickHouse for HIPAA orgs).
/// Multiple organizations can share a single data store row via organizationIds.
model OrganizationDataStore {
id String @id @default(cuid())
/// Human-readable unique key (e.g. "hipaa-clickhouse-us-east")
key String @unique
/// Organization IDs that use this data store
organizationIds String[]
kind DataStoreKind
/// Versioned config JSON. Structure is discriminated by the top-level `version` field.
config Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([kind])
}