Skip to content

Commit 30d01b2

Browse files
committed
feat(scheduler): add internal scheduler for self-hosted environments
Adds built-in scheduler that periodically polls /api/schedules/execute to trigger scheduled workflows in self-hosted environments. Enable by setting ENABLE_INTERNAL_SCHEDULER=true (enabled by default in docker-compose.prod.yml). Also requires CRON_SECRET to be configured. Fixes #1870
1 parent 4660b7a commit 30d01b2

File tree

5 files changed

+245
-0
lines changed

5 files changed

+245
-0
lines changed

apps/sim/instrumentation-node.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,12 @@ async function initializeOpenTelemetry() {
115115

116116
export async function register() {
117117
await initializeOpenTelemetry()
118+
119+
// Initialize internal scheduler for self-hosted environments
120+
try {
121+
const { initializeInternalScheduler } = await import('./lib/scheduler/internal-scheduler')
122+
initializeInternalScheduler()
123+
} catch (error) {
124+
logger.error('Failed to initialize internal scheduler', error)
125+
}
118126
}

apps/sim/lib/core/config/env.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,8 @@ export const env = createEnv({
125125
TRIGGER_SECRET_KEY: z.string().min(1).optional(), // Trigger.dev secret key for background jobs
126126
TRIGGER_DEV_ENABLED: z.boolean().optional(), // Toggle to enable/disable Trigger.dev for async jobs
127127
CRON_SECRET: z.string().optional(), // Secret for authenticating cron job requests
128+
ENABLE_INTERNAL_SCHEDULER: z.string().optional(), // Enable built-in scheduler for self-hosted environments
129+
INTERNAL_SCHEDULER_INTERVAL_MS: z.string().optional(), // Internal scheduler poll interval (default: 60000ms)
128130
JOB_RETENTION_DAYS: z.string().optional().default('1'), // Days to retain job logs/data
129131

130132
// Cloud Storage - AWS S3
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
vi.mock('@/lib/core/config/env', () => ({
4+
env: {
5+
ENABLE_INTERNAL_SCHEDULER: 'true',
6+
CRON_SECRET: 'test-secret',
7+
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
8+
INTERNAL_SCHEDULER_INTERVAL_MS: '1000',
9+
},
10+
}))
11+
12+
vi.mock('@/lib/logs/console/logger', () => ({
13+
createLogger: vi.fn().mockReturnValue({
14+
info: vi.fn(),
15+
error: vi.fn(),
16+
warn: vi.fn(),
17+
debug: vi.fn(),
18+
}),
19+
}))
20+
21+
const mockFetch = vi.fn()
22+
global.fetch = mockFetch
23+
24+
describe('Internal Scheduler', () => {
25+
beforeEach(() => {
26+
vi.clearAllMocks()
27+
mockFetch.mockResolvedValue({
28+
ok: true,
29+
json: async () => ({ executedCount: 0 }),
30+
})
31+
})
32+
33+
afterEach(() => {
34+
vi.clearAllMocks()
35+
})
36+
37+
it('should poll schedules endpoint with correct authentication', async () => {
38+
const { startInternalScheduler, stopInternalScheduler } = await import(
39+
'./internal-scheduler'
40+
)
41+
42+
startInternalScheduler()
43+
44+
// Wait for the initial poll to complete
45+
await new Promise((resolve) => setTimeout(resolve, 100))
46+
47+
expect(mockFetch).toHaveBeenCalledWith(
48+
'http://localhost:3000/api/schedules/execute',
49+
expect.objectContaining({
50+
method: 'GET',
51+
headers: expect.objectContaining({
52+
Authorization: 'Bearer test-secret',
53+
'User-Agent': 'sim-studio-internal-scheduler/1.0',
54+
}),
55+
})
56+
)
57+
58+
stopInternalScheduler()
59+
})
60+
61+
it('should handle fetch errors gracefully', async () => {
62+
mockFetch.mockRejectedValueOnce(new Error('Network error'))
63+
64+
const { startInternalScheduler, stopInternalScheduler } = await import(
65+
'./internal-scheduler'
66+
)
67+
68+
// Should not throw
69+
startInternalScheduler()
70+
await new Promise((resolve) => setTimeout(resolve, 100))
71+
stopInternalScheduler()
72+
})
73+
74+
it('should handle non-ok responses', async () => {
75+
mockFetch.mockResolvedValueOnce({
76+
ok: false,
77+
status: 401,
78+
text: async () => 'Unauthorized',
79+
})
80+
81+
const { startInternalScheduler, stopInternalScheduler } = await import(
82+
'./internal-scheduler'
83+
)
84+
85+
// Should not throw
86+
startInternalScheduler()
87+
await new Promise((resolve) => setTimeout(resolve, 100))
88+
stopInternalScheduler()
89+
})
90+
})
91+
92+
describe('shouldEnableInternalScheduler', () => {
93+
it('should return true when ENABLE_INTERNAL_SCHEDULER is true', async () => {
94+
const { shouldEnableInternalScheduler } = await import('./internal-scheduler')
95+
expect(shouldEnableInternalScheduler()).toBe(true)
96+
})
97+
})
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/**
2+
* Internal Scheduler for Self-Hosted Environments
3+
*
4+
* This module provides a built-in scheduler that periodically polls the
5+
* /api/schedules/execute endpoint to trigger scheduled workflows.
6+
* This is necessary for self-hosted environments that don't have access
7+
* to external cron services like Vercel Cron Jobs.
8+
*
9+
* Enable by setting ENABLE_INTERNAL_SCHEDULER=true in your environment.
10+
*/
11+
12+
import { env } from '@/lib/core/config/env'
13+
import { createLogger } from '@/lib/logs/console/logger'
14+
15+
const logger = createLogger('InternalScheduler')
16+
17+
const DEFAULT_POLL_INTERVAL_MS = 60000 // 1 minute
18+
19+
let schedulerInterval: ReturnType<typeof setInterval> | null = null
20+
let isRunning = false
21+
22+
/**
23+
* Execute the schedule poll
24+
*/
25+
async function pollSchedules(): Promise<void> {
26+
if (isRunning) {
27+
logger.debug('Previous poll still running, skipping this cycle')
28+
return
29+
}
30+
31+
isRunning = true
32+
33+
try {
34+
const appUrl = env.NEXT_PUBLIC_APP_URL || env.BETTER_AUTH_URL || 'http://localhost:3000'
35+
const cronSecret = env.CRON_SECRET
36+
37+
if (!cronSecret) {
38+
logger.warn('CRON_SECRET not configured, internal scheduler cannot authenticate')
39+
return
40+
}
41+
42+
const response = await fetch(`${appUrl}/api/schedules/execute`, {
43+
method: 'GET',
44+
headers: {
45+
Authorization: `Bearer ${cronSecret}`,
46+
'User-Agent': 'sim-studio-internal-scheduler/1.0',
47+
},
48+
})
49+
50+
if (!response.ok) {
51+
const errorText = await response.text()
52+
logger.error('Schedule poll failed', {
53+
status: response.status,
54+
error: errorText,
55+
})
56+
return
57+
}
58+
59+
const result = await response.json()
60+
if (result.executedCount > 0) {
61+
logger.info(`Triggered ${result.executedCount} scheduled workflow(s)`)
62+
}
63+
} catch (error) {
64+
logger.error('Error during schedule poll', error)
65+
} finally {
66+
isRunning = false
67+
}
68+
}
69+
70+
/**
71+
* Start the internal scheduler
72+
*/
73+
export function startInternalScheduler(): void {
74+
if (schedulerInterval) {
75+
logger.warn('Internal scheduler already running')
76+
return
77+
}
78+
79+
const pollInterval = Number(env.INTERNAL_SCHEDULER_INTERVAL_MS) || DEFAULT_POLL_INTERVAL_MS
80+
81+
logger.info(`Starting internal scheduler with poll interval: ${pollInterval}ms`)
82+
83+
// Run immediately on start
84+
void pollSchedules()
85+
86+
// Then run at regular intervals
87+
schedulerInterval = setInterval(() => {
88+
void pollSchedules()
89+
}, pollInterval)
90+
}
91+
92+
/**
93+
* Stop the internal scheduler
94+
*/
95+
export function stopInternalScheduler(): void {
96+
if (schedulerInterval) {
97+
clearInterval(schedulerInterval)
98+
schedulerInterval = null
99+
logger.info('Internal scheduler stopped')
100+
}
101+
}
102+
103+
/**
104+
* Check if the internal scheduler should be enabled
105+
*/
106+
export function shouldEnableInternalScheduler(): boolean {
107+
return env.ENABLE_INTERNAL_SCHEDULER === 'true'
108+
}
109+
110+
/**
111+
* Initialize the internal scheduler if enabled
112+
*/
113+
export function initializeInternalScheduler(): void {
114+
if (!shouldEnableInternalScheduler()) {
115+
logger.debug('Internal scheduler disabled (set ENABLE_INTERNAL_SCHEDULER=true to enable)')
116+
return
117+
}
118+
119+
if (!env.CRON_SECRET) {
120+
logger.warn('Cannot start internal scheduler: CRON_SECRET is not configured')
121+
return
122+
}
123+
124+
startInternalScheduler()
125+
126+
// Graceful shutdown handlers
127+
process.on('SIGTERM', () => {
128+
stopInternalScheduler()
129+
})
130+
131+
process.on('SIGINT', () => {
132+
stopInternalScheduler()
133+
})
134+
}

docker-compose.prod.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ services:
2020
- OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434}
2121
- SOCKET_SERVER_URL=${SOCKET_SERVER_URL:-http://localhost:3002}
2222
- NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-http://localhost:3002}
23+
# Internal scheduler for self-hosted environments (enables scheduled workflows)
24+
- ENABLE_INTERNAL_SCHEDULER=${ENABLE_INTERNAL_SCHEDULER:-true}
25+
- CRON_SECRET=${CRON_SECRET:-default-cron-secret-change-me}
26+
- INTERNAL_SCHEDULER_INTERVAL_MS=${INTERNAL_SCHEDULER_INTERVAL_MS:-60000}
2327
depends_on:
2428
db:
2529
condition: service_healthy

0 commit comments

Comments
 (0)