feat(scheduler): Support sub-hour interval_minutes in task_schedules

- Add interval_minutes column to TaskSchedule interface
- Prefer interval_minutes over interval_hours when calculating next_run_at
- Add jitter (0-20% of interval) for sub-hour schedules to prevent detection
- Update getSchedules() to include interval_minutes and dispensary_name
- Update updateSchedule() to allow setting interval_minutes
- Add migration 121 for interval_minutes column

Part of Real-Time Inventory Tracking feature.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kelly
2025-12-14 18:22:55 -07:00
parent bf988529eb
commit b607fd7f44
2 changed files with 103 additions and 40 deletions

View File

@@ -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;

View File

@@ -22,6 +22,7 @@ interface TaskSchedule {
role: TaskRole; role: TaskRole;
enabled: boolean; enabled: boolean;
interval_hours: number; interval_hours: number;
interval_minutes: number | null; // For sub-hour scheduling (takes precedence over interval_hours)
last_run_at: Date | null; last_run_at: Date | null;
next_run_at: Date | null; next_run_at: Date | null;
state_code: string | null; state_code: string | null;
@@ -167,28 +168,66 @@ class TaskScheduler {
console.log(`[TaskScheduler] Schedule ${schedule.name} created ${tasksCreated} tasks`); 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 // Per TASK_WORKFLOW_2024-12-10.md: Update last_run_at and calculate next_run_at
await client.query(` // Prefer interval_minutes over interval_hours for sub-hour scheduling
UPDATE task_schedules // Add jitter (0-20% of interval) to prevent predictable crawl patterns
SET let nextRunQuery: string;
last_run_at = NOW(), let nextRunParams: any[];
next_run_at = NOW() + ($1 || ' hours')::interval,
last_task_count = $2, if (schedule.interval_minutes) {
updated_at = NOW() // Sub-hour scheduling with jitter
WHERE id = $3 const jitterMinutes = Math.floor(Math.random() * (schedule.interval_minutes * 0.2));
`, [schedule.interval_hours, tasksCreated, schedule.id]); 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) { } catch (err: any) {
console.error(`[TaskScheduler] Schedule ${schedule.name} failed:`, err.message); console.error(`[TaskScheduler] Schedule ${schedule.name} failed:`, err.message);
// Still update next_run_at to prevent infinite retry loop // Still update next_run_at to prevent infinite retry loop
await client.query(` // Use interval_minutes if set, otherwise interval_hours
UPDATE task_schedules if (schedule.interval_minutes) {
SET await client.query(`
next_run_at = NOW() + ($1 || ' hours')::interval, UPDATE task_schedules
last_error = $2, SET
updated_at = NOW() next_run_at = NOW() + ($1 || ' minutes')::interval,
WHERE id = $3 last_error = $2,
`, [schedule.interval_hours, err.message, schedule.id]); 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 { try {
const result = await pool.query(` const result = await pool.query(`
SELECT SELECT
id, ts.id,
name, ts.name,
role, ts.role,
enabled, ts.enabled,
interval_hours, ts.interval_hours,
last_run_at, ts.interval_minutes,
next_run_at, ts.last_run_at,
state_code, ts.next_run_at,
priority, ts.state_code,
method, ts.dispensary_id,
COALESCE(is_immutable, false) as is_immutable, ts.priority,
description, ts.method,
platform, COALESCE(ts.is_immutable, false) as is_immutable,
last_task_count, ts.description,
last_error, ts.platform,
created_at, ts.last_task_count,
updated_at ts.last_error,
FROM task_schedules 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 ORDER BY
CASE role CASE ts.role
WHEN 'store_discovery' THEN 1 WHEN 'store_discovery' THEN 1
WHEN 'product_discovery' THEN 2 WHEN 'product_discovery' THEN 2
WHEN 'analytics_refresh' THEN 3 WHEN 'analytics_refresh' THEN 3
ELSE 4 ELSE 4
END, END,
state_code NULLS FIRST, ts.state_code NULLS FIRST,
name ts.name
`); `);
return result.rows as TaskSchedule[]; return result.rows as TaskSchedule[];
} catch { } catch {
@@ -561,8 +604,8 @@ class TaskScheduler {
/** /**
* Update a schedule * Update a schedule
* Allows updating: enabled, interval_hours, priority * Allows updating: enabled, interval_hours, interval_minutes, priority
* Does NOT allow updating: name, role, state_code, is_immutable * Does NOT allow updating: name, role, state_code, dispensary_id, is_immutable
*/ */
async updateSchedule(id: number, updates: Partial<TaskSchedule>): Promise<void> { async updateSchedule(id: number, updates: Partial<TaskSchedule>): Promise<void> {
const setClauses: string[] = []; const setClauses: string[] = [];
@@ -577,6 +620,11 @@ class TaskScheduler {
setClauses.push(`interval_hours = $${paramIndex++}`); setClauses.push(`interval_hours = $${paramIndex++}`);
values.push(updates.interval_hours); 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) { if (updates.priority !== undefined) {
setClauses.push(`priority = $${paramIndex++}`); setClauses.push(`priority = $${paramIndex++}`);
values.push(updates.priority); values.push(updates.priority);