Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions assistant/src/__tests__/schedule-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,35 @@ describe('claimDueSchedules', () => {
expect(claimed.length).toBe(0);
});

test('claims exhausted RRULE schedule and disables it', () => {
// COUNT=1 means only one occurrence — the DTSTART itself
const rrule = 'DTSTART:20250101T000000Z\nRRULE:FREQ=DAILY;COUNT=1';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make exhausted RRULE test independent of wall-clock date

This test hardcodes DTSTART:20250101... for a COUNT=1 enabled RRULE, but createSchedule computes nextRunAt immediately and throws once that date is in the past, so the test stops before exercising claimDueSchedules. As time advances, this regression test becomes invalid/flaky and no longer verifies the intended behavior; use a future date relative to Date.now() (or freeze time) before forcing next_run_at to past.

Useful? React with 👍 / 👎.

const job = createSchedule({
name: 'Finite RRULE',
cronExpression: rrule,
message: 'one-shot',
syntax: 'rrule',
expression: rrule,
});

// Force the schedule to be due (past the only occurrence)
getRawDb().run('UPDATE cron_jobs SET next_run_at = ? WHERE id = ?', [Date.now() - 1000, job.id]);

const claimed = claimDueSchedules(Date.now());
expect(claimed.length).toBe(1);
expect(claimed[0].id).toBe(job.id);
expect(claimed[0].enabled).toBe(false);
expect(claimed[0].nextRunAt).toBe(0);

// Verify the schedule is disabled in the DB
const persisted = getSchedule(job.id);
expect(persisted!.enabled).toBe(false);

// A subsequent claim should not pick it up
const again = claimDueSchedules(Date.now());
expect(again.length).toBe(0);
});

test('optimistic lock prevents double-claiming', () => {
const job = createSchedule({
name: 'Double claim',
Expand Down
29 changes: 20 additions & 9 deletions assistant/src/schedule/schedule-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@ export function claimDueSchedules(now: number): ScheduleJob[] {

const claimed: ScheduleJob[] = [];
for (const row of candidates) {
let newNextRunAt: number;
let newNextRunAt: number | null;
let exhausted = false;
try {
const syntax = (row.scheduleSyntax as ScheduleSyntax) ?? 'cron';
newNextRunAt = computeNextRunAtEngine({
Expand All @@ -216,28 +217,38 @@ export function claimDueSchedules(now: number): ScheduleJob[] {
timezone: row.timezone,
});
} catch {
// Expression has no future runs — skip
continue;
// Finite schedule with no future runs — still claim the current due
// run but disable the schedule so it doesn't fire again.
newNextRunAt = null;
exhausted = true;
Comment on lines 219 to +223

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Narrow exhaustion fallback to no-future-run errors

This catch now treats every computeNextRunAtEngine failure as an exhausted schedule and disables the row, but computeNextRunAtEngine also throws for non-exhaustion problems (for example invalid RRULE lines or unsupported syntax in recurrence-engine.ts). In those cases we would silently mark the job disabled and still run its payload once, which hides data issues and permanently turns schedules off instead of surfacing the real error path. Please only apply the disable-and-claim behavior for explicit "no upcoming runs" failures and rethrow other exceptions.

Useful? React with 👍 / 👎.

}

// Optimistic lock: only update if nextRunAt hasn't changed
const updates: Record<string, unknown> = {
lastRunAt: now,
updatedAt: now,
};
if (exhausted) {
updates.nextRunAt = 0;
updates.enabled = false;
} else {
updates.nextRunAt = newNextRunAt!;
}

const result = db
.update(scheduleJobs)
.set({
nextRunAt: newNextRunAt,
lastRunAt: now,
updatedAt: now,
})
.set(updates)
.where(and(eq(scheduleJobs.id, row.id), eq(scheduleJobs.nextRunAt, row.nextRunAt)))
.run() as unknown as { changes?: number };

if ((result.changes ?? 0) === 0) continue;

claimed.push(parseJobRow({
...row,
nextRunAt: newNextRunAt,
nextRunAt: exhausted ? 0 : newNextRunAt!,
lastRunAt: now,
updatedAt: now,
enabled: exhausted ? false : row.enabled,
}));
}
return claimed;
Expand Down
Loading