Skip to content

Commit 099d7ba

Browse files
feat(notifications): email on schedule auto-disable and 100% usage limit
1 parent e5ae445 commit 099d7ba

18 files changed

Lines changed: 1328 additions & 134 deletions

File tree

apps/sim/app/api/emails/preview/route.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
renderPasswordResetEmail,
1212
renderPaymentFailedEmail,
1313
renderPlanWelcomeEmail,
14+
renderScheduleDisabledEmail,
15+
renderUsageLimitReachedEmail,
1416
renderUsageThresholdEmail,
1517
renderWelcomeEmail,
1618
renderWorkspaceInvitationEmail,
@@ -93,6 +95,43 @@ const emailTemplates = {
9395
billingPortalUrl: 'https://sim.ai/settings/billing',
9496
failureReason: 'Card declined',
9597
}),
98+
'usage-limit-reached': () =>
99+
renderUsageLimitReachedEmail({
100+
userName: 'John',
101+
planName: 'Pro',
102+
scope: 'user',
103+
currentUsage: 20,
104+
limit: 20,
105+
ctaLink: 'https://sim.ai/settings/billing',
106+
}),
107+
'usage-limit-reached-org': () =>
108+
renderUsageLimitReachedEmail({
109+
userName: 'John',
110+
planName: 'Team',
111+
scope: 'organization',
112+
currentUsage: 500,
113+
limit: 500,
114+
ctaLink: 'https://sim.ai/organization/org_123/settings/billing',
115+
}),
116+
117+
// Operational notification emails
118+
'schedule-disabled': () =>
119+
renderScheduleDisabledEmail({
120+
recipientName: 'John',
121+
kind: 'workflow',
122+
resourceName: 'Daily digest',
123+
reason: 'consecutive_failures',
124+
failedCount: 100,
125+
manageLink: 'https://sim.ai/workspace/ws_123/w/wf_456',
126+
}),
127+
'schedule-disabled-auth': () =>
128+
renderScheduleDisabledEmail({
129+
recipientName: 'John',
130+
kind: 'job',
131+
resourceName: 'Weekly report',
132+
reason: 'authentication_error',
133+
manageLink: 'https://sim.ai/workspace/ws_123/scheduled-tasks',
134+
}),
96135
} as const
97136

98137
type EmailTemplate = keyof typeof emailTemplates
@@ -122,7 +161,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
122161
'plan-welcome-team',
123162
'credit-purchase',
124163
'payment-failed',
164+
'usage-limit-reached',
165+
'usage-limit-reached-org',
125166
],
167+
Notifications: ['schedule-disabled', 'schedule-disabled-auth'],
126168
}
127169

128170
const categoryHtml = Object.entries(categories)

