Skip to content

Commit 061c1df

Browse files
fix(schedules): offload next run calculation to croner (#1640)
* fix(schedules): offload next run calculation to croner * fix localstorage dependent tests * address greptile comment
1 parent 1a05ef9 commit 061c1df

10 files changed

Lines changed: 576 additions & 517 deletions

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,8 @@ export async function POST(req: NextRequest) {
309309

310310
// Additional validation for custom cron expressions
311311
if (defaultScheduleType === 'custom' && cronExpression) {
312-
const validation = validateCronExpression(cronExpression)
312+
// Validate with timezone for accurate validation
313+
const validation = validateCronExpression(cronExpression, timezone)
313314
if (!validation.isValid) {
314315
logger.error(`[${requestId}] Invalid cron expression: ${validation.error}`)
315316
return NextResponse.json(

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/schedule/schedule-config.tsx

Lines changed: 124 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from 'react'
1+
import { useCallback, useEffect, useState } from 'react'
22
import { Calendar, ExternalLink } from 'lucide-react'
33
import { useParams } from 'next/navigation'
44
import { Button } from '@/components/ui/button'
@@ -33,17 +33,23 @@ export function ScheduleConfig({
3333
disabled = false,
3434
}: ScheduleConfigProps) {
3535
const [error, setError] = useState<string | null>(null)
36-
const [scheduleId, setScheduleId] = useState<string | null>(null)
37-
const [nextRunAt, setNextRunAt] = useState<string | null>(null)
38-
const [lastRanAt, setLastRanAt] = useState<string | null>(null)
39-
const [cronExpression, setCronExpression] = useState<string | null>(null)
40-
const [timezone, setTimezone] = useState<string>('UTC')
36+
const [scheduleData, setScheduleData] = useState<{
37+
id: string | null
38+
nextRunAt: string | null
39+
lastRanAt: string | null
40+
cronExpression: string | null
41+
timezone: string
42+
}>({
43+
id: null,
44+
nextRunAt: null,
45+
lastRanAt: null,
46+
cronExpression: null,
47+
timezone: 'UTC',
48+
})
4149
const [isLoading, setIsLoading] = useState(false)
4250
const [isSaving, setIsSaving] = useState(false)
4351
const [isDeleting, setIsDeleting] = useState(false)
4452
const [isModalOpen, setIsModalOpen] = useState(false)
45-
// Track when we need to force a refresh of schedule data
46-
const [refreshCounter, setRefreshCounter] = useState(0)
4753

4854
const params = useParams()
4955
const workflowId = params.workflowId as string
@@ -61,79 +67,88 @@ export function ScheduleConfig({
6167
const blockWithValues = getBlockWithValues(blockId)
6268
const isScheduleTriggerBlock = blockWithValues?.type === 'schedule'
6369

64-
// Function to check if schedule exists in the database
65-
const checkSchedule = async () => {
70+
// Fetch schedule data from API
71+
const fetchSchedule = useCallback(async () => {
72+
if (!workflowId) return
73+
6674
setIsLoading(true)
6775
try {
68-
// Check if there's a schedule for this workflow, passing the mode parameter
69-
// For schedule trigger blocks, include blockId to get the specific schedule
70-
const url = new URL('/api/schedules', window.location.origin)
71-
url.searchParams.set('workflowId', workflowId)
72-
url.searchParams.set('mode', 'schedule')
73-
76+
const params = new URLSearchParams({
77+
workflowId,
78+
mode: 'schedule',
79+
})
7480
if (isScheduleTriggerBlock) {
75-
url.searchParams.set('blockId', blockId)
81+
params.set('blockId', blockId)
7682
}
7783

78-
const response = await fetch(url.toString(), {
79-
// Add cache: 'no-store' to prevent caching of this request
84+
const response = await fetch(`/api/schedules?${params}`, {
8085
cache: 'no-store',
81-
headers: {
82-
'Cache-Control': 'no-cache',
83-
},
86+
headers: { 'Cache-Control': 'no-cache' },
8487
})
8588

8689
if (response.ok) {
8790
const data = await response.json()
88-
logger.debug('Schedule check response:', data)
89-
9091
if (data.schedule) {
91-
setScheduleId(data.schedule.id)
92-
setNextRunAt(data.schedule.nextRunAt)
93-
setLastRanAt(data.schedule.lastRanAt)
94-
setCronExpression(data.schedule.cronExpression)
95-
setTimezone(data.schedule.timezone || 'UTC')
96-
97-
// Note: We no longer set global schedule status from individual components
98-
// The global schedule status should be managed by a higher-level component
92+
setScheduleData({
93+
id: data.schedule.id,
94+
nextRunAt: data.schedule.nextRunAt,
95+
lastRanAt: data.schedule.lastRanAt,
96+
cronExpression: data.schedule.cronExpression,
97+
timezone: data.schedule.timezone || 'UTC',
98+
})
9999
} else {
100-
setScheduleId(null)
101-
setNextRunAt(null)
102-
setLastRanAt(null)
103-
setCronExpression(null)
104-
105-
// Note: We no longer set global schedule status from individual components
100+
setScheduleData({
101+
id: null,
102+
nextRunAt: null,
103+
lastRanAt: null,
104+
cronExpression: null,
105+
timezone: 'UTC',
106+
})
106107
}
107108
}
108109
} catch (error) {
109-
logger.error('Error checking schedule:', { error })
110-
setError('Failed to check schedule status')
110+
logger.error('Error fetching schedule:', error)
111111
} finally {
112112
setIsLoading(false)
113113
}
114-
}
114+
}, [workflowId, blockId, isScheduleTriggerBlock])
115+
116+
// Fetch schedule data on mount and when dependencies change
117+
useEffect(() => {
118+
fetchSchedule()
119+
}, [fetchSchedule])
115120

116-
// Check for schedule on mount and when relevant dependencies change
121+
// Separate effect for event listener to avoid removing/re-adding on every dependency change
117122
useEffect(() => {
118-
// Check for schedules when workflowId changes, modal opens, or on initial mount
119-
if (workflowId) {
120-
checkSchedule()
123+
const handleScheduleUpdate = (event: CustomEvent) => {
124+
if (event.detail?.workflowId === workflowId && event.detail?.blockId === blockId) {
125+
logger.debug('Schedule update event received in schedule-config, refetching')
126+
fetchSchedule()
127+
}
121128
}
122129

123-
// Cleanup function to reset loading state
130+
window.addEventListener('schedule-updated', handleScheduleUpdate as EventListener)
131+
124132
return () => {
125-
setIsLoading(false)
133+
window.removeEventListener('schedule-updated', handleScheduleUpdate as EventListener)
126134
}
127-
}, [workflowId, isModalOpen, refreshCounter])
135+
}, [workflowId, blockId, fetchSchedule])
136+
137+
// Refetch when modal opens to get latest data
138+
useEffect(() => {
139+
if (isModalOpen) {
140+
fetchSchedule()
141+
}
142+
}, [isModalOpen, fetchSchedule])
128143

129144
// Format the schedule information for display
130145
const getScheduleInfo = () => {
131-
if (!scheduleId || !nextRunAt) return null
146+
if (!scheduleData.id || !scheduleData.nextRunAt) return null
132147

133148
let scheduleTiming = 'Unknown schedule'
134149

135-
if (cronExpression) {
136-
scheduleTiming = parseCronToHumanReadable(cronExpression)
150+
if (scheduleData.cronExpression) {
151+
scheduleTiming = parseCronToHumanReadable(scheduleData.cronExpression, scheduleData.timezone)
137152
} else if (scheduleType) {
138153
scheduleTiming = `${scheduleType.charAt(0).toUpperCase() + scheduleType.slice(1)}`
139154
}
@@ -142,8 +157,14 @@ export function ScheduleConfig({
142157
<>
143158
<div className='truncate font-normal text-sm'>{scheduleTiming}</div>
144159
<div className='text-muted-foreground text-xs'>
145-
<div>Next run: {formatDateTime(new Date(nextRunAt), timezone)}</div>
146-
{lastRanAt && <div>Last run: {formatDateTime(new Date(lastRanAt), timezone)}</div>}
160+
<div>
161+
Next run: {formatDateTime(new Date(scheduleData.nextRunAt), scheduleData.timezone)}
162+
</div>
163+
{scheduleData.lastRanAt && (
164+
<div>
165+
Last run: {formatDateTime(new Date(scheduleData.lastRanAt), scheduleData.timezone)}
166+
</div>
167+
)}
147168
</div>
148169
</>
149170
)
@@ -154,16 +175,11 @@ export function ScheduleConfig({
154175
setIsModalOpen(true)
155176
}
156177

157-
const handleCloseModal = () => {
178+
const handleCloseModal = useCallback(() => {
158179
setIsModalOpen(false)
159-
// Force a refresh when closing the modal
160-
// Use a small timeout to ensure backend updates are complete
161-
setTimeout(() => {
162-
setRefreshCounter((prev) => prev + 1)
163-
}, 500)
164-
}
180+
}, [])
165181

166-
const handleSaveSchedule = async (): Promise<boolean> => {
182+
const handleSaveSchedule = useCallback(async (): Promise<boolean> => {
167183
if (isPreview || disabled) return false
168184

169185
setIsSaving(true)
@@ -246,17 +262,24 @@ export function ScheduleConfig({
246262
logger.debug('Schedule save response:', responseData)
247263

248264
// 5. Update our local state with the response data
249-
if (responseData.cronExpression) {
250-
setCronExpression(responseData.cronExpression)
265+
if (responseData.cronExpression || responseData.nextRunAt) {
266+
setScheduleData((prev) => ({
267+
...prev,
268+
cronExpression: responseData.cronExpression || prev.cronExpression,
269+
nextRunAt:
270+
typeof responseData.nextRunAt === 'string'
271+
? responseData.nextRunAt
272+
: responseData.nextRunAt?.toISOString?.() || prev.nextRunAt,
273+
}))
251274
}
252275

253-
if (responseData.nextRunAt) {
254-
setNextRunAt(
255-
typeof responseData.nextRunAt === 'string'
256-
? responseData.nextRunAt
257-
: responseData.nextRunAt.toISOString?.() || responseData.nextRunAt
258-
)
259-
}
276+
// 6. Dispatch custom event to notify parent workflow-block component to refetch schedule info
277+
// This ensures the badge updates immediately after saving
278+
const event = new CustomEvent('schedule-updated', {
279+
detail: { workflowId, blockId },
280+
})
281+
window.dispatchEvent(event)
282+
logger.debug('Dispatched schedule-updated event', { workflowId, blockId })
260283

261284
// 6. Update the schedule status and trigger a workflow update
262285
// Note: Global schedule status is managed at a higher level
@@ -266,15 +289,8 @@ export function ScheduleConfig({
266289
workflowStore.updateLastSaved()
267290
workflowStore.triggerUpdate()
268291

269-
// 8. Force a refresh to update the UI
270-
// Use a timeout to ensure the API changes are completed
271-
setTimeout(() => {
272-
logger.debug('Refreshing schedule information after save')
273-
setRefreshCounter((prev) => prev + 1)
274-
275-
// Make a separate API call to ensure we get the latest schedule info
276-
checkSchedule()
277-
}, 500)
292+
// 8. Refetch the schedule to update local state
293+
await fetchSchedule()
278294

279295
return true
280296
} catch (error) {
@@ -284,10 +300,10 @@ export function ScheduleConfig({
284300
} finally {
285301
setIsSaving(false)
286302
}
287-
}
303+
}, [workflowId, blockId, isScheduleTriggerBlock, setStartWorkflow, fetchSchedule])
288304

289-
const handleDeleteSchedule = async (): Promise<boolean> => {
290-
if (isPreview || !scheduleId || disabled) return false
305+
const handleDeleteSchedule = useCallback(async (): Promise<boolean> => {
306+
if (isPreview || !scheduleData.id || disabled) return false
291307

292308
setIsDeleting(true)
293309
try {
@@ -315,7 +331,7 @@ export function ScheduleConfig({
315331
}
316332

317333
// 4. Make the DELETE API call to remove the schedule
318-
const response = await fetch(`/api/schedules/${scheduleId}`, {
334+
const response = await fetch(`/api/schedules/${scheduleData.id}`, {
319335
method: 'DELETE',
320336
})
321337

@@ -326,14 +342,23 @@ export function ScheduleConfig({
326342
}
327343

328344
// 5. Clear schedule state
329-
setScheduleId(null)
330-
setNextRunAt(null)
331-
setLastRanAt(null)
332-
setCronExpression(null)
345+
setScheduleData({
346+
id: null,
347+
nextRunAt: null,
348+
lastRanAt: null,
349+
cronExpression: null,
350+
timezone: 'UTC',
351+
})
333352

334353
// 6. Update schedule status and refresh UI
335354
// Note: Global schedule status is managed at a higher level
336-
setRefreshCounter((prev) => prev + 1)
355+
356+
// 7. Dispatch custom event to notify parent workflow-block component
357+
const event = new CustomEvent('schedule-updated', {
358+
detail: { workflowId, blockId },
359+
})
360+
window.dispatchEvent(event)
361+
logger.debug('Dispatched schedule-updated event after delete', { workflowId, blockId })
337362

338363
return true
339364
} catch (error) {
@@ -343,10 +368,18 @@ export function ScheduleConfig({
343368
} finally {
344369
setIsDeleting(false)
345370
}
346-
}
371+
}, [
372+
scheduleData.id,
373+
isPreview,
374+
disabled,
375+
isScheduleTriggerBlock,
376+
setStartWorkflow,
377+
workflowId,
378+
blockId,
379+
])
347380

348381
// Check if the schedule is active
349-
const isScheduleActive = !!scheduleId && !!nextRunAt
382+
const isScheduleActive = !!scheduleData.id && !!scheduleData.nextRunAt
350383

351384
return (
352385
<div className='w-full' onClick={(e) => e.stopPropagation()}>
@@ -399,7 +432,7 @@ export function ScheduleConfig({
399432
blockId={blockId}
400433
onSave={handleSaveSchedule}
401434
onDelete={handleDeleteSchedule}
402-
scheduleId={scheduleId}
435+
scheduleId={scheduleData.id}
403436
/>
404437
</Dialog>
405438
</div>

0 commit comments

Comments
 (0)