-
Notifications
You must be signed in to change notification settings - Fork 2
feat(scheduler): add job scheduling system with production-ready reliability fixes #111
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
c9920e0
feat(scheduler): add job scheduling system
ng de5c007
fix(scheduler): implement 11 critical fixes from code review
ng 703f76f
chore: add output directory for generated artifacts
ng 6d75109
style: fix linting issues in scheduler implementation
ng 801872f
fix(scheduler): address 5 critical bugs from CodeRabbit review
ng 2199b6a
fix(scheduler): address 5 critical bugs from CodeRabbit + fix CI tests
ng File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| output/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| # Authentication Route Group | ||
|
|
||
| ## Current Status: No Authentication Required | ||
|
|
||
| This route group uses Next.js App Router's [route groups](https://nextjs.org/docs/app/building-your-application/routing/route-groups) pattern (parentheses notation) to organize all API endpoints that **will eventually require authentication**. | ||
|
|
||
| ### Why No Auth Currently? | ||
|
|
||
| **Deployment Context: Local Hardware Only** | ||
|
|
||
| This application runs exclusively on local hardware (SleepyPod device) with **no external network exposure**: | ||
|
|
||
| - Server runs on localhost only | ||
| - No internet-facing endpoints | ||
| - No remote API access | ||
| - Single-user, single-device deployment | ||
| - No multi-tenant concerns | ||
| - Health/biometric data never leaves the device | ||
|
|
||
| **Security Posture:** | ||
| - Physical access control (device is in user's home) | ||
| - Network isolation (no external exposure) | ||
| - Direct hardware control (USB/local socket communication) | ||
| - No authentication needed for local-only deployment | ||
|
|
||
| ### Benefits of (auth) Route Group Structure | ||
|
|
||
| Despite not implementing auth yet, this structure provides: | ||
|
|
||
| 1. **Future-Ready Architecture**: All sensitive endpoints are grouped | ||
| 2. **Clear Intent**: The `(auth)` name signals these routes handle sensitive operations | ||
| 3. **Easy Migration Path**: When auth is needed, add middleware in one place | ||
| 4. **Route Organization**: Parentheses folder doesn't affect URL paths | ||
| - Routes stay at `/api/trpc/*` (not `/api/(auth)/trpc/*`) | ||
|
|
||
| ### When to Add Authentication | ||
|
|
||
| Consider implementing auth if any of these change: | ||
|
|
||
| - [ ] Device becomes accessible over network/WiFi | ||
| - [ ] Cloud sync or remote monitoring features added | ||
| - [ ] Mobile app connects from different devices | ||
| - [ ] Multi-user support per device (e.g., left/right side different users) | ||
| - [ ] Web UI accessible from other machines on network | ||
| - [ ] Data export to external services | ||
|
|
||
| ### How to Add Authentication Later | ||
|
|
||
| When the time comes, adding auth is straightforward: | ||
|
|
||
| **Option 1: Middleware at Route Group Level** | ||
| ```typescript | ||
| // app/api/(auth)/middleware.ts | ||
| import { NextResponse } from 'next/server' | ||
| import type { NextRequest } from 'next/server' | ||
|
|
||
| export function middleware(request: NextRequest) { | ||
| // Validate session, JWT, or API key | ||
| const token = request.headers.get('authorization') | ||
|
|
||
| if (!isValidToken(token)) { | ||
| return NextResponse.json( | ||
| { error: 'Unauthorized' }, | ||
| { status: 401 } | ||
| ) | ||
| } | ||
|
|
||
| return NextResponse.next() | ||
| } | ||
|
|
||
| export const config = { | ||
| matcher: '/api/(auth)/:path*' | ||
| } | ||
| ``` | ||
|
|
||
| **Option 2: tRPC Context-Based Auth** | ||
| ```typescript | ||
| // src/server/trpc.ts | ||
| import { TRPCError } from '@trpc/server' | ||
|
|
||
| const protectedProcedure = publicProcedure.use(async ({ ctx, next }) => { | ||
| if (!ctx.user) { | ||
| throw new TRPCError({ code: 'UNAUTHORIZED' }) | ||
| } | ||
| return next({ ctx: { ...ctx, user: ctx.user } }) | ||
| }) | ||
|
|
||
| // Apply to routers | ||
| export const deviceRouter = router({ | ||
| setTemperature: protectedProcedure.input(...).mutation(...) | ||
| }) | ||
| ``` | ||
|
|
||
| ### Current API Endpoints in This Group | ||
|
|
||
| All tRPC procedures are in this route group: | ||
|
|
||
| **Device Control** (`/api/trpc`) | ||
| - Hardware temperature, power, alarm, priming operations | ||
| - Direct physical device control | ||
|
|
||
| **Biometrics** (`/api/trpc`) | ||
| - Sleep records, vitals, movement data | ||
| - Personal health information (PHI) | ||
|
|
||
| **Schedules** (`/api/trpc`) | ||
| - Temperature, power, and alarm schedules | ||
| - Recurring automation | ||
|
|
||
| **Settings** (`/api/trpc`) | ||
| - Device configuration, side settings, tap gestures | ||
|
|
||
| See [tRPC API Documentation](../../../src/server/routers/README.md) for full endpoint details. | ||
|
|
||
| ### Related Documentation | ||
|
|
||
| - [tRPC Review Report](../../../docs/trpc-review-2026-02-23.md) - Comprehensive security analysis | ||
| - [Hardware Integration](../../../src/hardware/README.md) - Physical device communication | ||
| - [Database Schema](../../../src/db/README.md) - Data storage architecture | ||
|
|
||
| --- | ||
|
|
||
| **Last Updated:** 2026-02-23 | ||
| **Auth Status:** Not implemented (local hardware deployment) | ||
| **Requires Auth:** No (subject to change with network features) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| import { appRouter } from '@/src/server/routers/app' | ||
| import { fetchRequestHandler } from '@trpc/server/adapters/fetch' | ||
|
|
||
| const handler = (req: Request) => { | ||
| console.log('tRPC incoming:', req.url) | ||
| return fetchRequestHandler({ | ||
| endpoint: '/api/trpc', | ||
| req, | ||
| router: appRouter, | ||
| createContext: () => ({}), | ||
| }) | ||
| } | ||
|
|
||
| export { handler as GET, handler as POST } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import { appRouter } from '@/src/server/routers/app' | ||
| import { fetchRequestHandler } from '@trpc/server/adapters/fetch' | ||
|
|
||
| const handler = (req: Request) => | ||
| fetchRequestHandler({ | ||
| endpoint: '/api/trpc', | ||
| req, | ||
| router: appRouter, | ||
| createContext: () => ({}), | ||
| }) | ||
|
|
||
| export { handler as GET, handler as POST } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,207 @@ | ||
| /** | ||
| * Scheduler initialization and process lifecycle management | ||
| * | ||
| * Handles: | ||
| * - Job scheduler initialization with retry logic | ||
| * - Centralized signal handling (SIGTERM/SIGINT) | ||
| * - Global unhandled rejection/exception handlers | ||
| * - Hardware pre-flight validation | ||
| * - Graceful shutdown sequencing | ||
| * | ||
| * USAGE: | ||
| * - If your Next.js version supports instrumentation hooks, this will be called automatically | ||
| * - Otherwise, call `initializeScheduler()` from your app startup (e.g., in a layout or API route) | ||
| */ | ||
|
|
||
| import { getJobManager, shutdownJobManager } from '@/src/scheduler' | ||
| import { closeDatabase } from '@/src/db' | ||
| import { createHardwareClient } from '@/src/hardware/client' | ||
|
|
||
| const DAC_SOCK_PATH = process.env.DAC_SOCK_PATH || '/run/dac.sock' | ||
|
|
||
| let isInitialized = false | ||
| let isShuttingDown = false | ||
| let handlersRegistered = false | ||
|
|
||
| /** | ||
| * Centralized graceful shutdown coordinator. | ||
| * Sequences: wait for in-flight jobs β shutdown scheduler β close database β exit | ||
| */ | ||
| async function gracefulShutdown(signal: string): Promise<void> { | ||
| if (isShuttingDown) return | ||
| isShuttingDown = true | ||
|
|
||
| console.log(`Received ${signal}, starting graceful shutdown...`) | ||
|
|
||
| // Force exit after 10s if graceful shutdown hangs | ||
| const forceExitTimer = setTimeout(() => { | ||
| console.error('Graceful shutdown timed out after 10s, forcing exit') | ||
| process.exit(1) | ||
| }, 10_000) | ||
| forceExitTimer.unref() | ||
|
|
||
| // Step 1: Shutdown scheduler (waits for in-flight jobs internally) | ||
| try { | ||
| await shutdownJobManager() | ||
| } | ||
| catch (error) { | ||
| console.error('Error shutting down scheduler:', error) | ||
| } | ||
|
|
||
| // Step 2: Close database connection | ||
| try { | ||
| closeDatabase() | ||
| } | ||
| catch (error) { | ||
| console.error('Error closing database:', error) | ||
| } | ||
|
|
||
| process.exit(0) | ||
| } | ||
|
|
||
| /** | ||
| * Register global process handlers (signal handlers, error handlers). | ||
| * Safe to call multiple times - only registers once. | ||
| */ | ||
| function registerGlobalHandlers(): void { | ||
| if (handlersRegistered) return | ||
| handlersRegistered = true | ||
|
|
||
| // Centralized signal handlers | ||
| process.on('SIGTERM', () => gracefulShutdown('SIGTERM')) | ||
| process.on('SIGINT', () => gracefulShutdown('SIGINT')) | ||
|
|
||
| // Global unhandled rejection handler - log but don't crash | ||
| process.on('unhandledRejection', (reason: unknown) => { | ||
| console.error('Unhandled promise rejection:', reason) | ||
| // Don't exit - let the process continue serving other requests | ||
| }) | ||
|
|
||
| // Global uncaught exception handler - log and attempt graceful shutdown | ||
| process.on('uncaughtException', (error: Error) => { | ||
| console.error('Uncaught exception:', error) | ||
| // Process state may be corrupted, attempt graceful shutdown | ||
| gracefulShutdown('uncaughtException') | ||
| }) | ||
| } | ||
|
|
||
| /** | ||
| * Retry a function with exponential backoff. | ||
| */ | ||
| async function withRetry<T>( | ||
| fn: () => Promise<T>, | ||
| label: string, | ||
| maxAttempts: number = 3, | ||
| baseDelayMs: number = 500 | ||
| ): Promise<T> { | ||
| let lastError: unknown | ||
| for (let attempt = 1; attempt <= maxAttempts; attempt++) { | ||
| try { | ||
| return await fn() | ||
| } | ||
| catch (error) { | ||
| lastError = error | ||
| if (attempt < maxAttempts) { | ||
| const delay = baseDelayMs * Math.pow(2, attempt - 1) | ||
| console.warn( | ||
| `${label} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms:`, | ||
| error instanceof Error ? error.message : error | ||
| ) | ||
| await new Promise(resolve => setTimeout(resolve, delay)) | ||
| } | ||
| } | ||
| } | ||
| throw lastError | ||
| } | ||
|
|
||
| /** | ||
| * Validate hardware daemon connectivity on startup. | ||
| * Logs a warning if unavailable but does not crash. | ||
| */ | ||
| async function validateHardware(): Promise<void> { | ||
| try { | ||
| const client = await withRetry( | ||
| () => createHardwareClient({ socketPath: DAC_SOCK_PATH, connectionTimeout: 5000 }), | ||
| 'Hardware validation', | ||
| 3, | ||
| 1000 | ||
| ) | ||
| client.disconnect() | ||
| console.log('Hardware daemon connectivity verified') | ||
| } | ||
| catch (error) { | ||
| console.warn( | ||
| 'WARNING: Hardware daemon is not available at', | ||
| DAC_SOCK_PATH, | ||
| '-', | ||
| error instanceof Error ? error.message : error | ||
| ) | ||
| console.warn('Scheduled jobs that require hardware will fail until the daemon is running') | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Initialize the job scheduler | ||
| * Safe to call multiple times - will only initialize once | ||
| */ | ||
| export async function initializeScheduler(): Promise<void> { | ||
| if (isInitialized) return | ||
|
|
||
| try { | ||
| console.log('Initializing job scheduler...') | ||
| const jobManager = await withRetry( | ||
| () => getJobManager(), | ||
| 'Job manager initialization' | ||
| ) | ||
| const scheduler = jobManager.getScheduler() | ||
| const jobs = scheduler.getJobs() | ||
|
|
||
| console.log(`Job scheduler initialized with ${jobs.length} scheduled jobs`) | ||
|
|
||
| // Log next scheduled jobs for visibility | ||
| const upcomingJobs = jobs | ||
| .map((job) => { | ||
| const nextRun = scheduler.getNextInvocation(job.id) | ||
| return { | ||
| id: job.id, | ||
| type: job.type, | ||
| nextRun: nextRun ? nextRun.toISOString() : 'N/A', | ||
| } | ||
| }) | ||
| .filter(job => job.nextRun !== 'N/A') | ||
| .sort((a, b) => { | ||
| if (a.nextRun === 'N/A' || b.nextRun === 'N/A') return 0 | ||
| return new Date(a.nextRun).getTime() - new Date(b.nextRun).getTime() | ||
| }) | ||
| .slice(0, 5) | ||
|
|
||
| if (upcomingJobs.length > 0) { | ||
| console.log('Next scheduled jobs:') | ||
| for (const job of upcomingJobs) { | ||
| console.log(` - ${job.id}: ${job.nextRun}`) | ||
| } | ||
| } | ||
|
|
||
| isInitialized = true | ||
|
|
||
| // Validate hardware connectivity (non-blocking, runs after scheduler is ready) | ||
| validateHardware() | ||
| } | ||
| catch (error) { | ||
| console.error('Failed to initialize job scheduler:', error) | ||
| // Don't crash the app if scheduler fails to initialize | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Next.js instrumentation hook (if supported) | ||
| * Automatically called by Next.js on server startup | ||
| */ | ||
| export async function register(): Promise<void> { | ||
| // Only run on server | ||
| if (process.env.NEXT_RUNTIME === 'nodejs' || typeof window === 'undefined') { | ||
| // Register global handlers first (before any initialization that could fail) | ||
| registerGlobalHandlers() | ||
| await initializeScheduler() | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid logging full tRPC request URLs (PII risk).
Line 5 logs
req.url, which can include serialized inputs in the query string. This risks leaking sensitive data into logs. Consider logging only the pathname or removing the log line.π Suggested fix (sanitize logged URL)
const handler = (req: Request) => { - console.log('tRPC incoming:', req.url) + const { pathname } = new URL(req.url) + console.log('tRPC incoming:', pathname) return fetchRequestHandler({π Committable suggestion
π€ Prompt for AI Agents