feat(scheduler): add internal scheduler for self-hosted environments
Summary
- Add built-in internal scheduler for self-hosted environments
- Scheduler polls
/api/schedules/executeto trigger scheduled workflows - Enabled by default in
docker-compose.prod.ymlfor self-hosted setups - Configurable via
ENABLE_INTERNAL_SCHEDULER,CRON_SECRET, andINTERNAL_SCHEDULER_INTERVAL_MSenvironment variables
Test plan
- [x] Added unit tests for internal scheduler
- [x] All tests pass (4 tests)
- [ ] Manual testing with Docker deployment
Fixes #1870
@majiayu000 is attempting to deploy a commit to the Sim Team on Vercel.
A member of the Team first needs to authorize it.
Greptile Summary
This PR adds a built-in internal scheduler for self-hosted environments that periodically polls /api/schedules/execute to trigger scheduled workflows, solving the issue where schedule triggers don't work without external cron services like Vercel Cron.
Key Changes
-
Internal Scheduler Module: New
internal-scheduler.tsimplements polling logic with configurable interval (default 60s), proper error handling, and graceful shutdown -
App Integration: Scheduler initializes during app startup via
instrumentation-node.tsregister hook -
Configuration: Three new environment variables added:
ENABLE_INTERNAL_SCHEDULER,CRON_SECRET, andINTERNAL_SCHEDULER_INTERVAL_MS -
Docker Setup: Enabled by default in
docker-compose.prod.ymlwith sensible defaults - Test Coverage: Comprehensive unit tests for scheduler functionality
Implementation Details
The scheduler uses a simple polling mechanism with safeguards against concurrent executions. It authenticates using the existing CRON_SECRET via Bearer token, integrating seamlessly with the existing /api/schedules/execute endpoint that already supports both Trigger.dev and direct execution modes.
Issues Found
-
Critical:
docker-compose.prod.ymlis missing requiredINTERNAL_API_SECRETenvironment variable, which will prevent the app from starting -
Style: Environment variable types should use
z.boolean()andz.number()instead ofz.string()for consistency with codebase patterns -
Minor: Signal handlers could accumulate if
initializeInternalScheduler()is called multiple times (hot reload scenarios)
Confidence Score: 3/5
- This PR has a critical configuration issue that will prevent Docker deployments from starting, but the core implementation is solid
- The scheduler implementation itself is well-designed with proper error handling, tests, and graceful shutdown. However, the missing
INTERNAL_API_SECRETin docker-compose.prod.yml is a critical issue that will cause immediate failures for users following the self-hosted Docker setup. The score reflects this deployment blocker rather than the quality of the scheduler code itself. -
docker-compose.prod.ymlrequires the missingINTERNAL_API_SECRETenvironment variable to be added before merge
Important Files Changed
| Filename | Overview |
|---|---|
| apps/sim/lib/scheduler/internal-scheduler.ts | Core scheduler implementation with proper error handling and graceful shutdown. Implementation is solid but has a potential race condition with multiple SIGTERM/SIGINT handlers. |
| apps/sim/lib/core/config/env.ts | Added environment variables for scheduler configuration. Type should be z.boolean() instead of z.string() for consistency with other boolean flags. |
| docker-compose.prod.yml | Added scheduler configuration but missing required INTERNAL_API_SECRET environment variable which is needed for the app to start. |
Sequence Diagram
sequenceDiagram
participant App as Sim Application
participant Scheduler as Internal Scheduler
participant API as /api/schedules/execute
participant DB as Database
participant Workflow as Workflow Execution
Note over App: App Startup (instrumentation-node.ts)
App->>Scheduler: initializeInternalScheduler()
alt ENABLE_INTERNAL_SCHEDULER !== 'true'
Scheduler-->>App: Skip initialization
else CRON_SECRET not configured
Scheduler-->>App: Log warning, skip
else Configuration valid
Scheduler->>Scheduler: startInternalScheduler()
Note over Scheduler: Set interval timer (default: 60s)
Scheduler->>Scheduler: pollSchedules() immediately
end
loop Every INTERNAL_SCHEDULER_INTERVAL_MS
Scheduler->>Scheduler: Check if previous poll running
alt Previous poll still running
Scheduler->>Scheduler: Skip this cycle
else Ready to poll
Scheduler->>API: GET /api/schedules/execute<br/>Authorization: Bearer {CRON_SECRET}
API->>API: verifyCronAuth()
alt Auth fails
API-->>Scheduler: 401 Unauthorized
Scheduler->>Scheduler: Log error
else Auth succeeds
API->>DB: Query due schedules<br/>(nextRunAt <= now, status != disabled)
DB-->>API: Return due schedules
API->>DB: Update lastQueuedAt
loop For each due schedule
alt Trigger.dev enabled
API->>Workflow: tasks.trigger('schedule-execution')
else Trigger.dev disabled
API->>Workflow: executeScheduleJob() directly
end
end
API-->>Scheduler: 200 OK {executedCount: N}
Scheduler->>Scheduler: Log execution count
end
end
end
Note over App: Graceful Shutdown (SIGTERM/SIGINT)
App->>Scheduler: Signal handler triggered
Scheduler->>Scheduler: stopInternalScheduler()
Scheduler->>Scheduler: clearInterval()