diff --git a/backend/migrations/121_schedule_interval_minutes.sql b/backend/migrations/121_schedule_interval_minutes.sql new file mode 100644 index 00000000..17383a73 --- /dev/null +++ b/backend/migrations/121_schedule_interval_minutes.sql @@ -0,0 +1,15 @@ +-- Migration 121: Add interval_minutes to task_schedules for sub-hour scheduling +-- Part of Real-Time Inventory Tracking feature +-- Created: 2024-12-14 + +-- Add interval_minutes column for sub-hour scheduling (15min, 30min, etc.) +-- When set, takes precedence over interval_hours +ALTER TABLE task_schedules ADD COLUMN IF NOT EXISTS interval_minutes INT DEFAULT NULL; + +-- Add comment for documentation +COMMENT ON COLUMN task_schedules.interval_minutes IS 'Sub-hour scheduling interval in minutes (takes precedence over interval_hours when set)'; + +-- Create index for finding schedules by interval type +CREATE INDEX IF NOT EXISTS idx_task_schedules_interval_minutes +ON task_schedules(interval_minutes) +WHERE interval_minutes IS NOT NULL; diff --git a/backend/src/services/task-scheduler.ts b/backend/src/services/task-scheduler.ts index 8aff0e0c..9ce5f65e 100644 --- a/backend/src/services/task-scheduler.ts +++ b/backend/src/services/task-scheduler.ts @@ -22,6 +22,7 @@ interface TaskSchedule { role: TaskRole; enabled: boolean; interval_hours: number; + interval_minutes: number | null; // For sub-hour scheduling (takes precedence over interval_hours) last_run_at: Date | null; next_run_at: Date | null; state_code: string | null; @@ -167,28 +168,66 @@ class TaskScheduler { console.log(`[TaskScheduler] Schedule ${schedule.name} created ${tasksCreated} tasks`); // Per TASK_WORKFLOW_2024-12-10.md: Update last_run_at and calculate next_run_at - await client.query(` - UPDATE task_schedules - SET - last_run_at = NOW(), - next_run_at = NOW() + ($1 || ' hours')::interval, - last_task_count = $2, - updated_at = NOW() - WHERE id = $3 - `, [schedule.interval_hours, tasksCreated, schedule.id]); + // Prefer interval_minutes over interval_hours for sub-hour scheduling + // Add jitter (0-20% of interval) to prevent predictable crawl patterns + let nextRunQuery: string; + let nextRunParams: any[]; + + if (schedule.interval_minutes) { + // Sub-hour scheduling with jitter + const jitterMinutes = Math.floor(Math.random() * (schedule.interval_minutes * 0.2)); + const totalMinutes = schedule.interval_minutes + jitterMinutes; + nextRunQuery = ` + UPDATE task_schedules + SET + last_run_at = NOW(), + next_run_at = NOW() + ($1 || ' minutes')::interval, + last_task_count = $2, + updated_at = NOW() + WHERE id = $3 + `; + nextRunParams = [totalMinutes, tasksCreated, schedule.id]; + console.log(`[TaskScheduler] Schedule ${schedule.name} next run in ${totalMinutes}min (${schedule.interval_minutes}min + ${jitterMinutes}min jitter)`); + } else { + // Standard hour-based scheduling + nextRunQuery = ` + UPDATE task_schedules + SET + last_run_at = NOW(), + next_run_at = NOW() + ($1 || ' hours')::interval, + last_task_count = $2, + updated_at = NOW() + WHERE id = $3 + `; + nextRunParams = [schedule.interval_hours, tasksCreated, schedule.id]; + } + + await client.query(nextRunQuery, nextRunParams); } catch (err: any) { console.error(`[TaskScheduler] Schedule ${schedule.name} failed:`, err.message); // Still update next_run_at to prevent infinite retry loop - await client.query(` - UPDATE task_schedules - SET - next_run_at = NOW() + ($1 || ' hours')::interval, - last_error = $2, - updated_at = NOW() - WHERE id = $3 - `, [schedule.interval_hours, err.message, schedule.id]); + // Use interval_minutes if set, otherwise interval_hours + if (schedule.interval_minutes) { + await client.query(` + UPDATE task_schedules + SET + next_run_at = NOW() + ($1 || ' minutes')::interval, + last_error = $2, + updated_at = NOW() + WHERE id = $3 + `, [schedule.interval_minutes, err.message, schedule.id]); + } else { + await client.query(` + UPDATE task_schedules + SET + next_run_at = NOW() + ($1 || ' hours')::interval, + last_error = $2, + updated_at = NOW() + WHERE id = $3 + `, [schedule.interval_hours, err.message, schedule.id]); + } } } @@ -511,33 +550,37 @@ class TaskScheduler { try { const result = await pool.query(` SELECT - id, - name, - role, - enabled, - interval_hours, - last_run_at, - next_run_at, - state_code, - priority, - method, - COALESCE(is_immutable, false) as is_immutable, - description, - platform, - last_task_count, - last_error, - created_at, - updated_at - FROM task_schedules + ts.id, + ts.name, + ts.role, + ts.enabled, + ts.interval_hours, + ts.interval_minutes, + ts.last_run_at, + ts.next_run_at, + ts.state_code, + ts.dispensary_id, + ts.priority, + ts.method, + COALESCE(ts.is_immutable, false) as is_immutable, + ts.description, + ts.platform, + ts.last_task_count, + ts.last_error, + ts.created_at, + ts.updated_at, + d.name as dispensary_name + FROM task_schedules ts + LEFT JOIN dispensaries d ON ts.dispensary_id = d.id ORDER BY - CASE role + CASE ts.role WHEN 'store_discovery' THEN 1 WHEN 'product_discovery' THEN 2 WHEN 'analytics_refresh' THEN 3 ELSE 4 END, - state_code NULLS FIRST, - name + ts.state_code NULLS FIRST, + ts.name `); return result.rows as TaskSchedule[]; } catch { @@ -561,8 +604,8 @@ class TaskScheduler { /** * Update a schedule - * Allows updating: enabled, interval_hours, priority - * Does NOT allow updating: name, role, state_code, is_immutable + * Allows updating: enabled, interval_hours, interval_minutes, priority + * Does NOT allow updating: name, role, state_code, dispensary_id, is_immutable */ async updateSchedule(id: number, updates: Partial): Promise { const setClauses: string[] = []; @@ -577,6 +620,11 @@ class TaskScheduler { setClauses.push(`interval_hours = $${paramIndex++}`); values.push(updates.interval_hours); } + // Allow setting interval_minutes (can be null to disable sub-hour scheduling) + if ('interval_minutes' in updates) { + setClauses.push(`interval_minutes = $${paramIndex++}`); + values.push(updates.interval_minutes); + } if (updates.priority !== undefined) { setClauses.push(`priority = $${paramIndex++}`); values.push(updates.priority);