@@ -22,6 +22,7 @@ import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
2222import { runDetached } from '@/lib/core/utils/background'
2323import { generateRequestId } from '@/lib/core/utils/request'
2424import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
25+ import { notifyScheduleAutoDisabled } from '@/lib/workflows/schedules/disable-notifications'
2526import {
2627 SCHEDULE_EXECUTION_CONCURRENCY_LIMIT ,
2728 SCHEDULE_EXECUTION_QUEUE_NAME ,
@@ -31,7 +32,7 @@ import {
3132} from '@/lib/workflows/schedules/execution-limits'
3233import { calculateScheduleInfraRetryDelayMs } from '@/lib/workflows/schedules/retry'
3334import {
34- buildScheduleFailureUpdate ,
35+ applyScheduleFailureUpdate ,
3536 executeJobInline ,
3637 executeScheduleJob ,
3738 releaseScheduleLock ,
@@ -43,6 +44,12 @@ export const maxDuration = 3600
4344
4445const logger = createLogger ( 'ScheduledExecuteAPI' )
4546const WORKFLOW_CHUNK_SIZE = 100
47+ /**
48+ * Recovery sweeps a batch of up to `STALE_SCHEDULE_RECOVERY_BATCH_SIZE` schedules,
49+ * each fanning out to every workspace admin. Cap the mail so one tick can't turn
50+ * into hundreds of inline sends; the remainder is logged.
51+ */
52+ const STALE_SCHEDULE_RECOVERY_NOTIFY_LIMIT = 25
4653const JOB_CHUNK_SIZE = 100
4754const MAX_TICK_DURATION_MS = 3 * 60 * 1000
4855const STALE_SCHEDULE_CLAIM_MS = getMaxExecutionTimeout ( )
@@ -394,20 +401,22 @@ async function markClaimedScheduleFailed(
394401 context : string
395402) : Promise < void > {
396403 const now = new Date ( )
397- await db
398- . update ( workflowSchedule )
399- . set ( buildScheduleFailureUpdate ( now , getScheduleNextRunAt ( schedule , now ) ) )
400- . where (
401- and (
402- eq ( workflowSchedule . id , schedule . id ) ,
403- isNull ( workflowSchedule . archivedAt ) ,
404- eq ( workflowSchedule . lastQueuedAt , expectedLastQueuedAt )
405- )
406- )
407- . catch ( ( error ) => {
408- logger . error ( `[${ requestId } ] ${ context } ` , error )
409- throw error
404+ const { disabled } = await applyScheduleFailureUpdate ( {
405+ scheduleId : schedule . id ,
406+ now,
407+ nextRunAt : getScheduleNextRunAt ( schedule , now ) ,
408+ expectedLastQueuedAt,
409+ requestId,
410+ context,
411+ } )
412+
413+ if ( disabled ) {
414+ await notifyScheduleAutoDisabled ( {
415+ scheduleId : schedule . id ,
416+ reason : 'consecutive_failures' ,
417+ requestId,
410418 } )
419+ }
411420}
412421
413422async function deferClaimedScheduleAfterQueueFailure (
@@ -491,6 +500,13 @@ async function handleClaimedScheduleSetupFailure(
491500async function recoverStaleDatabaseScheduleJobs ( now : Date ) : Promise < void > {
492501 const staleStartedBefore = getStaleScheduleExecutionCutoff ( now )
493502
503+ /**
504+ * Collected inside the transaction, flushed after it commits. Emailing inside
505+ * would both notify about writes a rollback discards and issue pooled-client
506+ * reads while the transaction still holds row locks under the advisory lock.
507+ */
508+ const disabledScheduleIds : string [ ] = [ ]
509+
494510 await db . transaction ( async ( tx ) => {
495511 const [ lock ] = await tx . execute < { acquired : boolean } > (
496512 sql `SELECT pg_try_advisory_xact_lock(hashtextextended(${ SCHEDULE_EXECUTION_QUEUE_NAME } , 0)) AS acquired`
@@ -539,16 +555,17 @@ async function recoverStaleDatabaseScheduleJobs(now: Date): Promise<void> {
539555 const claimedAt = getSchedulePayloadClaimedAt ( payload )
540556 if ( ! payload || ! claimedAt ) continue
541557
542- await tx
543- . update ( workflowSchedule )
544- . set ( buildScheduleFailureUpdate ( now , getScheduleNextRunAt ( payload , now ) ) )
545- . where (
546- and (
547- eq ( workflowSchedule . id , payload . scheduleId ) ,
548- isNull ( workflowSchedule . archivedAt ) ,
549- eq ( workflowSchedule . lastQueuedAt , claimedAt )
550- )
551- )
558+ const { disabled } = await applyScheduleFailureUpdate ( {
559+ scheduleId : payload . scheduleId ,
560+ now,
561+ nextRunAt : getScheduleNextRunAt ( payload , now ) ,
562+ expectedLastQueuedAt : claimedAt ,
563+ requestId : 'stale-schedule-recovery' ,
564+ context : `Error updating schedule ${ payload . scheduleId } after stale lease recovery` ,
565+ executor : tx ,
566+ } )
567+
568+ if ( disabled ) disabledScheduleIds . push ( payload . scheduleId )
552569 }
553570
554571 if ( retryableRows . length > 0 ) {
@@ -568,6 +585,22 @@ async function recoverStaleDatabaseScheduleJobs(now: Date): Promise<void> {
568585 )
569586 }
570587 } )
588+
589+ const notifiable = disabledScheduleIds . slice ( 0 , STALE_SCHEDULE_RECOVERY_NOTIFY_LIMIT )
590+ if ( disabledScheduleIds . length > notifiable . length ) {
591+ logger . warn ( 'Capped schedule auto-disable notifications for stale recovery batch' , {
592+ disabled : disabledScheduleIds . length ,
593+ notified : notifiable . length ,
594+ } )
595+ }
596+
597+ for ( const scheduleId of notifiable ) {
598+ await notifyScheduleAutoDisabled ( {
599+ scheduleId,
600+ reason : 'consecutive_failures' ,
601+ requestId : 'stale-schedule-recovery' ,
602+ } )
603+ }
571604}
572605
573606function isStaleDatabaseScheduleJob ( job : { status : string ; startedAt ?: Date } ) : boolean {
0 commit comments