diff --git a/.changeset/fix-console-interceptor-2900.md b/.changeset/fix-console-interceptor-2900.md new file mode 100644 index 00000000000..8a13754f391 --- /dev/null +++ b/.changeset/fix-console-interceptor-2900.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +Fix: ConsoleInterceptor now delegates to original console methods to preserve log chain when other interceptors (like Sentry) are present. (#2900) diff --git a/.changeset/fix-docker-hub-rate-limit-2911.md b/.changeset/fix-docker-hub-rate-limit-2911.md new file mode 100644 index 00000000000..3f121cff4ad --- /dev/null +++ b/.changeset/fix-docker-hub-rate-limit-2911.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/cli-v3": patch +--- + +Fix: Native build server failed with Docker Hub rate limits. Added support for checking checking `DOCKER_USERNAME` and `DOCKER_PASSWORD` in environment variables and logging into Docker Hub before building. (#2911) diff --git a/.changeset/fix-github-install-node-version-2913.md b/.changeset/fix-github-install-node-version-2913.md new file mode 100644 index 00000000000..130b92be126 --- /dev/null +++ b/.changeset/fix-github-install-node-version-2913.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/cli-v3": patch +--- + +Fix: Ignore engine checks during deployment install phase to prevent failure on build server when Node version mismatch exists. (#2913) diff --git a/.changeset/fix-orphaned-workers-2909.md b/.changeset/fix-orphaned-workers-2909.md new file mode 100644 index 00000000000..2b02495c7c9 --- /dev/null +++ b/.changeset/fix-orphaned-workers-2909.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/cli-v3": patch +--- + +Fix: `trigger.dev dev` command left orphaned worker processes when exited via Ctrl+C (SIGINT). Added signal handlers to ensure proper cleanup of child processes and lockfiles. (#2909) diff --git a/.changeset/fix-sentry-oom-2920.md b/.changeset/fix-sentry-oom-2920.md new file mode 100644 index 00000000000..7c770e4cd21 --- /dev/null +++ b/.changeset/fix-sentry-oom-2920.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/cli-v3": patch +--- + +Fix Sentry OOM: Allow disabling `source-map-support` via `TRIGGER_SOURCE_MAPS=false`. Also supports `node` for native source maps. (#2920) diff --git a/.server-changes/replication-error-recovery.md b/.server-changes/replication-error-recovery.md new file mode 100644 index 00000000000..f5c8dfc6223 --- /dev/null +++ b/.server-changes/replication-error-recovery.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Runs and sessions replication services now auto-recover from stream errors (e.g. after a Postgres failover) instead of silently leaving replication stopped. Behaviour is configurable per service — reconnect (default), exit so a process supervisor can restart the host, or log. diff --git a/apps/webapp/app/db.server.ts b/apps/webapp/app/db.server.ts index 96f6307f576..c994fdab045 100644 --- a/apps/webapp/app/db.server.ts +++ b/apps/webapp/app/db.server.ts @@ -7,6 +7,8 @@ import { type PrismaTransactionClient, type PrismaTransactionOptions, } from "@trigger.dev/database"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { createHash } from "node:crypto"; import invariant from "tiny-invariant"; import { z } from "zod"; import { env } from "./env.server"; @@ -127,21 +129,30 @@ function getClient() { const { DATABASE_URL } = process.env; invariant(typeof DATABASE_URL === "string", "DATABASE_URL env var not set"); - const databaseUrl = extendQueryParams(DATABASE_URL, { - connection_limit: env.DATABASE_CONNECTION_LIMIT.toString(), - pool_timeout: env.DATABASE_POOL_TIMEOUT.toString(), - connection_timeout: env.DATABASE_CONNECTION_TIMEOUT.toString(), - application_name: env.SERVICE_NAME, - }); + const databaseUrl = new URL(DATABASE_URL); + + // Set application_name as a query param on the connection string (pg understands this) + databaseUrl.searchParams.set("application_name", env.SERVICE_NAME); console.log(`šŸ”Œ setting up prisma client to ${redactUrlSecrets(databaseUrl)}`); - const client = new PrismaClient({ - datasources: { - db: { - url: databaseUrl.href, - }, + const adapter = new PrismaPg( + { + connectionString: databaseUrl.href, + max: env.DATABASE_CONNECTION_LIMIT, + idleTimeoutMillis: env.DATABASE_CONNECTION_TIMEOUT * 1000, + connectionTimeoutMillis: env.DATABASE_CONNECTION_TIMEOUT * 1000, }, + { + // Generate deterministic prepared statement names from query SQL so PostgreSQL + // can reuse cached query plans. Without this, every query uses an anonymous + // prepared statement that PG must parse and plan from scratch each time. + statementNameGenerator: (query) => `p_${createHash("sha256").update(query.sql).digest("hex").slice(0, 16)}`, + } + ); + + const client = new PrismaClient({ + adapter, log: [ // events { @@ -251,21 +262,25 @@ function getReplicaClient() { return; } - const replicaUrl = extendQueryParams(env.DATABASE_READ_REPLICA_URL, { - connection_limit: env.DATABASE_CONNECTION_LIMIT.toString(), - pool_timeout: env.DATABASE_POOL_TIMEOUT.toString(), - connection_timeout: env.DATABASE_CONNECTION_TIMEOUT.toString(), - application_name: env.SERVICE_NAME, - }); + const replicaUrl = new URL(env.DATABASE_READ_REPLICA_URL); + replicaUrl.searchParams.set("application_name", env.SERVICE_NAME); console.log(`šŸ”Œ setting up read replica connection to ${redactUrlSecrets(replicaUrl)}`); - const replicaClient = new PrismaClient({ - datasources: { - db: { - url: replicaUrl.href, - }, + const adapter = new PrismaPg( + { + connectionString: replicaUrl.href, + max: env.DATABASE_CONNECTION_LIMIT, + idleTimeoutMillis: env.DATABASE_CONNECTION_TIMEOUT * 1000, + connectionTimeoutMillis: env.DATABASE_CONNECTION_TIMEOUT * 1000, }, + { + statementNameGenerator: (query) => `p_${createHash("sha256").update(query.sql).digest("hex").slice(0, 16)}`, + } + ); + + const replicaClient = new PrismaClient({ + adapter, log: [ // events { @@ -368,19 +383,6 @@ function getReplicaClient() { return replicaClient; } -function extendQueryParams(hrefOrUrl: string | URL, queryParams: Record) { - const url = new URL(hrefOrUrl); - const query = url.searchParams; - - for (const [key, val] of Object.entries(queryParams)) { - query.set(key, val); - } - - url.search = query.toString(); - - return url; -} - function redactUrlSecrets(hrefOrUrl: string | URL) { const url = new URL(hrefOrUrl); url.password = ""; diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 8eacb9634e1..e5b02e3dcad 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -1330,6 +1330,16 @@ const EnvironmentSchema = z RUN_REPLICATION_INSERT_STRATEGY: z.enum(["insert", "insert_async"]).default("insert"), RUN_REPLICATION_DISABLE_PAYLOAD_INSERT: z.string().default("0"), RUN_REPLICATION_DISABLE_ERROR_FINGERPRINTING: z.string().default("0"), + // What to do when the runs replication client errors (e.g. after a + // Postgres failover). `reconnect` (default) re-subscribes in-process with + // exponential backoff; `exit` exits the process so a supervisor restarts + // it; `log` preserves the old no-op behaviour. Reconnect tuning is + // shared across both replication services via REPLICATION_RECONNECT_*. + RUN_REPLICATION_ERROR_STRATEGY: z + .enum(["reconnect", "exit", "log"]) + .default("reconnect"), + RUN_REPLICATION_EXIT_DELAY_MS: z.coerce.number().int().min(0).default(5_000), + RUN_REPLICATION_EXIT_CODE: z.coerce.number().int().min(0).max(255).default(1), // Session replication (Postgres → ClickHouse sessions_v1). Shares Redis // with the runs replicator for leader locking but has its own slot and @@ -1362,6 +1372,19 @@ const EnvironmentSchema = z SESSION_REPLICATION_INSERT_MAX_RETRIES: z.coerce.number().int().default(3), SESSION_REPLICATION_INSERT_BASE_DELAY_MS: z.coerce.number().int().default(100), SESSION_REPLICATION_INSERT_MAX_DELAY_MS: z.coerce.number().int().default(2000), + // Error recovery — same semantics as RUN_REPLICATION_ERROR_STRATEGY. + SESSION_REPLICATION_ERROR_STRATEGY: z + .enum(["reconnect", "exit", "log"]) + .default("reconnect"), + SESSION_REPLICATION_EXIT_DELAY_MS: z.coerce.number().int().min(0).default(5_000), + SESSION_REPLICATION_EXIT_CODE: z.coerce.number().int().min(0).max(255).default(1), + + // Reconnect tuning shared across both replication services. Only + // applies when error strategy is `reconnect`. Max attempts of 0 means + // unlimited (default). + REPLICATION_RECONNECT_INITIAL_DELAY_MS: z.coerce.number().int().min(0).default(1_000), + REPLICATION_RECONNECT_MAX_DELAY_MS: z.coerce.number().int().min(0).default(60_000), + REPLICATION_RECONNECT_MAX_ATTEMPTS: z.coerce.number().int().min(0).default(0), // Clickhouse CLICKHOUSE_URL: z.string(), diff --git a/apps/webapp/app/routes/metrics.ts b/apps/webapp/app/routes/metrics.ts index f5e9931e662..67c2605fc72 100644 --- a/apps/webapp/app/routes/metrics.ts +++ b/apps/webapp/app/routes/metrics.ts @@ -1,5 +1,4 @@ import { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { prisma } from "~/db.server"; import { metricsRegister } from "~/metrics.server"; export async function loader({ request }: LoaderFunctionArgs) { @@ -13,14 +12,9 @@ export async function loader({ request }: LoaderFunctionArgs) { } } - // We need to remove empty lines from the prisma metrics, grafana doesn't like them - const prismaMetrics = (await prisma.$metrics.prometheus()).replace(/^\s*[\r\n]/gm, ""); const coreMetrics = await metricsRegister.metrics(); - // Order matters, core metrics end with `# EOF`, prisma metrics don't - const metrics = prismaMetrics + coreMetrics; - - return new Response(metrics, { + return new Response(coreMetrics, { headers: { "Content-Type": metricsRegister.contentType, }, diff --git a/apps/webapp/app/services/replicationErrorRecovery.server.ts b/apps/webapp/app/services/replicationErrorRecovery.server.ts new file mode 100644 index 00000000000..46cf05a3181 --- /dev/null +++ b/apps/webapp/app/services/replicationErrorRecovery.server.ts @@ -0,0 +1,207 @@ +import { Logger } from "@trigger.dev/core/logger"; + +// When the LogicalReplicationClient's WAL stream errors (e.g. after a +// Postgres failover) it calls stop() on itself and stays stopped. The host +// service has to decide how to recover. Three strategies are available: +// +// - "reconnect" — re-subscribe in-process with exponential backoff. Default; +// works without a process supervisor. +// - "exit" — exit the process so an external supervisor (Docker +// restart=always, ECS, systemd, k8s, ...) replaces it. Recommended when a +// supervisor is present because it gets a clean slate every time. +// - "log" — preserve the historical no-op behaviour. Useful for +// debugging or in test environments where you want to observe the +// silent-death failure mode. +export type ReplicationErrorRecoveryStrategy = + | { + type: "reconnect"; + initialDelayMs?: number; + maxDelayMs?: number; + // 0 (or undefined) means retry forever. + maxAttempts?: number; + } + | { + type: "exit"; + exitDelayMs?: number; + exitCode?: number; + } + | { type: "log" }; + +export type ReplicationErrorRecoveryDeps = { + strategy: ReplicationErrorRecoveryStrategy; + logger: Logger; + // Re-subscribe the underlying replication client. Implementations should + // call client.subscribe(...) and resolve once the stream is started. + reconnect: () => Promise; + // True once the host service has begun graceful shutdown — recovery + // suppresses all work in that state. + isShuttingDown: () => boolean; +}; + +export type ReplicationErrorRecovery = { + // Called from the replication client's "error" event handler. + handle(error: unknown): void; + // Called from the replication client's "start" event handler. Resets the + // reconnect attempt counter so the next failure starts from initialDelayMs. + notifyStreamStarted(): void; + // Called from the replication client's "leaderElection" event handler with + // isLeader=false. Only the reconnect strategy acts on this; exit and log + // strategies treat losing the lock as a normal multi-instance state (an + // "exit" instance would otherwise restart-loop whenever a peer holds it). + notifyLeaderElectionLost(error: unknown): void; + // Cancel any pending reconnect/exit timer. Called from shutdown(). + dispose(): void; +}; + +export function createReplicationErrorRecovery( + deps: ReplicationErrorRecoveryDeps +): ReplicationErrorRecovery { + const { strategy, logger, reconnect, isShuttingDown } = deps; + let attempt = 0; + let pendingReconnect: NodeJS.Timeout | null = null; + let pendingExit: NodeJS.Timeout | null = null; + let exiting = false; + + function scheduleReconnect(error: unknown): void { + if (strategy.type !== "reconnect") return; + if (pendingReconnect) return; + + attempt += 1; + const maxAttempts = strategy.maxAttempts ?? 0; + if (maxAttempts > 0 && attempt > maxAttempts) { + logger.error("Replication reconnect exceeded maxAttempts; giving up", { + attempt, + maxAttempts, + error, + }); + return; + } + + const initialDelay = strategy.initialDelayMs ?? 1_000; + const maxDelay = strategy.maxDelayMs ?? 60_000; + const delay = Math.min(initialDelay * Math.pow(2, attempt - 1), maxDelay); + + logger.error("Replication stream lost — scheduling reconnect", { + attempt, + delayMs: delay, + error, + }); + + pendingReconnect = setTimeout(async () => { + pendingReconnect = null; + if (isShuttingDown()) return; + + try { + await reconnect(); + // Success path is handled by notifyStreamStarted, which fires from + // the replication client's "start" event after the stream is live. + } catch (err) { + // subscribe() can throw without first emitting an "error" event — + // notably when the initial pg client.connect() fails because Postgres + // is still unreachable mid-failover. Schedule the next attempt + // ourselves so recovery doesn't silently stop. If subscribe() did + // also emit an "error" event, handle() will call scheduleReconnect() + // first; the guard on pendingReconnect makes this idempotent. + logger.error("Replication reconnect attempt failed", { + attempt, + error: err, + }); + scheduleReconnect(err); + } + }, delay); + } + + function scheduleExit(): void { + if (strategy.type !== "exit") return; + if (exiting) return; + exiting = true; + + const delay = strategy.exitDelayMs ?? 5_000; + const code = strategy.exitCode ?? 1; + + logger.error("Fatal replication error — exiting to let process supervisor restart", { + exitCode: code, + exitDelayMs: delay, + }); + + pendingExit = setTimeout(() => { + // eslint-disable-next-line no-process-exit + process.exit(code); + }, delay); + // Don't hold a clean shutdown back on this timer. + pendingExit.unref(); + } + + return { + handle(error) { + if (isShuttingDown()) return; + switch (strategy.type) { + case "log": + return; + case "exit": + return scheduleExit(); + case "reconnect": + return scheduleReconnect(error); + } + }, + notifyStreamStarted() { + if (attempt > 0) { + logger.info("Replication reconnect succeeded", { attempt }); + attempt = 0; + } + }, + notifyLeaderElectionLost(error) { + if (isShuttingDown()) return; + // Only the reconnect strategy should react. For exit, losing the + // lock to a peer would otherwise trigger a restart loop. For log, + // we keep historical no-op semantics. + if (strategy.type !== "reconnect") return; + scheduleReconnect(error); + }, + dispose() { + if (pendingReconnect) { + clearTimeout(pendingReconnect); + pendingReconnect = null; + } + if (pendingExit) { + clearTimeout(pendingExit); + pendingExit = null; + } + }, + }; +} + +// Shape of the env-driven configuration object the instance bootstrap files +// build from process.env. Kept separate from the strategy union above so the +// instance code can pass a single object regardless of which strategy is set. +export type ReplicationErrorRecoveryEnv = { + strategy: "reconnect" | "exit" | "log"; + reconnectInitialDelayMs?: number; + reconnectMaxDelayMs?: number; + reconnectMaxAttempts?: number; + exitDelayMs?: number; + exitCode?: number; +}; + +export function strategyFromEnv( + env: ReplicationErrorRecoveryEnv +): ReplicationErrorRecoveryStrategy { + switch (env.strategy) { + case "exit": + return { + type: "exit", + exitDelayMs: env.exitDelayMs, + exitCode: env.exitCode, + }; + case "log": + return { type: "log" }; + case "reconnect": + default: + return { + type: "reconnect", + initialDelayMs: env.reconnectInitialDelayMs, + maxDelayMs: env.reconnectMaxDelayMs, + maxAttempts: env.reconnectMaxAttempts, + }; + } +} diff --git a/apps/webapp/app/services/runsReplicationInstance.server.ts b/apps/webapp/app/services/runsReplicationInstance.server.ts index 0a8ab5e1bde..a614793ccc9 100644 --- a/apps/webapp/app/services/runsReplicationInstance.server.ts +++ b/apps/webapp/app/services/runsReplicationInstance.server.ts @@ -3,6 +3,7 @@ import invariant from "tiny-invariant"; import { env } from "~/env.server"; import { singleton } from "~/utils/singleton"; import { meter, provider } from "~/v3/tracer.server"; +import { strategyFromEnv } from "./replicationErrorRecovery.server"; import { RunsReplicationService } from "./runsReplicationService.server"; import { signalsEmitter } from "./signals.server"; @@ -69,6 +70,14 @@ function initializeRunsReplicationInstance() { insertStrategy: env.RUN_REPLICATION_INSERT_STRATEGY, disablePayloadInsert: env.RUN_REPLICATION_DISABLE_PAYLOAD_INSERT === "1", disableErrorFingerprinting: env.RUN_REPLICATION_DISABLE_ERROR_FINGERPRINTING === "1", + errorRecovery: strategyFromEnv({ + strategy: env.RUN_REPLICATION_ERROR_STRATEGY, + reconnectInitialDelayMs: env.REPLICATION_RECONNECT_INITIAL_DELAY_MS, + reconnectMaxDelayMs: env.REPLICATION_RECONNECT_MAX_DELAY_MS, + reconnectMaxAttempts: env.REPLICATION_RECONNECT_MAX_ATTEMPTS, + exitDelayMs: env.RUN_REPLICATION_EXIT_DELAY_MS, + exitCode: env.RUN_REPLICATION_EXIT_CODE, + }), }); if (env.RUN_REPLICATION_ENABLED === "1") { diff --git a/apps/webapp/app/services/runsReplicationService.server.ts b/apps/webapp/app/services/runsReplicationService.server.ts index 167564572eb..4bdc2551dd8 100644 --- a/apps/webapp/app/services/runsReplicationService.server.ts +++ b/apps/webapp/app/services/runsReplicationService.server.ts @@ -29,6 +29,11 @@ import EventEmitter from "node:events"; import pLimit from "p-limit"; import { detectBadJsonStrings } from "~/utils/detectBadJsonStrings"; import { calculateErrorFingerprint } from "~/utils/errorFingerprinting"; +import { + createReplicationErrorRecovery, + type ReplicationErrorRecovery, + type ReplicationErrorRecoveryStrategy, +} from "./replicationErrorRecovery.server"; interface TransactionEvent { tag: "insert" | "update" | "delete"; @@ -73,6 +78,9 @@ export type RunsReplicationServiceOptions = { insertMaxDelayMs?: number; disablePayloadInsert?: boolean; disableErrorFingerprinting?: boolean; + // What to do when the replication client errors (e.g. after a Postgres + // failover). Defaults to in-process reconnect with exponential backoff. + errorRecovery?: ReplicationErrorRecoveryStrategy; }; type PostgresTaskRun = TaskRun & { masterQueue: string }; @@ -119,6 +127,7 @@ export class RunsReplicationService { private _insertStrategy: "insert" | "insert_async"; private _disablePayloadInsert: boolean; private _disableErrorFingerprinting: boolean; + private _errorRecovery: ReplicationErrorRecovery; // Metrics private _replicationLagHistogram: Histogram; @@ -250,14 +259,25 @@ export class RunsReplicationService { } }); + this._errorRecovery = createReplicationErrorRecovery({ + strategy: options.errorRecovery ?? { type: "reconnect" }, + logger: this.logger, + reconnect: async () => { + await this._replicationClient.subscribe(this._latestCommitEndLsn ?? undefined); + }, + isShuttingDown: () => this._isShuttingDown || this._isShutDownComplete, + }); + this._replicationClient.events.on("error", (error) => { this.logger.error("Replication client error", { error, }); + this._errorRecovery.handle(error); }); this._replicationClient.events.on("start", () => { this.logger.info("Replication client started"); + this._errorRecovery.notifyStreamStarted(); }); this._replicationClient.events.on("acknowledge", ({ lsn }) => { @@ -266,6 +286,16 @@ export class RunsReplicationService { this._replicationClient.events.on("leaderElection", (isLeader) => { this.logger.info("Leader election", { isLeader }); + if (!isLeader) { + // Failed leader election doesn't throw or emit an "error" event — + // subscribe() just emits leaderElection(false), calls stop(), and + // returns. Route through a dedicated handler so only the reconnect + // strategy acts; the exit strategy must not restart-loop when + // another instance holds the lock. + this._errorRecovery.notifyLeaderElectionLost( + new Error("Failed to acquire replication leader lock") + ); + } }); // Initialize retry configuration @@ -278,6 +308,7 @@ export class RunsReplicationService { if (this._isShuttingDown) return; this._isShuttingDown = true; + this._errorRecovery.dispose(); this.logger.info("Initiating shutdown of runs replication service"); diff --git a/apps/webapp/app/services/sessionsReplicationInstance.server.ts b/apps/webapp/app/services/sessionsReplicationInstance.server.ts index c6ed1b6b088..5954d50df57 100644 --- a/apps/webapp/app/services/sessionsReplicationInstance.server.ts +++ b/apps/webapp/app/services/sessionsReplicationInstance.server.ts @@ -3,6 +3,7 @@ import invariant from "tiny-invariant"; import { env } from "~/env.server"; import { singleton } from "~/utils/singleton"; import { meter, provider } from "~/v3/tracer.server"; +import { strategyFromEnv } from "./replicationErrorRecovery.server"; import { SessionsReplicationService } from "./sessionsReplicationService.server"; export const sessionsReplicationInstance = singleton( @@ -66,6 +67,14 @@ function initializeSessionsReplicationInstance() { insertBaseDelayMs: env.SESSION_REPLICATION_INSERT_BASE_DELAY_MS, insertMaxDelayMs: env.SESSION_REPLICATION_INSERT_MAX_DELAY_MS, insertStrategy: env.SESSION_REPLICATION_INSERT_STRATEGY, + errorRecovery: strategyFromEnv({ + strategy: env.SESSION_REPLICATION_ERROR_STRATEGY, + reconnectInitialDelayMs: env.REPLICATION_RECONNECT_INITIAL_DELAY_MS, + reconnectMaxDelayMs: env.REPLICATION_RECONNECT_MAX_DELAY_MS, + reconnectMaxAttempts: env.REPLICATION_RECONNECT_MAX_ATTEMPTS, + exitDelayMs: env.SESSION_REPLICATION_EXIT_DELAY_MS, + exitCode: env.SESSION_REPLICATION_EXIT_CODE, + }), }); return service; diff --git a/apps/webapp/app/services/sessionsReplicationService.server.ts b/apps/webapp/app/services/sessionsReplicationService.server.ts index f7f384faffc..95b386f9686 100644 --- a/apps/webapp/app/services/sessionsReplicationService.server.ts +++ b/apps/webapp/app/services/sessionsReplicationService.server.ts @@ -23,6 +23,11 @@ import { tryCatch } from "@trigger.dev/core/utils"; import { type Session } from "@trigger.dev/database"; import EventEmitter from "node:events"; import { ConcurrentFlushScheduler } from "./runsReplicationService.server"; +import { + createReplicationErrorRecovery, + type ReplicationErrorRecovery, + type ReplicationErrorRecoveryStrategy, +} from "./replicationErrorRecovery.server"; interface TransactionEvent { tag: "insert" | "update" | "delete"; @@ -65,6 +70,9 @@ export type SessionsReplicationServiceOptions = { insertMaxRetries?: number; insertBaseDelayMs?: number; insertMaxDelayMs?: number; + // What to do when the replication client errors (e.g. after a Postgres + // failover). Defaults to in-process reconnect with exponential backoff. + errorRecovery?: ReplicationErrorRecoveryStrategy; }; type SessionInsert = { @@ -105,6 +113,7 @@ export class SessionsReplicationService { private _insertBaseDelayMs: number; private _insertMaxDelayMs: number; private _insertStrategy: "insert" | "insert_async"; + private _errorRecovery: ReplicationErrorRecovery; // Metrics private _replicationLagHistogram: Histogram; @@ -231,14 +240,25 @@ export class SessionsReplicationService { } }); + this._errorRecovery = createReplicationErrorRecovery({ + strategy: options.errorRecovery ?? { type: "reconnect" }, + logger: this.logger, + reconnect: async () => { + await this._replicationClient.subscribe(this._latestCommitEndLsn ?? undefined); + }, + isShuttingDown: () => this._isShuttingDown || this._isShutDownComplete, + }); + this._replicationClient.events.on("error", (error) => { this.logger.error("Replication client error", { error, }); + this._errorRecovery.handle(error); }); this._replicationClient.events.on("start", () => { this.logger.info("Replication client started"); + this._errorRecovery.notifyStreamStarted(); }); this._replicationClient.events.on("acknowledge", ({ lsn }) => { @@ -247,6 +267,12 @@ export class SessionsReplicationService { this._replicationClient.events.on("leaderElection", (isLeader) => { this.logger.info("Leader election", { isLeader }); + if (!isLeader) { + // See RunsReplicationService for the rationale. + this._errorRecovery.notifyLeaderElectionLost( + new Error("Failed to acquire replication leader lock") + ); + } }); // Initialize retry configuration @@ -259,6 +285,7 @@ export class SessionsReplicationService { if (this._isShuttingDown) return; this._isShuttingDown = true; + this._errorRecovery.dispose(); this.logger.info("Initiating shutdown of sessions replication service"); diff --git a/apps/webapp/app/v3/tracer.server.ts b/apps/webapp/app/v3/tracer.server.ts index 3b924ff8a19..a71eb36c845 100644 --- a/apps/webapp/app/v3/tracer.server.ts +++ b/apps/webapp/app/v3/tracer.server.ts @@ -56,9 +56,7 @@ import { LoggerSpanExporter } from "./telemetry/loggerExporter.server"; import { CompactMetricExporter } from "./telemetry/compactMetricExporter.server"; import { logger } from "~/services/logger.server"; import { flattenAttributes } from "@trigger.dev/core/v3"; -import { prisma } from "~/db.server"; import { metricsRegister } from "~/metrics.server"; -import type { Prisma } from "@trigger.dev/database"; import { performance } from "node:perf_hooks"; export const SEMINTATTRS_FORCE_RECORDING = "forceRecording"; @@ -352,221 +350,12 @@ function setupMetrics() { const meter = meterProvider.getMeter("trigger.dev", "3.3.12"); - configurePrismaMetrics({ meter }); configureNodejsMetrics({ meter }); configureHostMetrics({ meterProvider }); return meter; } -function configurePrismaMetrics({ meter }: { meter: Meter }) { - // Counters - const queriesTotal = meter.createObservableCounter("db.client.queries.total", { - description: "Total number of Prisma Client queries executed", - unit: "queries", - }); - const datasourceQueriesTotal = meter.createObservableCounter("db.datasource.queries.total", { - description: "Total number of datasource queries executed", - unit: "queries", - }); - const connectionsOpenedTotal = meter.createObservableCounter("db.pool.connections.opened.total", { - description: "Total number of pool connections opened", - unit: "connections", - }); - const connectionsClosedTotal = meter.createObservableCounter("db.pool.connections.closed.total", { - description: "Total number of pool connections closed", - unit: "connections", - }); - - // Gauges - const queriesActive = meter.createObservableGauge("db.client.queries.active", { - description: "Number of currently active Prisma Client queries", - unit: "queries", - }); - const queriesWait = meter.createObservableGauge("db.client.queries.wait", { - description: "Number of queries currently waiting for a connection", - unit: "queries", - }); - const totalGauge = meter.createObservableGauge("db.pool.connections.total", { - description: "Open Prisma-pool connections", - unit: "connections", - }); - const busyGauge = meter.createObservableGauge("db.pool.connections.busy", { - description: "Connections currently executing queries", - unit: "connections", - }); - const freeGauge = meter.createObservableGauge("db.pool.connections.free", { - description: "Idle (free) connections in the pool", - unit: "connections", - }); - - // Histogram statistics as gauges - const queriesWaitTimeCount = meter.createObservableGauge("db.client.queries.wait_time.count", { - description: "Number of wait time observations", - unit: "observations", - }); - const queriesWaitTimeSum = meter.createObservableGauge("db.client.queries.wait_time.sum", { - description: "Total wait time across all observations", - unit: "ms", - }); - const queriesWaitTimeMean = meter.createObservableGauge("db.client.queries.wait_time.mean", { - description: "Average wait time for a connection", - unit: "ms", - }); - - const queriesDurationCount = meter.createObservableGauge("db.client.queries.duration.count", { - description: "Number of query duration observations", - unit: "observations", - }); - const queriesDurationSum = meter.createObservableGauge("db.client.queries.duration.sum", { - description: "Total query duration across all observations", - unit: "ms", - }); - const queriesDurationMean = meter.createObservableGauge("db.client.queries.duration.mean", { - description: "Average duration of Prisma Client queries", - unit: "ms", - }); - - const datasourceQueriesDurationCount = meter.createObservableGauge( - "db.datasource.queries.duration.count", - { - description: "Number of datasource query duration observations", - unit: "observations", - } - ); - const datasourceQueriesDurationSum = meter.createObservableGauge( - "db.datasource.queries.duration.sum", - { - description: "Total datasource query duration across all observations", - unit: "ms", - } - ); - const datasourceQueriesDurationMean = meter.createObservableGauge( - "db.datasource.queries.duration.mean", - { - description: "Average duration of datasource queries", - unit: "ms", - } - ); - - // Single helper so we hit Prisma only once per scrape --------------------- - async function readPrismaMetrics() { - const metrics = await prisma.$metrics.json(); - - // Extract counter values - const counters: Record = {}; - for (const counter of metrics.counters) { - counters[counter.key] = counter.value; - } - - // Extract gauge values - const gauges: Record = {}; - for (const gauge of metrics.gauges) { - gauges[gauge.key] = gauge.value; - } - - // Extract histogram values - const histograms: Record = {}; - for (const histogram of metrics.histograms) { - histograms[histogram.key] = histogram.value; - } - - return { - counters: { - queriesTotal: counters["prisma_client_queries_total"] ?? 0, - datasourceQueriesTotal: counters["prisma_datasource_queries_total"] ?? 0, - connectionsOpenedTotal: counters["prisma_pool_connections_opened_total"] ?? 0, - connectionsClosedTotal: counters["prisma_pool_connections_closed_total"] ?? 0, - }, - gauges: { - queriesActive: gauges["prisma_client_queries_active"] ?? 0, - queriesWait: gauges["prisma_client_queries_wait"] ?? 0, - connectionsOpen: gauges["prisma_pool_connections_open"] ?? 0, - connectionsBusy: gauges["prisma_pool_connections_busy"] ?? 0, - connectionsIdle: gauges["prisma_pool_connections_idle"] ?? 0, - }, - histograms: { - queriesWait: histograms["prisma_client_queries_wait_histogram_ms"], - queriesDuration: histograms["prisma_client_queries_duration_histogram_ms"], - datasourceQueriesDuration: histograms["prisma_datasource_queries_duration_histogram_ms"], - }, - }; - } - - meter.addBatchObservableCallback( - async (res) => { - const { counters, gauges, histograms } = await readPrismaMetrics(); - - // Observe counters - res.observe(queriesTotal, counters.queriesTotal); - res.observe(datasourceQueriesTotal, counters.datasourceQueriesTotal); - res.observe(connectionsOpenedTotal, counters.connectionsOpenedTotal); - res.observe(connectionsClosedTotal, counters.connectionsClosedTotal); - - // Observe gauges - res.observe(queriesActive, gauges.queriesActive); - res.observe(queriesWait, gauges.queriesWait); - res.observe(totalGauge, gauges.connectionsOpen); - res.observe(busyGauge, gauges.connectionsBusy); - res.observe(freeGauge, gauges.connectionsIdle); - - // Observe histogram statistics as gauges - if (histograms.queriesWait) { - res.observe(queriesWaitTimeCount, histograms.queriesWait.count); - res.observe(queriesWaitTimeSum, histograms.queriesWait.sum); - res.observe( - queriesWaitTimeMean, - histograms.queriesWait.count > 0 - ? histograms.queriesWait.sum / histograms.queriesWait.count - : 0 - ); - } - - if (histograms.queriesDuration) { - res.observe(queriesDurationCount, histograms.queriesDuration.count); - res.observe(queriesDurationSum, histograms.queriesDuration.sum); - res.observe( - queriesDurationMean, - histograms.queriesDuration.count > 0 - ? histograms.queriesDuration.sum / histograms.queriesDuration.count - : 0 - ); - } - - if (histograms.datasourceQueriesDuration) { - res.observe(datasourceQueriesDurationCount, histograms.datasourceQueriesDuration.count); - res.observe(datasourceQueriesDurationSum, histograms.datasourceQueriesDuration.sum); - res.observe( - datasourceQueriesDurationMean, - histograms.datasourceQueriesDuration.count > 0 - ? histograms.datasourceQueriesDuration.sum / histograms.datasourceQueriesDuration.count - : 0 - ); - } - }, - [ - queriesTotal, - datasourceQueriesTotal, - connectionsOpenedTotal, - connectionsClosedTotal, - queriesActive, - queriesWait, - totalGauge, - busyGauge, - freeGauge, - queriesWaitTimeCount, - queriesWaitTimeSum, - queriesWaitTimeMean, - queriesDurationCount, - queriesDurationSum, - queriesDurationMean, - datasourceQueriesDurationCount, - datasourceQueriesDurationSum, - datasourceQueriesDurationMean, - ] - ); -} - function configureNodejsMetrics({ meter }: { meter: Meter }) { if (!env.INTERNAL_OTEL_NODEJS_METRICS_ENABLED) { return; diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 198ce88b9f5..60375894aae 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -89,7 +89,7 @@ "@opentelemetry/sdk-trace-node": "2.0.1", "@opentelemetry/semantic-conventions": "1.36.0", "@popperjs/core": "^2.11.8", - "@prisma/instrumentation": "^6.14.0", + "@prisma/instrumentation": "^7.7.0", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.0.4", "@radix-ui/react-dialog": "^1.0.3", diff --git a/apps/webapp/test/runsReplicationBenchmark.producer.ts b/apps/webapp/test/runsReplicationBenchmark.producer.ts index 547d1318c46..2e69e4655f8 100644 --- a/apps/webapp/test/runsReplicationBenchmark.producer.ts +++ b/apps/webapp/test/runsReplicationBenchmark.producer.ts @@ -5,6 +5,7 @@ */ import { PrismaClient } from "@trigger.dev/database"; +import { PrismaPg } from "@prisma/adapter-pg"; import { performance } from "node:perf_hooks"; interface ProducerConfig { @@ -91,13 +92,8 @@ function generateError() { } async function runProducer(config: ProducerConfig) { - const prisma = new PrismaClient({ - datasources: { - db: { - url: config.postgresUrl, - }, - }, - }); + const adapter = new PrismaPg(config.postgresUrl); + const prisma = new PrismaClient({ adapter }); try { console.log( diff --git a/apps/webapp/test/runsReplicationService.errorRecovery.test.ts b/apps/webapp/test/runsReplicationService.errorRecovery.test.ts new file mode 100644 index 00000000000..fc25c3b9eef --- /dev/null +++ b/apps/webapp/test/runsReplicationService.errorRecovery.test.ts @@ -0,0 +1,305 @@ +import { ClickHouse } from "@internal/clickhouse"; +import { containerTest } from "@internal/testcontainers"; +import { setTimeout } from "node:timers/promises"; +import { z } from "zod"; +import { RunsReplicationService } from "~/services/runsReplicationService.server"; + +vi.setConfig({ testTimeout: 120_000 }); + +// These tests force a replication-stream disconnect (the same shape Postgres +// reports during an RDS failover) and verify each error-recovery strategy +// behaves correctly: +// - "reconnect" (default) auto-resubscribes and resumes from the last LSN +// - "exit" exits the process so a supervisor restarts it +// - "log" keeps historical behaviour (silent death of the stream) +describe("RunsReplicationService error recovery", () => { + containerTest( + "reconnect strategy auto-recovers after the replication backend is killed", + async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => { + await prisma.$executeRawUnsafe(`ALTER TABLE public."TaskRun" REPLICA IDENTITY FULL;`); + + const clickhouse = new ClickHouse({ + url: clickhouseContainer.getConnectionUrl(), + name: "runs-replication", + compression: { request: true }, + logLevel: "warn", + }); + + const service = new RunsReplicationService({ + clickhouse, + pgConnectionUrl: postgresContainer.getConnectionUri(), + serviceName: "runs-replication", + slotName: "task_runs_to_clickhouse_v1", + publicationName: "task_runs_to_clickhouse_v1_publication", + redisOptions, + maxFlushConcurrency: 1, + flushIntervalMs: 100, + flushBatchSize: 1, + leaderLockTimeoutMs: 5000, + leaderLockExtendIntervalMs: 1000, + ackIntervalSeconds: 5, + logLevel: "warn", + // Tight backoff so the test doesn't wait minutes. + errorRecovery: { + type: "reconnect", + initialDelayMs: 200, + maxDelayMs: 1000, + }, + }); + + try { + await service.start(); + const seed = await seedOrgProjectEnv(prisma); + + // Insert a row pre-failure and verify it replicates. + const runA = await createTaskRun(prisma, seed, "run_pre_failover"); + await waitForRunIdsInClickHouse(clickhouse, [runA.id]); + + // Kill the WAL sender backend — same shape as the RDS failover that + // dropped both replication clients on test cloud. + await killReplicationBackend(prisma, "runs-replication"); + + // Insert a row after the kill. With the reconnect strategy the + // service should automatically resubscribe and pick this up. + const runB = await createTaskRun(prisma, seed, "run_post_failover"); + await waitForRunIdsInClickHouse(clickhouse, [runA.id, runB.id], { timeoutMs: 30_000 }); + } finally { + await service.shutdown(); + } + } + ); + + containerTest( + "exit strategy calls process.exit after the replication backend is killed", + async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => { + await prisma.$executeRawUnsafe(`ALTER TABLE public."TaskRun" REPLICA IDENTITY FULL;`); + + // Stub process.exit so the test process itself doesn't terminate. + // mockImplementation returns never; cast to satisfy the signature. + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(((code?: number) => undefined as never) as typeof process.exit); + + const clickhouse = new ClickHouse({ + url: clickhouseContainer.getConnectionUrl(), + name: "runs-replication", + compression: { request: true }, + logLevel: "warn", + }); + + const service = new RunsReplicationService({ + clickhouse, + pgConnectionUrl: postgresContainer.getConnectionUri(), + serviceName: "runs-replication", + slotName: "task_runs_to_clickhouse_v1", + publicationName: "task_runs_to_clickhouse_v1_publication", + redisOptions, + maxFlushConcurrency: 1, + flushIntervalMs: 100, + flushBatchSize: 1, + leaderLockTimeoutMs: 5000, + leaderLockExtendIntervalMs: 1000, + ackIntervalSeconds: 5, + logLevel: "warn", + errorRecovery: { + type: "exit", + // Short delay so the test stays quick; the flush window doesn't + // matter here because we're stubbing the actual exit call. + exitDelayMs: 100, + exitCode: 1, + }, + }); + + try { + await service.start(); + const seed = await seedOrgProjectEnv(prisma); + + // Sanity check: replication is alive before the kill. + const runA = await createTaskRun(prisma, seed, "run_pre_exit"); + await waitForRunIdsInClickHouse(clickhouse, [runA.id]); + + await killReplicationBackend(prisma, "runs-replication"); + + // Wait long enough for the error event to fire and the exit timer + // to elapse, plus slack. + await setTimeout(2000); + + expect(exitSpy).toHaveBeenCalledWith(1); + } finally { + // shutdown() before mockRestore() so any in-flight exit timer can + // be disposed without terminating the Vitest worker. + await service.shutdown(); + exitSpy.mockRestore(); + } + } + ); + + containerTest( + "log strategy leaves replication permanently stopped", + async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => { + await prisma.$executeRawUnsafe(`ALTER TABLE public."TaskRun" REPLICA IDENTITY FULL;`); + + const clickhouse = new ClickHouse({ + url: clickhouseContainer.getConnectionUrl(), + name: "runs-replication", + compression: { request: true }, + logLevel: "warn", + }); + + const service = new RunsReplicationService({ + clickhouse, + pgConnectionUrl: postgresContainer.getConnectionUri(), + serviceName: "runs-replication", + slotName: "task_runs_to_clickhouse_v1", + publicationName: "task_runs_to_clickhouse_v1_publication", + redisOptions, + maxFlushConcurrency: 1, + flushIntervalMs: 100, + flushBatchSize: 1, + leaderLockTimeoutMs: 5000, + leaderLockExtendIntervalMs: 1000, + ackIntervalSeconds: 5, + logLevel: "warn", + errorRecovery: { type: "log" }, + }); + + try { + await service.start(); + const seed = await seedOrgProjectEnv(prisma); + + const runA = await createTaskRun(prisma, seed, "run_pre_log"); + await waitForRunIdsInClickHouse(clickhouse, [runA.id]); + + await killReplicationBackend(prisma, "runs-replication"); + + // Give the service time to attempt (and not) any recovery. + await setTimeout(2000); + + // Insert a row after the kill — under the log strategy nothing + // brings the stream back, so this should not appear in ClickHouse. + const runB = await createTaskRun(prisma, seed, "run_post_log"); + await setTimeout(3000); + + const ids = await readReplicatedRunIds(clickhouse); + expect(ids).toContain(runA.id); + expect(ids).not.toContain(runB.id); + } finally { + await service.shutdown(); + } + } + ); +}); + +// -------------------------------------------------------------------------- +// helpers +// -------------------------------------------------------------------------- + +type SeedRefs = { + organizationId: string; + projectId: string; + runtimeEnvironmentId: string; +}; + +async function seedOrgProjectEnv(prisma: any): Promise { + const organization = await prisma.organization.create({ + data: { title: "test", slug: "test" }, + }); + const project = await prisma.project.create({ + data: { + name: "test", + slug: "test", + organizationId: organization.id, + externalRef: "test", + }, + }); + const runtimeEnvironment = await prisma.runtimeEnvironment.create({ + data: { + slug: "test", + type: "DEVELOPMENT", + projectId: project.id, + organizationId: organization.id, + apiKey: "test", + pkApiKey: "test", + shortcode: "test", + }, + }); + return { + organizationId: organization.id, + projectId: project.id, + runtimeEnvironmentId: runtimeEnvironment.id, + }; +} + +async function createTaskRun(prisma: any, seed: SeedRefs, friendlyId: string) { + return prisma.taskRun.create({ + data: { + friendlyId, + taskIdentifier: "my-task", + payload: JSON.stringify({ foo: "bar" }), + traceId: friendlyId, + spanId: friendlyId, + queue: "test", + runtimeEnvironmentId: seed.runtimeEnvironmentId, + projectId: seed.projectId, + organizationId: seed.organizationId, + environmentType: "DEVELOPMENT", + engine: "V2", + }, + }); +} + +// Kills any active WAL-sender backends whose application_name matches the +// service. This mirrors the failover-style disconnect that surfaced the bug: +// the WAL stream connection drops and the LogicalReplicationClient errors. +async function killReplicationBackend(prisma: any, applicationName: string) { + // Wait briefly for the WAL sender to appear in pg_stat_replication after + // subscribe() completes — there's a small async gap between + // replicationStart firing and the row being visible to other sessions. + for (let attempt = 0; attempt < 20; attempt++) { + const rows = await prisma.$queryRawUnsafe<{ pid: number }[]>( + `SELECT pid FROM pg_stat_replication WHERE application_name = $1`, + applicationName + ); + if (rows.length > 0) { + for (const { pid } of rows) { + await prisma.$executeRawUnsafe(`SELECT pg_terminate_backend(${pid})`); + } + return; + } + await setTimeout(100); + } + throw new Error( + `No active replication backend found for application_name=${applicationName} after 2s` + ); +} + +async function readReplicatedRunIds(clickhouse: ClickHouse): Promise { + const queryRuns = clickhouse.reader.query({ + name: "runs-replication", + query: "SELECT run_id FROM trigger_dev.task_runs_v2", + schema: z.object({ run_id: z.string() }), + }); + const [queryError, result] = await queryRuns({}); + if (queryError) throw queryError; + return (result ?? []).map((row) => row.run_id); +} + +async function waitForRunIdsInClickHouse( + clickhouse: ClickHouse, + expectedIds: string[], + options: { timeoutMs?: number; pollIntervalMs?: number } = {} +) { + const timeoutMs = options.timeoutMs ?? 10_000; + const pollIntervalMs = options.pollIntervalMs ?? 250; + const deadline = Date.now() + timeoutMs; + let lastIds: string[] = []; + while (Date.now() < deadline) { + lastIds = await readReplicatedRunIds(clickhouse); + if (expectedIds.every((id) => lastIds.includes(id))) return; + await setTimeout(pollIntervalMs); + } + throw new Error( + `Timed out waiting for run ids ${JSON.stringify(expectedIds)} to land in ClickHouse. ` + + `Last seen: ${JSON.stringify(lastIds)}` + ); +} diff --git a/consolidated_pr_body.md b/consolidated_pr_body.md new file mode 100644 index 00000000000..46f06b3556b --- /dev/null +++ b/consolidated_pr_body.md @@ -0,0 +1,40 @@ +# Consolidated Bug Fixes + +This PR combines fixes for several independent issues identified in the codebase, covering CLI stability, deployment/build reliability, and runtime correctness. + +## Fixes + +| Issue / Feature | Description | +|-----------------|-------------| +| **Orphaned Workers** | Fixes `trigger dev` leaving orphaned `trigger-dev-run-worker` processes by ensuring graceful shutdown on `SIGINT`/`SIGTERM` and robust process cleanup. | +| **Sentry Interception** | Fixes `ConsoleInterceptor` swallowing logs when Sentry (or other monkey-patchers) are present by delegating to the original preserved console methods. | +| **Engine Strictness** | Fixes deployment failures on GitHub Integration when `engines.node` is strict (e.g. "22") by passing `--no-engine-strict` (and equivalents) during the `trigger deploy` build phase. | +| **Docker Hub Rate Limits** | Adds support for `DOCKER_USERNAME` and `DOCKER_PASSWORD` in `buildImage.ts` to authenticate with Docker Hub and avoid rate limits during native builds. | +| **Dead Process Hang** | Fixes a hang in `TaskRunProcess.execute()` by checking specific process connectivity before attempting to send IPC messages. | +| **Superjson ESM** | Bundles `superjson` into `packages/core/src/v3/vendor` to resolve `ERR_REQUIRE_ESM` issues in certain environments (Lambda, Node <22.12). | +| **Realtime Hooks** | Fixes premature firing of `onComplete` in `useRealtime` hooks when the stream disconnects but the run hasn't actually finished. | +| **Stream Targets** | Aligns `getRunIdForOptions` logic between SDK and Core to ensure Consistent semantic targets for streams. | +| **Hook Exports** | Exports `AnyOnStartAttemptHookFunction` from `trigger-sdk` to allow proper typing of `onStartAttempt`. | + +## Verification + +### Automated Verification +- **Engine Strictness**: Pass in `packages/cli-v3/src/commands/update.test.ts`. +- **Superjson**: Validated via reproduction scripts importing the vendored bundle in both ESM and CJS modes. +- **Sentry**: Validated via `repro_2900_sentry.ts` script ensuring logs flow through Sentry patches. + +### Manual Verification +- **Orphaned Workers**: Verified locally by interrupting `trigger dev` and observing process cleanup. +- **Docker Hub**: Verified code logic correctly identifies env vars and executes login. +- **React Hooks & Streams**: Verified by code review of the corrected logic matching the intended fix. + +## Changesets +- `fix-orphaned-workers-2909` +- `fix-sentry-console-interceptor-2900` +- `fix-github-install-node-version-2913` +- `fix-docker-hub-rate-limit-2911` +- `fix-dead-process-execute-hang` +- `vendor-superjson-esm-fix` +- `calm-hooks-wait` +- `consistent-stream-targets` +- `export-start-attempt-hook-type` diff --git a/docker/Dockerfile b/docker/Dockerfile index 5906b63e194..9bd8d231c36 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -30,7 +30,8 @@ ENV NODE_ENV=development RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --no-frozen-lockfile # Generate Prisma client here where all deps are installed COPY --from=pruner --chown=node:node /triggerdotdev/internal-packages/database/prisma/schema.prisma /triggerdotdev/internal-packages/database/prisma/schema.prisma -RUN pnpx prisma@6.14.0 generate --schema /triggerdotdev/internal-packages/database/prisma/schema.prisma +COPY --from=pruner --chown=node:node /triggerdotdev/internal-packages/database/prisma.config.ts /triggerdotdev/internal-packages/database/prisma.config.ts +RUN pnpx prisma@7.7.0 generate --schema /triggerdotdev/internal-packages/database/prisma/schema.prisma ## Production deps FROM base AS production-deps diff --git a/docker/scripts/entrypoint.sh b/docker/scripts/entrypoint.sh index a6bc7dd15b9..8436e9a0da8 100755 --- a/docker/scripts/entrypoint.sh +++ b/docker/scripts/entrypoint.sh @@ -41,7 +41,6 @@ fi # Copy over required prisma files cp internal-packages/database/prisma/schema.prisma apps/webapp/prisma/ -cp node_modules/@prisma/engines/*.node apps/webapp/prisma/ cd /triggerdotdev/apps/webapp diff --git a/internal-packages/database/package.json b/internal-packages/database/package.json index cd9b40db95d..3465cd57ebd 100644 --- a/internal-packages/database/package.json +++ b/internal-packages/database/package.json @@ -5,12 +5,13 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "dependencies": { - "@prisma/client": "6.14.0", + "@prisma/adapter-pg": "7.7.0", + "@prisma/client": "7.7.0", "decimal.js": "^10.6.0" }, "devDependencies": { "@types/decimal.js": "^7.4.3", - "prisma": "6.14.0", + "prisma": "7.7.0", "rimraf": "6.0.1" }, "scripts": { @@ -25,4 +26,4 @@ "build": "pnpm run clean && tsc --noEmit false --outDir dist --declaration", "dev": "tsc --noEmit false --outDir dist --declaration --watch" } -} \ No newline at end of file +} diff --git a/internal-packages/database/prisma.config.ts b/internal-packages/database/prisma.config.ts new file mode 100644 index 00000000000..28615ea4995 --- /dev/null +++ b/internal-packages/database/prisma.config.ts @@ -0,0 +1,11 @@ +import path from "node:path"; +import { defineConfig } from "prisma/config"; + +export default defineConfig({ + schema: path.join(__dirname, "prisma", "schema.prisma"), + engine: "classic", + datasource: { + url: process.env.DATABASE_URL ?? "postgresql://localhost:5432/trigger", + directUrl: process.env.DIRECT_URL, + }, +}); diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 7e32a96d805..33478ea2d0e 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -1,14 +1,11 @@ datasource db { - provider = "postgresql" - url = env("DATABASE_URL") - directUrl = env("DIRECT_URL") + provider = "postgresql" } generator client { - provider = "prisma-client-js" - output = "../generated/prisma" - binaryTargets = ["native", "debian-openssl-1.1.x"] - previewFeatures = ["metrics"] + provider = "prisma-client-js" + output = "../generated/prisma" + engineType = "client" } model User { diff --git a/internal-packages/database/src/transaction.ts b/internal-packages/database/src/transaction.ts index 5d0cdb85f0e..21e6c7f16e3 100644 --- a/internal-packages/database/src/transaction.ts +++ b/internal-packages/database/src/transaction.ts @@ -1,6 +1,6 @@ import { PrismaClient } from "../generated/prisma"; import { Decimal } from "decimal.js"; -import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/client"; // Define the isolation levels manually type TransactionIsolationLevel = @@ -37,7 +37,23 @@ export function isPrismaKnownError(error: unknown): error is PrismaClientKnownRe */ const retryCodes = ["P2024", "P2028", "P2034"]; +/** + * With Prisma 7's driver adapter (PrismaPg), write conflicts (PostgreSQL 40001) + * surface as a DriverAdapterError with message "TransactionWriteConflict" instead + * of a PrismaClientKnownRequestError with code P2034. This function detects that + * error so the retry logic still works. + */ +function isDriverAdapterTransactionWriteConflict(error: unknown): boolean { + if (typeof error !== "object" || error === null) return false; + const err = error as { name?: string; message?: string }; + return err.name === "DriverAdapterError" && err.message === "TransactionWriteConflict"; +} + export function isPrismaRetriableError(error: unknown): boolean { + if (isDriverAdapterTransactionWriteConflict(error)) { + return true; + } + if (!isPrismaKnownError(error)) { return false; } @@ -90,15 +106,16 @@ export async function $transaction( try { return await (prisma as PrismaClient).$transaction(fn, options); } catch (error) { - if (isPrismaKnownError(error)) { - if ( - retryCodes.includes(error.code) && - typeof options?.maxRetries === "number" && - attempt < options.maxRetries - ) { - return $transaction(prisma, fn, prismaError, options, attempt + 1); - } + // With Prisma 7 driver adapters, write conflicts (PG 40001) surface as + // DriverAdapterError instead of PrismaClientKnownRequestError P2034. + // Check for both error shapes so retries work correctly. + const retriable = isPrismaRetriableError(error); + + if (retriable && typeof options?.maxRetries === "number" && attempt < options.maxRetries) { + return $transaction(prisma, fn, prismaError, options, attempt + 1); + } + if (isPrismaKnownError(error)) { prismaError(error); if (options?.swallowPrismaErrors) { diff --git a/internal-packages/database/tsconfig.json b/internal-packages/database/tsconfig.json index 67f916782db..62a118e0e3c 100644 --- a/internal-packages/database/tsconfig.json +++ b/internal-packages/database/tsconfig.json @@ -9,5 +9,5 @@ "noEmit": true, "strict": true }, - "exclude": ["node_modules"] + "exclude": ["node_modules", "prisma.config.ts"] } diff --git a/internal-packages/run-engine/src/engine/tests/priority.test.ts b/internal-packages/run-engine/src/engine/tests/priority.test.ts index 13e25186c28..82ba76c132a 100644 --- a/internal-packages/run-engine/src/engine/tests/priority.test.ts +++ b/internal-packages/run-engine/src/engine/tests/priority.test.ts @@ -28,7 +28,12 @@ describe("RunEngine priority", () => { }, queue: { redis: redisOptions, - processWorkerQueueDebounceMs: 50, + // Use a large debounce so the background processQueueForWorkerQueue job + // doesn't race with the manual processMasterQueueForEnvironment call. + // With PrismaPg adapter overhead each trigger() takes longer, so a small + // debounce causes items to be moved to the worker queue individually in + // arrival order rather than collectively in priority order. + processWorkerQueueDebounceMs: 10_000, masterQueueConsumersDisabled: true, }, runLock: { diff --git a/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts b/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts index 9937314d799..b69aaf84fda 100644 --- a/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts +++ b/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts @@ -379,7 +379,7 @@ describe("RunEngine Waitpoints", () => { id: result.waitpoint.id, }); - await setTimeout(200); + await setTimeout(1_000); assertNonNullable(event); const notificationEvent = event as EventBusEventArgs<"workerNotification">[0]; @@ -936,7 +936,7 @@ describe("RunEngine Waitpoints", () => { id: result.waitpoint.id, }); - await setTimeout(200); + await setTimeout(1_000); const executionData2 = await engine.getRunExecutionData({ runId: run.id }); expect(executionData2?.snapshot.executionStatus).toBe("EXECUTING"); @@ -1050,7 +1050,7 @@ describe("RunEngine Waitpoints", () => { environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, idempotencyKey, - idempotencyKeyExpiresAt: new Date(Date.now() + 200), + idempotencyKeyExpiresAt: new Date(Date.now() + 60_000), }); expect(result.waitpoint.status).toBe("PENDING"); expect(result.waitpoint.idempotencyKey).toBe(idempotencyKey); @@ -1060,7 +1060,7 @@ describe("RunEngine Waitpoints", () => { environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, idempotencyKey, - idempotencyKeyExpiresAt: new Date(Date.now() + 200), + idempotencyKeyExpiresAt: new Date(Date.now() + 60_000), }); expect(sameWaitpointResult.waitpoint.id).toBe(result.waitpoint.id); @@ -1096,7 +1096,7 @@ describe("RunEngine Waitpoints", () => { id: result.waitpoint.id, }); - await setTimeout(200); + await setTimeout(1_000); const executionData2 = await engine.getRunExecutionData({ runId: run.id }); expect(executionData2?.snapshot.executionStatus).toBe("EXECUTING"); @@ -1212,9 +1212,9 @@ describe("RunEngine Waitpoints", () => { }); // Wait for the waitpoint to complete and unblock (snapshot 3) - await setTimeout(200); + await setTimeout(500); await engine.completeWaitpoint({ id: waitpoint.id }); - await setTimeout(200); + await setTimeout(1_000); // Get all snapshots for the run const allSnapshots = await prisma.taskRunExecutionSnapshot.findMany({ diff --git a/internal-packages/testcontainers/package.json b/internal-packages/testcontainers/package.json index 104f982cc28..9bd0889571c 100644 --- a/internal-packages/testcontainers/package.json +++ b/internal-packages/testcontainers/package.json @@ -11,6 +11,7 @@ "dependencies": { "@clickhouse/client": "^1.11.1", "@opentelemetry/api": "^1.9.0", + "@prisma/adapter-pg": "7.7.0", "@trigger.dev/database": "workspace:*", "ioredis": "^5.3.2" }, @@ -25,4 +26,4 @@ "scripts": { "typecheck": "tsc --noEmit" } -} \ No newline at end of file +} diff --git a/internal-packages/testcontainers/src/index.ts b/internal-packages/testcontainers/src/index.ts index f678fa01e7b..abcc3b68c3f 100644 --- a/internal-packages/testcontainers/src/index.ts +++ b/internal-packages/testcontainers/src/index.ts @@ -1,6 +1,7 @@ import { StartedPostgreSqlContainer } from "@testcontainers/postgresql"; import { StartedRedisContainer } from "@testcontainers/redis"; import { PrismaClient } from "@trigger.dev/database"; +import { PrismaPg } from "@prisma/adapter-pg"; import { RedisOptions } from "ioredis"; import { Network, type StartedNetwork } from "testcontainers"; import { TaskContext, test } from "vitest"; @@ -106,13 +107,8 @@ export const prisma = async ( console.log("Initializing Prisma with URL:", url); - const prisma = new PrismaClient({ - datasources: { - db: { - url, - }, - }, - }); + const adapter = new PrismaPg(url); + const prisma = new PrismaClient({ adapter }); try { await use(prisma); } finally { diff --git a/internal-packages/testcontainers/src/utils.ts b/internal-packages/testcontainers/src/utils.ts index eca9b06d388..cceb1cf136d 100644 --- a/internal-packages/testcontainers/src/utils.ts +++ b/internal-packages/testcontainers/src/utils.ts @@ -29,9 +29,10 @@ export async function createPostgresContainer(network: StartedNetwork) { "push", "--force-reset", "--accept-data-loss", - "--skip-generate", "--schema", `${databasePath}/prisma/schema.prisma`, + "--url", + container.getConnectionUri(), ], { nodeOptions: { diff --git a/packages/cli-v3/src/cli/common.ts b/packages/cli-v3/src/cli/common.ts index f251e4e5ef4..ba53ce15a56 100644 --- a/packages/cli-v3/src/cli/common.ts +++ b/packages/cli-v3/src/cli/common.ts @@ -14,6 +14,7 @@ export const CommonCommandOptions = z.object({ logLevel: z.enum(["debug", "info", "log", "warn", "error", "none"]).default("log"), skipTelemetry: z.boolean().default(false), profile: z.string().default(readAuthConfigCurrentProfileName()), + ignoreEngines: z.boolean().default(false), }); export type CommonCommandOptions = z.infer; @@ -30,9 +31,9 @@ export function commonOptions(command: Command) { .option("--skip-telemetry", "Opt-out of sending telemetry"); } -export class SkipLoggingError extends Error {} -export class SkipCommandError extends Error {} -export class OutroCommandError extends SkipCommandError {} +export class SkipLoggingError extends Error { } +export class SkipCommandError extends Error { } +export class OutroCommandError extends SkipCommandError { } export async function handleTelemetry(action: () => Promise) { try { diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 1ac161d3e4a..841863855a5 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -259,7 +259,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { } if (!options.skipUpdateCheck) { - await updateTriggerPackages(dir, { ...options }, true, true); + await updateTriggerPackages(dir, { ...options, ignoreEngines: true }, true, true); } const cwd = process.cwd(); @@ -501,9 +501,8 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { const version = deployment.version; const rawDeploymentLink = `${authorization.dashboardUrl}/projects/v3/${resolvedConfig.project}/deployments/${deployment.shortCode}`; - const rawTestLink = `${authorization.dashboardUrl}/projects/v3/${ - resolvedConfig.project - }/test?environment=${options.env === "prod" ? "prod" : "stg"}`; + const rawTestLink = `${authorization.dashboardUrl}/projects/v3/${resolvedConfig.project + }/test?environment=${options.env === "prod" ? "prod" : "stg"}`; const deploymentLink = cliLink("View deployment", rawDeploymentLink); const testLink = cliLink("Test tasks", rawTestLink); @@ -720,8 +719,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { } } else { outro( - `Version ${version} deployed with ${taskCount} detected task${taskCount === 1 ? "" : "s"} ${ - isLinksSupported ? `| ${deploymentLink} | ${testLink}` : "" + `Version ${version} deployed with ${taskCount} detected task${taskCount === 1 ? "" : "s"} ${isLinksSupported ? `| ${deploymentLink} | ${testLink}` : "" }` ); @@ -745,18 +743,16 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { TRIGGER_VERSION: version, TRIGGER_DEPLOYMENT_SHORT_CODE: deployment.shortCode, TRIGGER_DEPLOYMENT_URL: `${authorization.dashboardUrl}/projects/v3/${resolvedConfig.project}/deployments/${deployment.shortCode}`, - TRIGGER_TEST_URL: `${authorization.dashboardUrl}/projects/v3/${ - resolvedConfig.project - }/test?environment=${options.env === "prod" ? "prod" : "stg"}`, + TRIGGER_TEST_URL: `${authorization.dashboardUrl}/projects/v3/${resolvedConfig.project + }/test?environment=${options.env === "prod" ? "prod" : "stg"}`, }, outputs: { deploymentVersion: version, workerVersion: version, deploymentShortCode: deployment.shortCode, deploymentUrl: `${authorization.dashboardUrl}/projects/v3/${resolvedConfig.project}/deployments/${deployment.shortCode}`, - testUrl: `${authorization.dashboardUrl}/projects/v3/${ - resolvedConfig.project - }/test?environment=${options.env === "prod" ? "prod" : "stg"}`, + testUrl: `${authorization.dashboardUrl}/projects/v3/${resolvedConfig.project + }/test?environment=${options.env === "prod" ? "prod" : "stg"}`, needsPromotion: options.skipPromotion ? "true" : "false", }, }); @@ -799,8 +795,7 @@ async function failDeploy( checkLogsForErrors(logs); outro( - `${chalkError(`${prefix}:`)} ${ - error.message + `${chalkError(`${prefix}:`)} ${error.message }. Full build logs have been saved to ${logPath}` ); @@ -1100,9 +1095,8 @@ async function handleNativeBuildServerDeploy({ const deployment = initializeDeploymentResult.data; const rawDeploymentLink = `${dashboardUrl}/projects/v3/${config.project}/deployments/${deployment.shortCode}`; - const rawTestLink = `${dashboardUrl}/projects/v3/${config.project}/test?environment=${ - options.env === "prod" ? "prod" : "stg" - }`; + const rawTestLink = `${dashboardUrl}/projects/v3/${config.project}/test?environment=${options.env === "prod" ? "prod" : "stg" + }`; const exposedDeploymentLink = isLinksSupported ? cliLink(chalk.bold(rawDeploymentLink), rawDeploymentLink) @@ -1167,8 +1161,7 @@ async function handleNativeBuildServerDeploy({ log.warn(`Failed streaming build logs, open the deployment in the dashboard to view the logs`); outro( - `Version ${deployment.version} is being deployed ${ - isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" + `Version ${deployment.version} is being deployed ${isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" }` ); @@ -1214,10 +1207,10 @@ async function handleNativeBuildServerDeploy({ level === "error" ? chalk.bold(chalkError(message)) : level === "warn" - ? chalkWarning(message) - : level === "debug" - ? chalkGrey(message) - : message; + ? chalkWarning(message) + : level === "debug" + ? chalkGrey(message) + : message; // We use console.log here instead of clack's logger as the current version does not support changing the line spacing. // And the logs look verbose with the default spacing. @@ -1250,8 +1243,7 @@ async function handleNativeBuildServerDeploy({ log.error("Failed dequeueing build, please try again shortly"); throw new OutroCommandError( - `Version ${deployment.version} ${ - isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" + `Version ${deployment.version} ${isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" }` ); } @@ -1266,8 +1258,7 @@ async function handleNativeBuildServerDeploy({ } throw new OutroCommandError( - `Version ${deployment.version} ${ - isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" + `Version ${deployment.version} ${isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" }` ); } @@ -1293,13 +1284,12 @@ async function handleNativeBuildServerDeploy({ } outro( - `Version ${deployment.version} was deployed ${ - isLinksSupported - ? `| ${cliLink("Test tasks", rawTestLink)} | ${cliLink( - "View deployment", - rawDeploymentLink - )}` - : "" + `Version ${deployment.version} was deployed ${isLinksSupported + ? `| ${cliLink("Test tasks", rawTestLink)} | ${cliLink( + "View deployment", + rawDeploymentLink + )}` + : "" }` ); return process.exit(0); @@ -1313,14 +1303,13 @@ async function handleNativeBuildServerDeploy({ chalk.bold( chalkError( "Deployment failed" + - (finalDeploymentEvent.message ? `: ${finalDeploymentEvent.message}` : "") + (finalDeploymentEvent.message ? `: ${finalDeploymentEvent.message}` : "") ) ) ); throw new OutroCommandError( - `Version ${deployment.version} deployment failed ${ - isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" + `Version ${deployment.version} deployment failed ${isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" }` ); } @@ -1333,14 +1322,13 @@ async function handleNativeBuildServerDeploy({ chalk.bold( chalkError( "Deployment timed out" + - (finalDeploymentEvent.message ? `: ${finalDeploymentEvent.message}` : "") + (finalDeploymentEvent.message ? `: ${finalDeploymentEvent.message}` : "") ) ) ); throw new OutroCommandError( - `Version ${deployment.version} deployment timed out ${ - isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" + `Version ${deployment.version} deployment timed out ${isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" }` ); } @@ -1353,14 +1341,13 @@ async function handleNativeBuildServerDeploy({ chalk.bold( chalkError( "Deployment was canceled" + - (finalDeploymentEvent.message ? `: ${finalDeploymentEvent.message}` : "") + (finalDeploymentEvent.message ? `: ${finalDeploymentEvent.message}` : "") ) ) ); throw new OutroCommandError( - `Version ${deployment.version} deployment canceled ${ - isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" + `Version ${deployment.version} deployment canceled ${isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" }` ); } @@ -1379,13 +1366,12 @@ async function handleNativeBuildServerDeploy({ } outro( - `Version ${deployment.version} ${ - isLinksSupported - ? `| ${cliLink("Test tasks", rawTestLink)} | ${cliLink( - "View deployment", - rawDeploymentLink - )}` - : "" + `Version ${deployment.version} ${isLinksSupported + ? `| ${cliLink("Test tasks", rawTestLink)} | ${cliLink( + "View deployment", + rawDeploymentLink + )}` + : "" }` ); return process.exit(0); diff --git a/packages/cli-v3/src/commands/dev.ts b/packages/cli-v3/src/commands/dev.ts index 73e79933dd0..dd5b268b01f 100644 --- a/packages/cli-v3/src/commands/dev.ts +++ b/packages/cli-v3/src/commands/dev.ts @@ -183,8 +183,7 @@ export async function devCommand(options: DevCommandOptions) { ); } else { logger.log( - `${chalkError("X Error:")} You must login first. Use the \`login\` CLI command.\n\n${ - authorization.error + `${chalkError("X Error:")} You must login first. Use the \`login\` CLI command.\n\n${authorization.error }` ); } @@ -192,13 +191,30 @@ export async function devCommand(options: DevCommandOptions) { return; } - let watcher; + let devInstance: Awaited> | undefined; + + const cleanup = async () => { + if (devInstance) { + await devInstance.stop(); + } + }; + + const signalHandler = async (signal: string) => { + logger.debug(`Received ${signal}, cleaning up...`); + await cleanup(); + process.exit(0); + }; + try { - const devInstance = await startDev({ ...options, cwd: process.cwd(), login: authorization }); - watcher = devInstance.watcher; + process.on("SIGINT", signalHandler); + process.on("SIGTERM", signalHandler); + + devInstance = await startDev({ ...options, cwd: process.cwd(), login: authorization }); await devInstance.waitUntilExit(); } finally { - await watcher?.stop(); + process.off("SIGINT", signalHandler); + process.off("SIGTERM", signalHandler); + await cleanup(); } } @@ -293,7 +309,7 @@ async function startDev(options: StartDevOptions) { devInstance = await bootDevSession(watcher.config); - const waitUntilExit = async () => {}; + const waitUntilExit = async () => { }; return { watcher, diff --git a/packages/cli-v3/src/commands/login.ts b/packages/cli-v3/src/commands/login.ts index 3561183b36a..7a1a1b88840 100644 --- a/packages/cli-v3/src/commands/login.ts +++ b/packages/cli-v3/src/commands/login.ts @@ -163,6 +163,7 @@ export async function login(options?: LoginOptions): Promise { profile: options?.profile ?? "default", skipTelemetry: !span.isRecording(), logLevel: logger.loggerLevel, + ignoreEngines: false, }, true, opts.silent @@ -173,8 +174,7 @@ export async function login(options?: LoginOptions): Promise { if (!opts.embedded) { outro( - `Login failed using stored token. To fix, first logout using \`trigger.dev logout${ - options?.profile ? ` --profile ${options.profile}` : "" + `Login failed using stored token. To fix, first logout using \`trigger.dev logout${options?.profile ? ` --profile ${options.profile}` : "" }\` and then try again.` ); @@ -328,6 +328,7 @@ export async function login(options?: LoginOptions): Promise { profile: options?.profile ?? "default", skipTelemetry: !span.isRecording(), logLevel: logger.loggerLevel, + ignoreEngines: false, }, opts.embedded ); diff --git a/packages/cli-v3/src/commands/update.test.ts b/packages/cli-v3/src/commands/update.test.ts new file mode 100644 index 00000000000..78d1d62a11d --- /dev/null +++ b/packages/cli-v3/src/commands/update.test.ts @@ -0,0 +1,113 @@ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { updateTriggerPackages } from "./update.js"; +import * as nypm from "nypm"; +import * as pkgTypes from "pkg-types"; +import * as fs from "node:fs/promises"; +import * as clack from "@clack/prompts"; +import path from "node:path"; + +// Mock dependencies +vi.mock("nypm"); +vi.mock("pkg-types"); +vi.mock("node:fs/promises"); +vi.mock("@clack/prompts"); +vi.mock("std-env", () => ({ + hasTTY: true, + isCI: false, +})); +vi.mock("../utilities/initialBanner.js", () => ({ + updateCheck: vi.fn().mockResolvedValue(undefined), + printStandloneInitialBanner: vi.fn(), +})); +vi.mock("../version.js", () => ({ + VERSION: "3.0.0", +})); +vi.mock("../cli/common.js", () => ({ + CommonCommandOptions: { pick: () => ({}) }, +})); +vi.mock("../utilities/cliOutput.js", () => ({ + chalkError: vi.fn(), + prettyError: vi.fn(), + prettyWarning: vi.fn(), +})); +vi.mock("../utilities/fileSystem.js", () => ({ + removeFile: vi.fn(), + writeJSONFilePreserveOrder: vi.fn(), +})); +vi.mock("../utilities/logger.js", () => ({ + logger: { + debug: vi.fn(), + log: vi.fn(), + table: vi.fn(), + }, +})); +vi.mock("../utilities/windows.js", () => ({ + spinner: () => ({ + start: vi.fn(), + message: vi.fn(), + stop: vi.fn(), + }), +})); + +describe("updateTriggerPackages", () => { + beforeEach(() => { + vi.resetAllMocks(); + + // Default mocks + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.rm).mockResolvedValue(undefined); + vi.mocked(pkgTypes.readPackageJSON).mockResolvedValue({ + dependencies: { + "@trigger.dev/sdk": "2.0.0", // Mismatch + }, + }); + vi.mocked(pkgTypes.resolvePackageJSON).mockResolvedValue("/path/to/package.json"); + vi.mocked(clack.confirm).mockResolvedValue(true); // User confirms update + vi.mocked(nypm.installDependencies).mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should pass --no-engine-strict for npm when ignoreEngines is true", async () => { + vi.mocked(nypm.detectPackageManager).mockResolvedValue({ name: "npm", command: "npm", version: "1.0.0" } as any); + + await updateTriggerPackages(".", { ignoreEngines: true } as any, true, true); + + expect(nypm.installDependencies).toHaveBeenCalledWith(expect.objectContaining({ + args: ["--no-engine-strict"], + })); + }); + + it("should pass --config.engine-strict=false for pnpm when ignoreEngines is true", async () => { + vi.mocked(nypm.detectPackageManager).mockResolvedValue({ name: "pnpm", command: "pnpm", version: "1.0.0" } as any); + + await updateTriggerPackages(".", { ignoreEngines: true } as any, true, true); + + expect(nypm.installDependencies).toHaveBeenCalledWith(expect.objectContaining({ + args: ["--config.engine-strict=false"], + })); + }); + + it("should pass --ignore-engines for yarn when ignoreEngines is true", async () => { + vi.mocked(nypm.detectPackageManager).mockResolvedValue({ name: "yarn", command: "yarn", version: "1.0.0" } as any); + + await updateTriggerPackages(".", { ignoreEngines: true } as any, true, true); + + expect(nypm.installDependencies).toHaveBeenCalledWith(expect.objectContaining({ + args: ["--ignore-engines"], + })); + }); + + it("should NOT pass engine flags if ignoreEngines is false (default)", async () => { + vi.mocked(nypm.detectPackageManager).mockResolvedValue({ name: "npm", command: "npm", version: "1.0.0" } as any); + + await updateTriggerPackages(".", { ignoreEngines: false } as any, true, true); + + expect(nypm.installDependencies).toHaveBeenCalledWith(expect.objectContaining({ + args: [], + })); + }); +}); diff --git a/packages/cli-v3/src/commands/update.ts b/packages/cli-v3/src/commands/update.ts index f94718213f7..62af1e080db 100644 --- a/packages/cli-v3/src/commands/update.ts +++ b/packages/cli-v3/src/commands/update.ts @@ -18,6 +18,7 @@ import * as semver from "semver"; export const UpdateCommandOptions = CommonCommandOptions.pick({ logLevel: true, skipTelemetry: true, + ignoreEngines: true, }); export type UpdateCommandOptions = z.infer; @@ -260,8 +261,7 @@ export async function updateTriggerPackages( await installDependencies({ cwd: projectPath, silent: true }); } catch (error) { installSpinner.stop( - `Failed to install new package versions${ - packageManager ? ` with ${packageManager.name}` : "" + `Failed to install new package versions${packageManager ? ` with ${packageManager.name}` : "" }` ); diff --git a/packages/cli-v3/src/deploy/buildImage.ts b/packages/cli-v3/src/deploy/buildImage.ts index aa8285a7c3e..90bbc9ac047 100644 --- a/packages/cli-v3/src/deploy/buildImage.ts +++ b/packages/cli-v3/src/deploy/buildImage.ts @@ -474,6 +474,40 @@ async function localBuildImage(options: SelfHostedBuildImageOptions): Promise ({ + default: { + install: vi.fn(), + }, +})); + +describe("installSourceMapSupport", () => { + const originalEnv = process.env; + const originalSetSourceMapsEnabled = process.setSourceMapsEnabled; + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...originalEnv }; + // Mock setSourceMapsEnabled if it doesn't exist (Node < 16.6) or restore it + process.setSourceMapsEnabled = vi.fn(); + }); + + afterEach(() => { + process.env = originalEnv; + process.setSourceMapsEnabled = originalSetSourceMapsEnabled; + }); + + it("should install source-map-support by default (undefined env var)", () => { + delete process.env.TRIGGER_SOURCE_MAPS; + installSourceMapSupport(); + expect(sourceMapSupport.install).toHaveBeenCalledWith({ + handleUncaughtExceptions: false, + environment: "node", + hookRequire: false, + }); + }); + + it("should install source-map-support if env var is 'true'", () => { + process.env.TRIGGER_SOURCE_MAPS = "true"; + installSourceMapSupport(); + expect(sourceMapSupport.install).toHaveBeenCalled(); + }); + + it("should NOT install source-map-support if env var is 'false'", () => { + process.env.TRIGGER_SOURCE_MAPS = "false"; + installSourceMapSupport(); + expect(sourceMapSupport.install).not.toHaveBeenCalled(); + }); + + it("should NOT install source-map-support if env var is '0'", () => { + process.env.TRIGGER_SOURCE_MAPS = "0"; + installSourceMapSupport(); + expect(sourceMapSupport.install).not.toHaveBeenCalled(); + }); + + it("should enable native node source maps if env var is 'node'", () => { + process.env.TRIGGER_SOURCE_MAPS = "node"; + installSourceMapSupport(); + expect(sourceMapSupport.install).not.toHaveBeenCalled(); + expect(process.setSourceMapsEnabled).toHaveBeenCalledWith(true); + }); +}); diff --git a/packages/cli-v3/src/utilities/sourceMaps.ts b/packages/cli-v3/src/utilities/sourceMaps.ts new file mode 100644 index 00000000000..746caab94a1 --- /dev/null +++ b/packages/cli-v3/src/utilities/sourceMaps.ts @@ -0,0 +1,22 @@ +import sourceMapSupport from "source-map-support"; + +export function installSourceMapSupport() { + const sourceMaps = process.env.TRIGGER_SOURCE_MAPS; + + if (sourceMaps === "false" || sourceMaps === "0") { + return; + } + + if (sourceMaps === "node") { + if (process.setSourceMapsEnabled) { + process.setSourceMapsEnabled(true); + } + return; + } + + sourceMapSupport.install({ + handleUncaughtExceptions: false, + environment: "node", + hookRequire: false, + }); +} diff --git a/packages/core/src/v3/consoleInterceptor.ts b/packages/core/src/v3/consoleInterceptor.ts index c24b827e205..3adfb4aeeef 100644 --- a/packages/core/src/v3/consoleInterceptor.ts +++ b/packages/core/src/v3/consoleInterceptor.ts @@ -13,7 +13,17 @@ export class ConsoleInterceptor { private readonly sendToStdIO: boolean, private readonly interceptingDisabled: boolean, private readonly maxAttributeCount?: number - ) {} + ) { } + + private originalConsole: + | { + log: Console["log"]; + info: Console["info"]; + warn: Console["warn"]; + error: Console["error"]; + debug: Console["debug"]; + } + | undefined; // Intercept the console and send logs to the OpenTelemetry logger // during the execution of the callback @@ -23,7 +33,7 @@ export class ConsoleInterceptor { } // Save the original console methods - const originalConsole = { + this.originalConsole = { log: console.log, info: console.info, warn: console.warn, @@ -42,11 +52,15 @@ export class ConsoleInterceptor { return await callback(); } finally { // Restore the original console methods - console.log = originalConsole.log; - console.info = originalConsole.info; - console.warn = originalConsole.warn; - console.error = originalConsole.error; - console.debug = originalConsole.debug; + if (this.originalConsole) { + console.log = this.originalConsole.log; + console.info = this.originalConsole.info; + console.warn = this.originalConsole.warn; + console.error = this.originalConsole.error; + console.debug = this.originalConsole.debug; + + this.originalConsole = undefined; + } } } @@ -79,10 +93,30 @@ export class ConsoleInterceptor { const body = util.format(...args); if (this.sendToStdIO) { - if (severityNumber === SeverityNumber.ERROR) { - process.stderr.write(body); + if (this.originalConsole) { + switch (severityNumber) { + case SeverityNumber.INFO: + this.originalConsole.log(...args); + break; + case SeverityNumber.WARN: + this.originalConsole.warn(...args); + break; + case SeverityNumber.ERROR: + this.originalConsole.error(...args); + break; + case SeverityNumber.DEBUG: + this.originalConsole.debug(...args); + break; + default: + this.originalConsole.log(...args); + break; + } } else { - process.stdout.write(body); + if (severityNumber === SeverityNumber.ERROR) { + process.stderr.write(body + "\n"); + } else { + process.stdout.write(body + "\n"); + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17f73d9a252..4fac4b6fbf5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -429,8 +429,8 @@ importers: specifier: ^2.11.8 version: 2.11.8 '@prisma/instrumentation': - specifier: ^6.14.0 - version: 6.14.0(@opentelemetry/api@1.9.0) + specifier: ^7.7.0 + version: 7.7.0(@opentelemetry/api@1.9.0) '@radix-ui/react-accordion': specifier: ^1.2.11 version: 1.2.11(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -1123,9 +1123,12 @@ importers: internal-packages/database: dependencies: + '@prisma/adapter-pg': + specifier: 7.7.0 + version: 7.7.0 '@prisma/client': - specifier: 6.14.0 - version: 6.14.0(prisma@6.14.0(magicast@0.3.5)(typescript@5.5.4))(typescript@5.5.4) + specifier: 7.7.0 + version: 7.7.0(prisma@7.7.0(@types/react-dom@19.0.4(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@11.10.0)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.5.4))(typescript@5.5.4) decimal.js: specifier: ^10.6.0 version: 10.6.0 @@ -1134,8 +1137,8 @@ importers: specifier: ^7.4.3 version: 7.4.3 prisma: - specifier: 6.14.0 - version: 6.14.0(magicast@0.3.5)(typescript@5.5.4) + specifier: 7.7.0 + version: 7.7.0(@types/react-dom@19.0.4(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@11.10.0)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.5.4) rimraf: specifier: 6.0.1 version: 6.0.1 @@ -1159,7 +1162,7 @@ importers: version: 18.3.1 react-email: specifier: ^2.1.1 - version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0) + version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0) resend: specifier: ^3.2.0 version: 3.2.0 @@ -1381,6 +1384,9 @@ importers: '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.0 + '@prisma/adapter-pg': + specifier: 7.7.0 + version: 7.7.0 '@trigger.dev/database': specifier: workspace:* version: link:../database @@ -2833,11 +2839,11 @@ importers: references/prisma-7: dependencies: '@prisma/adapter-pg': - specifier: 6.20.0-integration-next.8 - version: 6.20.0-integration-next.8 + specifier: 7.7.0 + version: 7.7.0 '@prisma/client': - specifier: 6.20.0-integration-next.8 - version: 6.20.0-integration-next.8(prisma@6.20.0-integration-next.8(@types/react@19.2.14)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.5.4))(typescript@5.5.4) + specifier: 7.7.0 + version: 7.7.0(prisma@7.7.0(@types/react-dom@19.0.4(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@11.10.0)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.5.4))(typescript@5.5.4) '@trigger.dev/build': specifier: workspace:* version: link:../../packages/build @@ -2849,8 +2855,8 @@ importers: version: 17.2.3 devDependencies: prisma: - specifier: 6.20.0-integration-next.8 - version: 6.20.0-integration-next.8(@types/react@19.2.14)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.5.4) + specifier: 7.7.0 + version: 7.7.0(@types/react-dom@19.0.4(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@11.10.0)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.5.4) trigger.dev: specifier: workspace:* version: link:../../packages/cli-v3 @@ -5547,6 +5553,12 @@ packages: peerDependencies: hono: ^4 + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@hono/node-server@1.19.9': resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} engines: {node: '>=18.14.1'} @@ -6548,6 +6560,10 @@ packages: resolution: {integrity: sha512-9B9RU0H7Ya1Dx/Rkyc4stuBZSGVQF27WigitInx2QQoj6KUpEFYPKoWjdFTunJYxmXmh17HeBvbMa1EhGyPmqQ==} engines: {node: '>=8.0.0'} + '@opentelemetry/api-logs@0.207.0': + resolution: {integrity: sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api-logs@0.52.1': resolution: {integrity: sha512-qnSqB2DQ9TPP96dl8cDubDvrUyWc0/sK81xHTK8eSUspzDM3bsewX903qclQFvVhgStjRWdC5bLb3kQqMkfV5A==} engines: {node: '>=14'} @@ -6850,9 +6866,9 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation@0.52.1': - resolution: {integrity: sha512-uXJbYU/5/MBHjMp1FqrILLRuiJCs3Ofk0MeRDk8g1S1gD47U8X3JnSwcMO1rtRo1x1a7zKaQHaoYu49p/4eSKw==} - engines: {node: '>=14'} + '@opentelemetry/instrumentation@0.207.0': + resolution: {integrity: sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 @@ -7101,9 +7117,9 @@ packages: typescript: optional: true - '@prisma/client@6.20.0-integration-next.8': - resolution: {integrity: sha512-cSxdnyO3nBr+JQFsW8j4C3JvMWiknSoZktmMNRNtXQ7bmUeG4IyQks97bjzeAUP8feJechk5casCIq3p26GDvA==} - engines: {node: ^20.19 || ^22.12 || ^24.0} + '@prisma/client@7.7.0': + resolution: {integrity: sha512-5Ar4OsZpJ54s21sy5oDNNW9gQtd4NuxCaiM7+JDTOU07D6VvlpLjYzAVCMB1+JzokN+08dAVomlx+b7bhJd3ww==} + engines: {node: ^20.19 || ^22.12 || >=24.0} peerDependencies: prisma: '*' typescript: 5.5.4 @@ -8088,6 +8104,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-tooltip@1.0.5': resolution: {integrity: sha512-cDKVcfzyO6PpckZekODJZDe5ZxZ2fCZlzKzTmPhe4mX9qTHRfLcKgqb0OKf22xLwDequ2tVleim+ZYx3rabD5w==} peerDependencies: @@ -12099,6 +12128,9 @@ packages: cjs-module-lexer@1.2.3: resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==} + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + class-variance-authority@0.5.2: resolution: {integrity: sha512-j7Qqw3NPbs4IpO80gvdACWmVvHiLLo5MECacUBLnJG17CrLpWaQ7/4OaWX6P0IO1j2nvZ7AuSfBS/ImtEUZJGA==} peerDependencies: @@ -14554,6 +14586,9 @@ packages: import-in-the-middle@1.14.2: resolution: {integrity: sha512-5tCuY9BV8ujfOpwtAGgsTx9CGUapcFMEEyByLv1B+v2+6DhAcw+Zr0nhQT7uwaZ7DiourxFEscghOR8e1aPLQw==} + import-in-the-middle@2.0.6: + resolution: {integrity: sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==} + import-meta-resolve@4.1.0: resolution: {integrity: sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==} @@ -16114,6 +16149,9 @@ packages: module-details-from-path@1.0.3: resolution: {integrity: sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==} + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + moo@0.5.2: resolution: {integrity: sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==} @@ -17445,13 +17483,16 @@ packages: typescript: optional: true - prisma@6.20.0-integration-next.8: - resolution: {integrity: sha512-KUVwHRuyvl57CpEU6kZc5eMdbhUogmneo2a7jF1GKEZwPZscAU+FXIDsgCH+U4BCpKlm0NVrRd0YKz9+7zBWFQ==} - engines: {node: ^20.19 || ^22.12 || ^24.0} + prisma@7.7.0: + resolution: {integrity: sha512-HlgwRBt1uEFB9LStHL4HLYDvoi4BNu1rYA0hPG0zCAEyK9SaZBqp7E5Rjpc3Qh8Lex/ye/svoHZ0OWoFNhWxuQ==} + engines: {node: ^20.19 || ^22.12 || >=24.0} hasBin: true peerDependencies: + better-sqlite3: '>=9.0.0' typescript: 5.5.4 peerDependenciesMeta: + better-sqlite3: + optional: true typescript: optional: true @@ -18128,6 +18169,10 @@ packages: resolution: {integrity: sha512-OScOjQjrrjhAdFpQmnkE/qbIBGCRFhQB/YaJhcC3CPOlmhe7llnW46Ac1J5+EjcNXOTnDdpF96Erw/yedsGksQ==} engines: {node: '>=8.6.0'} + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + require-like@0.1.2: resolution: {integrity: sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==} @@ -19825,6 +19870,14 @@ packages: typescript: optional: true + valibot@1.2.0: + resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} + peerDependencies: + typescript: 5.5.4 + peerDependenciesMeta: + typescript: + optional: true + valibot@1.3.1: resolution: {integrity: sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==} peerDependencies: @@ -24908,6 +24961,10 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs@0.207.0': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs@0.52.1': dependencies: '@opentelemetry/api': 1.9.0 @@ -25318,15 +25375,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation@0.207.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.52.1 - '@types/shimmer': 1.2.0 - import-in-the-middle: 1.11.0 - require-in-the-middle: 7.1.1(supports-color@10.0.0) - semver: 7.7.3 - shimmer: 1.2.1 + '@opentelemetry/api-logs': 0.207.0 + import-in-the-middle: 2.0.6 + require-in-the-middle: 8.0.1 transitivePeerDependencies: - supports-color @@ -25644,11 +25698,11 @@ snapshots: transitivePeerDependencies: - magicast - '@prisma/config@6.20.0-integration-next.8(magicast@0.3.5)': + '@prisma/config@7.7.0(magicast@0.3.5)': dependencies: c12: 3.1.0(magicast@0.3.5) deepmerge-ts: 7.1.5 - effect: 3.18.4 + effect: 3.20.0 empathic: 2.0.0 transitivePeerDependencies: - magicast @@ -32395,6 +32449,8 @@ snapshots: cjs-module-lexer@1.2.3: {} + cjs-module-lexer@2.2.0: {} + class-variance-authority@0.5.2(typescript@5.5.4): optionalDependencies: typescript: 5.5.4 @@ -33417,6 +33473,11 @@ snapshots: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 + effect@3.20.0: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + effect@3.7.2: {} electron-to-chromium@1.4.433: {} @@ -34678,6 +34739,11 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + forever-agent@0.6.1: {} form-data-encoder@1.7.2: {} @@ -35421,6 +35487,13 @@ snapshots: cjs-module-lexer: 1.2.3 module-details-from-path: 1.0.3 + import-in-the-middle@2.0.6: + dependencies: + acorn: 8.15.0 + acorn-import-attributes: 1.9.5(acorn@8.15.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + import-meta-resolve@4.1.0: {} imurmurhash@0.1.4: {} @@ -37274,6 +37347,8 @@ snapshots: module-details-from-path@1.0.3: {} + module-details-from-path@1.0.4: {} + moo@0.5.2: {} morgan@1.10.0: @@ -38631,16 +38706,20 @@ snapshots: transitivePeerDependencies: - magicast - prisma@6.20.0-integration-next.8(@types/react@19.2.14)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.5.4): + prisma@7.7.0(@types/react-dom@19.0.4(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@11.10.0)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.5.4): dependencies: - '@prisma/config': 6.20.0-integration-next.8(magicast@0.3.5) - '@prisma/engines': 6.20.0-integration-next.8 - '@prisma/studio-core-licensed': 0.6.0(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@prisma/config': 7.7.0(magicast@0.3.5) + '@prisma/dev': 0.24.3(typescript@5.5.4) + '@prisma/engines': 7.7.0 + '@prisma/studio-core': 0.27.3(@types/react-dom@19.0.4(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + mysql2: 3.15.3 postgres: 3.4.7 optionalDependencies: + better-sqlite3: 11.10.0 typescript: 5.5.4 transitivePeerDependencies: - '@types/react' + - '@types/react-dom' - magicast - react - react-dom @@ -38975,7 +39054,7 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0): + react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0): dependencies: '@babel/parser': 7.24.1 '@radix-ui/colors': 1.0.1 @@ -39012,8 +39091,8 @@ snapshots: react: 18.3.1 react-dom: 18.2.0(react@18.3.1) shelljs: 0.8.5 - socket.io: 4.7.3(bufferutil@4.0.9) - socket.io-client: 4.7.3(bufferutil@4.0.9) + socket.io: 4.7.3 + socket.io-client: 4.7.3 sonner: 1.3.1(react-dom@18.2.0(react@18.3.1))(react@18.3.1) source-map-js: 1.0.2 stacktrace-parser: 0.1.10 @@ -39690,6 +39769,13 @@ snapshots: transitivePeerDependencies: - supports-color + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3(supports-color@10.0.0) + module-details-from-path: 1.0.3 + transitivePeerDependencies: + - supports-color + require-like@0.1.2: {} require-main-filename@2.0.0: {} @@ -40269,7 +40355,7 @@ snapshots: - supports-color - utf-8-validate - socket.io-client@4.7.3(bufferutil@4.0.9): + socket.io-client@4.7.3: dependencies: '@socket.io/component-emitter': 3.1.0 debug: 4.3.7(supports-color@10.0.0) @@ -40298,7 +40384,7 @@ snapshots: transitivePeerDependencies: - supports-color - socket.io@4.7.3(bufferutil@4.0.9): + socket.io@4.7.3: dependencies: accepts: 1.3.8 base64id: 2.0.0 @@ -41839,6 +41925,10 @@ snapshots: optionalDependencies: typescript: 5.5.4 + valibot@1.2.0(typescript@5.5.4): + optionalDependencies: + typescript: 5.5.4 + valibot@1.3.1(typescript@5.5.4): optionalDependencies: typescript: 5.5.4 diff --git a/references/prisma-7/package.json b/references/prisma-7/package.json index 2fff27d9979..e040d3e2a65 100644 --- a/references/prisma-7/package.json +++ b/references/prisma-7/package.json @@ -3,12 +3,12 @@ "private": true, "type": "module", "devDependencies": { - "prisma": "6.20.0-integration-next.8", + "prisma": "7.7.0", "trigger.dev": "workspace:*" }, "dependencies": { - "@prisma/client": "6.20.0-integration-next.8", - "@prisma/adapter-pg": "6.20.0-integration-next.8", + "@prisma/client": "7.7.0", + "@prisma/adapter-pg": "7.7.0", "@trigger.dev/build": "workspace:*", "@trigger.dev/sdk": "workspace:*", "dotenv": "^17.2.3" @@ -18,4 +18,4 @@ "deploy": "trigger deploy", "prisma:generate": "prisma generate" } -} \ No newline at end of file +} diff --git a/scripts/recover-stuck-runs.ts b/scripts/recover-stuck-runs.ts index 15deeb899c9..7424f1f16da 100755 --- a/scripts/recover-stuck-runs.ts +++ b/scripts/recover-stuck-runs.ts @@ -46,6 +46,7 @@ */ import { PrismaClient, TaskRunExecutionStatus } from "@trigger.dev/database"; +import { PrismaPg } from "@prisma/adapter-pg"; import { createRedisClient } from "@internal/redis"; interface StuckRun { @@ -71,18 +72,20 @@ async function main() { const [environmentId, postgresUrl, redisReadUrl, redisWriteUrl] = process.argv.slice(2); if (!environmentId || !postgresUrl || !redisReadUrl) { - console.error("Usage: tsx scripts/recover-stuck-runs.ts [redisWriteUrl]"); + console.error( + "Usage: tsx scripts/recover-stuck-runs.ts [redisWriteUrl]" + ); console.error(""); console.error("Dry-run mode when no redisWriteUrl is provided (read-only)."); console.error("Execute mode when redisWriteUrl is provided (makes actual changes)."); console.error(""); console.error("Example (dry-run):"); - console.error(' tsx scripts/recover-stuck-runs.ts env_1234567890 \\'); + console.error(" tsx scripts/recover-stuck-runs.ts env_1234567890 \\"); console.error(' "postgresql://user:pass@localhost:5432/triggerdev" \\'); console.error(' "redis://readonly.example.com:6379"'); console.error(""); console.error("Example (execute):"); - console.error(' tsx scripts/recover-stuck-runs.ts env_1234567890 \\'); + console.error(" tsx scripts/recover-stuck-runs.ts env_1234567890 \\"); console.error(' "postgresql://user:pass@localhost:5432/triggerdev" \\'); console.error(' "redis://readonly.example.com:6379" \\'); console.error(' "redis://writeonly.example.com:6379"'); @@ -100,13 +103,8 @@ async function main() { console.log(`šŸ” Scanning for stuck runs in environment: ${environmentId}`); // Create Prisma client with the provided connection URL - const prisma = new PrismaClient({ - datasources: { - db: { - url: postgresUrl, - }, - }, - }); + const adapter = new PrismaPg(postgresUrl); + const prisma = new PrismaClient({ adapter }); try { // Get environment details @@ -259,7 +257,9 @@ async function main() { } // Prepare recovery operations - console.log(`\n⚔ ${executeMode ? "Executing" : "Planning"} recovery for ${stuckRuns.length} stuck runs`); + console.log( + `\n⚔ ${executeMode ? "Executing" : "Planning"} recovery for ${stuckRuns.length} stuck runs` + ); console.log(`This will:`); console.log(` 1. Add each run back to its specific queue sorted set`); console.log(` 2. Remove each run from the queue-specific currentConcurrency set`); diff --git a/tests/utils.ts b/tests/utils.ts index d23f4009d25..fd4b763a84c 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,18 +1,13 @@ import { PrismaClient } from "@trigger.dev/database"; +import { PrismaPg } from "@prisma/adapter-pg"; type SetDBCallback = (prisma: PrismaClient) => Promise; export const setDB = async (cb: SetDBCallback) => { const { DATABASE_URL } = process.env; - const prisma = new PrismaClient({ - datasources: { - db: { - url: DATABASE_URL, - // We can't set directUrl here, and we don't have to - }, - }, - }); + const adapter = new PrismaPg(DATABASE_URL ?? "postgresql://localhost:5432/trigger"); + const prisma = new PrismaClient({ adapter }); await prisma.$connect(); await cb(prisma);