From 1715c79c9c887ee6e5517fd48a1d73503416478f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 6 Aug 2026 03:57:02 -0700 Subject: [PATCH] fix(cron): bind stale async-job cutoffs through the column encoder The stale-processing predicate interpolated `Date` values straight into a raw `sql` template. A raw template carries no column context, so drizzle skips `PgTimestamp.mapToDriverValue` (which stringifies via `toISOString`) and postgres-js receives a `Date` it cannot serialize under the pools' `prepare: false` / `fetch_types: false` options. Every run of the job has failed its async-job sweep since the change shipped, leaving stuck jobs unreaped while the surrounding typed `lt(column, date)` sweeps succeeded. Bind both cutoffs with `sql.param(date, column)`, matching the workflow sweep in the same handler. The testing `sql` mock already rejected `sql.param(date)` for this reason but not the interpolated form that shipped, so extend it to cover both. That guard alone fails three existing tests when the fix is reverted, and the full suite shows no other route binding a bare Date this way. --- .../sim/app/api/cron/cleanup-stale-executions/route.ts | 8 ++++++-- packages/testing/src/mocks/database.mock.ts | 10 ++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts index c8b74944dbe..0f5f62074d4 100644 --- a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts +++ b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts @@ -245,10 +245,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { = (${asyncJobs.metadata}->>'maxDurationSeconds')::numeric ELSE FALSE END` + // A bare `Date` in a raw template reaches the driver unserialized; `sql.param` + // binds it through the column encoder, as `lt(column, date)` does elsewhere here. + const staleProcessingCutoff = sql.param(now, asyncJobs.startedAt) + const staleProcessingFallbackCutoff = sql.param(staleThreshold, asyncJobs.startedAt) const staleProcessingDurationPredicate = sql`CASE WHEN ${hasPositiveMaxDuration} - THEN ${asyncJobs.startedAt} + ((${asyncJobs.metadata}->>'maxDurationSeconds')::double precision * interval '1 second') < ${now} - ELSE ${asyncJobs.startedAt} < ${staleThreshold} + THEN ${asyncJobs.startedAt} + ((${asyncJobs.metadata}->>'maxDurationSeconds')::double precision * interval '1 second') < ${staleProcessingCutoff} + ELSE ${asyncJobs.startedAt} < ${staleProcessingFallbackCutoff} END` const staleProcessingPredicate = and( eq(asyncJobs.status, JOB_STATUS.PROCESSING), diff --git a/packages/testing/src/mocks/database.mock.ts b/packages/testing/src/mocks/database.mock.ts index 455c90f66c8..d246ba9d603 100644 --- a/packages/testing/src/mocks/database.mock.ts +++ b/packages/testing/src/mocks/database.mock.ts @@ -6,6 +6,16 @@ import { vi } from 'vitest' */ export function createMockSql() { const sqlFn = (strings: TemplateStringsArray, ...values: any[]) => { + // Same hazard as `sql.param(date)` below, and the form that actually shipped: + // an interpolated `Date` carries no column context, so drizzle skips + // `PgTimestamp.mapToDriverValue` and postgres-js receives a Date it cannot serialize. + if (values.some((value) => value instanceof Date)) { + throw new Error( + 'sql`…${date}` interpolates a Date without an encoder, which reaches ' + + 'postgres-js as a Date object its unsafe path cannot serialize. Bind ' + + 'through the matching column: sql.param(date, table.timestampColumn).' + ) + } const fragment = { strings, values,