fix(cron): bind stale async-job cutoffs through the column encoder (#6327)

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.
This commit is contained in:
Waleed
2026-08-06 04:06:17 -07:00
committed by GitHub
parent 71d7d8da56
commit 82e654d73c
2 changed files with 16 additions and 2 deletions
@@ -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<boolean>`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),
@@ -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,