apps/sim/app/api/schedules/execute/route.test.ts

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ const {
3030
mockShouldExecuteInline,
3131
mockResolveSystemBillingAttribution,
3232
mockAssertBillingAttributionSnapshot,
33+
mockApplyScheduleFailureUpdate,
34+
mockNotifyScheduleAutoDisabled,
3335
} = vi.hoisted(() => ({
3436
mockVerifyCronAuth: vi.fn().mockReturnValue(null),
3537
mockExecuteScheduleJob: vi.fn().mockResolvedValue(undefined),
@@ -44,6 +46,8 @@ const {
4446
mockShouldExecuteInline: vi.fn().mockReturnValue(false),
4547
mockResolveSystemBillingAttribution: vi.fn(),
4648
mockAssertBillingAttributionSnapshot: vi.fn(),
49+
mockApplyScheduleFailureUpdate: vi.fn().mockResolvedValue({ updated: true, disabled: false }),
50+
mockNotifyScheduleAutoDisabled: vi.fn().mockResolvedValue(undefined),
4751
}))
4852

4953
vi.mock('@/lib/auth/internal', () => ({
@@ -59,15 +63,11 @@ vi.mock('@/background/schedule-execution', () => ({
5963
executeScheduleJob: mockExecuteScheduleJob,
6064
executeJobInline: mockExecuteJobInline,
6165
releaseScheduleLock: mockReleaseScheduleLock,
62-
buildScheduleFailureUpdate: (now: Date, nextRunAt: Date | null) => ({
63-
updatedAt: now,
64-
lastQueuedAt: null,
65-
nextRunAt,
66-
failedCount: { type: 'sql' },
67-
lastFailedAt: now,
68-
status: { type: 'sql' },
69-
infraRetryCount: 0,
70-
}),
66+
applyScheduleFailureUpdate: mockApplyScheduleFailureUpdate,
67+
}))
68+
69+
vi.mock('@/lib/workflows/schedules/disable-notifications', () => ({
70+
notifyScheduleAutoDisabled: mockNotifyScheduleAutoDisabled,
7171
}))
7272

7373
vi.mock('@/lib/core/async-jobs', () => ({
@@ -730,11 +730,11 @@ describe('Scheduled Workflow Execution API Route', () => {
730730
error: expect.stringContaining('exhausted retry attempts'),
731731
})
732732
)
733-
expect(dbChainMockFns.set).toHaveBeenCalledWith(
733+
expect(mockApplyScheduleFailureUpdate).toHaveBeenCalledWith(
734734
expect.objectContaining({
735-
lastQueuedAt: null,
736-
lastFailedAt: expect.any(Date),
737-
nextRunAt: expect.any(Date),
735+
scheduleId: 'schedule-1',
736+
expectedLastQueuedAt: claimedAt,
737+
executor: expect.anything(),
738738
})
739739
)
740740
})
@@ -779,12 +779,11 @@ describe('Scheduled Workflow Execution API Route', () => {
779779

780780
await runScheduleTick('test-request-id')
781781
expect(mockEnqueue).not.toHaveBeenCalled()
782-
expect(dbChainMockFns.set).toHaveBeenCalledWith(
782+
expect(mockApplyScheduleFailureUpdate).toHaveBeenCalledWith(
783783
expect.objectContaining({
784-
lastQueuedAt: null,
785-
lastFailedAt: expect.any(Date),
784+
scheduleId: schedule.id,
785+
expectedLastQueuedAt: claimedAt,
786786
nextRunAt: expect.any(Date),
787-
infraRetryCount: 0,
788787
})
789788
)
790789
expect(dbChainMockFns.set).not.toHaveBeenCalledWith(
@@ -794,6 +793,44 @@ describe('Scheduled Workflow Execution API Route', () => {
794793
)
795794
})
796795

796+
it('emails the schedule owners when a non-retryable setup failure disables the schedule', async () => {
797+
const claimedAt = new Date('2025-01-01T00:00:00.000Z')
798+
const schedule = {
799+
...SINGLE_SCHEDULE[0],
800+
lastQueuedAt: claimedAt,
801+
}
802+
mockGetJob.mockRejectedValueOnce(new Error('bad setup invariant'))
803+
mockApplyScheduleFailureUpdate.mockResolvedValueOnce({ updated: true, disabled: true })
804+
dbChainMockFns.limit
805+
.mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS)
806+
.mockResolvedValueOnce([])
807+
dbChainMockFns.returning.mockReturnValueOnce([schedule]).mockReturnValueOnce([])
808+
809+
await runScheduleTick('test-request-id')
810+
811+
expect(mockNotifyScheduleAutoDisabled).toHaveBeenCalledWith(
812+
expect.objectContaining({ scheduleId: schedule.id, reason: 'consecutive_failures' })
813+
)
814+
})
815+
816+
it('does not email when the failure update leaves the schedule active', async () => {
817+
const claimedAt = new Date('2025-01-01T00:00:00.000Z')
818+
const schedule = {
819+
...SINGLE_SCHEDULE[0],
820+
lastQueuedAt: claimedAt,
821+
}
822+
mockGetJob.mockRejectedValueOnce(new Error('bad setup invariant'))
823+
mockApplyScheduleFailureUpdate.mockResolvedValueOnce({ updated: true, disabled: false })
824+
dbChainMockFns.limit
825+
.mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS)
826+
.mockResolvedValueOnce([])
827+
dbChainMockFns.returning.mockReturnValueOnce([schedule]).mockReturnValueOnce([])
828+
829+
await runScheduleTick('test-request-id')
830+
831+
expect(mockNotifyScheduleAutoDisabled).not.toHaveBeenCalled()
832+
})
833+
797834
it('uses one backend mode decision for slot accounting and schedule processing', async () => {
798835
mockShouldExecuteInline.mockReturnValue(true)
799836
dbChainMockFns.limit

apps/sim/app/api/schedules/execute/route.ts

Lines changed: 57 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
2222
import { runDetached } from '@/lib/core/utils/background'
2323
import { generateRequestId } from '@/lib/core/utils/request'
2424
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
25+
import { notifyScheduleAutoDisabled } from '@/lib/workflows/schedules/disable-notifications'
2526
import {
2627
SCHEDULE_EXECUTION_CONCURRENCY_LIMIT,
2728
SCHEDULE_EXECUTION_QUEUE_NAME,
@@ -31,7 +32,7 @@ import {
3132
} from '@/lib/workflows/schedules/execution-limits'
3233
import { calculateScheduleInfraRetryDelayMs } from '@/lib/workflows/schedules/retry'
3334
import {
34-
buildScheduleFailureUpdate,
35+
applyScheduleFailureUpdate,
3536
executeJobInline,
3637
executeScheduleJob,
3738
releaseScheduleLock,
@@ -43,6 +44,12 @@ export const maxDuration = 3600
4344

4445
const logger = createLogger('ScheduledExecuteAPI')
4546
const 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
4653
const JOB_CHUNK_SIZE = 100
4754
const MAX_TICK_DURATION_MS = 3 * 60 * 1000
4855
const 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

413422
async function deferClaimedScheduleAfterQueueFailure(
@@ -491,6 +500,13 @@ async function handleClaimedScheduleSetupFailure(
491500
async 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

573606
function isStaleDatabaseScheduleJob(job: { status: string; startedAt?: Date }): boolean {

0 commit comments

Comments
 (0)