Skip to content

Commit 878d3d7

Browse files
committed
feat(schedule): add getSchedule() and previewSchedule()
Reading one schedule and answering "when does this next fire?" are both common in admin tooling and in libraries that wrap schedule(), and both are awkward today. getSchedules(name, key) already narrows to the single row a (name, key) pair can hold, but still returns an array, so every caller destructures and then checks for undefined. getSchedule(name, key) returns the row or null. Previewing upcoming occurrences means reaching for cron-parser directly, which duplicates the expression/time zone validation schedule() performs and evaluates against the local clock rather than database time. previewSchedule() reuses the same validation (extracted as assertRecurrence, now shared with schedule()) and defaults its reference point to database time, the reading the cron pass itself evaluates against. previewSchedule() is pure computation: no query, no started instance needed. Occurrences are strictly after `from`, so passing the last one back in pages forward. count is capped at 1000 so a per-second expression cannot be asked for an unbounded walk.
1 parent 20fdc8a commit 878d3d7

5 files changed

Lines changed: 319 additions & 6 deletions

File tree

docs/api/scheduling.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,65 @@ Returns all scheduled jobs by queue name and unique key.
103103

104104
```js
105105
const [schedule] = await boss.getSchedules('report', 'eu')
106+
```
107+
108+
### `getSchedule(name, key)`
109+
110+
Returns the schedule for a queue name and unique key, or `null` if there is none. `key` defaults to
111+
the empty string, the key `schedule()` uses when none is supplied.
112+
113+
Unlike `getSchedules(name, key)`, which always returns an array, this reads the one row a
114+
`(name, key)` pair can have.
115+
116+
```js
117+
const schedule = await boss.getSchedule('report', 'eu')
118+
119+
if (schedule) {
120+
console.log(`${schedule.cron} ${schedule.timezone}`)
121+
}
122+
```
123+
124+
### `previewSchedule(cron, options)`
125+
126+
Returns the next occurrences a cron expression produces, as an array of `Date`. The expression and
127+
time zone are validated exactly as `schedule()` validates them, so anything this previews can also
128+
be stored.
129+
130+
This is pure computation: it does not query the database and does not require a started instance.
131+
132+
**Arguments**
133+
134+
- `cron`: string, *required*
135+
- `options`: object
136+
137+
**options**
138+
139+
* **tz**, string, *default: `UTC`*
140+
141+
Time zone the expression is evaluated in.
142+
143+
* **from**, Date, *default: database time*
144+
145+
Reference point the walk starts from. The default is this instance's clock plus the cached skew,
146+
the same reading the cron pass evaluates against. Occurrences are strictly after it, so passing
147+
the last occurrence of one page back in yields the next page.
148+
149+
* **count**, number, *default: 5*
150+
151+
How many occurrences to return. Must be an integer between 1 and 1000.
152+
153+
```js
154+
const occurrences = boss.previewSchedule('0 3 * * *', { tz: 'America/Chicago', count: 3 })
155+
```
156+
157+
The result describes the expression, not the delivery. Schedules are checked every
158+
`cronMonitorIntervalSeconds` and an occurrence within the preceding 60 seconds is sent, so a job
159+
lands at or shortly after each listed time.
160+
161+
To preview a stored schedule, read it first:
162+
163+
```js
164+
const schedule = await boss.getSchedule('report', 'eu')
165+
166+
const upcoming = boss.previewSchedule(schedule.cron, { tz: schedule.timezone })
106167
```

src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,14 @@ export class PgBoss extends EventEmitter<types.PgBossEventMap> {
519519
return this.#timekeeper.getSchedules(name, key)
520520
}
521521

522+
getSchedule (name: string, key?: string): Promise<types.Schedule | null> {
523+
return this.#timekeeper.getSchedule(name, key)
524+
}
525+
526+
previewSchedule (cron: string, options?: types.PreviewScheduleOptions): Date[] {
527+
return this.#timekeeper.previewSchedule(cron, options)
528+
}
529+
522530
async getBamStatus (): Promise<types.BamStatusSummary[]> {
523531
const sql = plans.getBamStatus(this.#config.schema)
524532
const { rows } = await this.#db.executeSql(sql)

src/timekeeper.ts

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { CronExpressionParser } from 'cron-parser'
2+
import assert from 'node:assert'
23
import EventEmitter from 'node:events'
34

45
import * as Attorney from './attorney.ts'
@@ -29,6 +30,12 @@ const WARNING_TYPES = {
2930
INVALID_SCHEDULE: 'invalid_schedule'
3031
} as const
3132

33+
// previewSchedule() defaults and ceiling. The ceiling is not a database limit, since the walk is
34+
// pure cron-parser arithmetic, but an unbounded count on a per-second expression is a foot-gun.
35+
// A caller that genuinely wants more can page by passing the last occurrence back as `from`.
36+
const PREVIEW_DEFAULT_COUNT = 5
37+
const PREVIEW_MAX_COUNT = 1000
38+
3239
/**
3340
* Asserts that `tz` is a time zone cron evaluation can actually use.
3441
*
@@ -50,6 +57,20 @@ function assertTimezone (tz: string): void {
5057
}
5158
}
5259

60+
/**
61+
* Validates a recurrence exactly as schedule() does, so previewSchedule() rejects the same inputs
62+
* schedule() rejects rather than previewing an expression that could never be stored.
63+
*
64+
* Expression first, so a bad expression reports as one rather than as a time zone problem. The
65+
* check is deliberately run against UTC rather than the supplied tz: it only works today because
66+
* cron-parser is lazy about an unusable zone, and if that ever changes this call would throw the
67+
* opaque "CronDate: unhandled timestamp" that assertTimezone exists to replace.
68+
*/
69+
function assertRecurrence (cron: string, tz: string): void {
70+
CronExpressionParser.parse(cron, { tz: 'UTC', strict: false })
71+
assertTimezone(tz)
72+
}
73+
5374
class Timekeeper extends EventEmitter implements types.EventsMixin {
5475
db: types.IDatabase
5576
config: types.ResolvedConstructorOptions
@@ -294,15 +315,55 @@ class Timekeeper extends EventEmitter implements types.EventsMixin {
294315
return rows
295316
}
296317

318+
async getSchedule (name: string, key = ''): Promise<types.Schedule | null> {
319+
Attorney.assertQueueName(name)
320+
Attorney.assertKey(key)
321+
322+
const [schedule] = await this.getSchedules(name, key)
323+
324+
return schedule ?? null
325+
}
326+
327+
/**
328+
* The occurrences a cron expression produces, computed in process without touching the database
329+
* or the schedule table.
330+
*
331+
* `from` defaults to database time (this instance's clock plus the cached skew), the same reading
332+
* the cron pass evaluates against, so a preview taken from a running instance lines up with what
333+
* that instance will actually send. Occurrences are strictly after `from`, so paging is a matter
334+
* of passing the last one back in.
335+
*
336+
* The result describes the expression, not the delivery. The cron pass runs every
337+
* `cronMonitorIntervalSeconds` and matches an occurrence within the preceding 60 seconds, so a
338+
* job lands at or shortly after each listed time.
339+
*/
340+
previewSchedule (cron: string, options: types.PreviewScheduleOptions = {}): Date[] {
341+
const { tz = 'UTC', count = PREVIEW_DEFAULT_COUNT } = options
342+
343+
assert(Number.isInteger(count) && count >= 1 && count <= PREVIEW_MAX_COUNT,
344+
`count must be an integer between 1 and ${PREVIEW_MAX_COUNT}`)
345+
346+
const from = options.from ?? new Date(Date.now() + this.clockSkew)
347+
348+
assert(from instanceof Date && !Number.isNaN(from.getTime()), 'from must be a valid Date')
349+
350+
assertRecurrence(cron, tz)
351+
352+
const interval = CronExpressionParser.parse(cron, { tz, strict: false, currentDate: from })
353+
354+
const occurrences: Date[] = []
355+
356+
for (let i = 0; i < count; i++) {
357+
occurrences.push(interval.next().toDate())
358+
}
359+
360+
return occurrences
361+
}
362+
297363
async schedule (name: string, cron: string, data?: unknown, options: types.ScheduleOptions = {}): Promise<void> {
298364
const { tz = 'UTC', key = '', ...rest } = options
299365

300-
// Expression first, so a bad expression reports as one rather than as a time zone problem. The
301-
// check is deliberately run against UTC rather than the supplied tz: it only works today
302-
// because cron-parser is lazy about an unusable zone, and if that ever changes this call would
303-
// throw the opaque "CronDate: unhandled timestamp" that assertTimezone exists to replace.
304-
CronExpressionParser.parse(cron, { tz: 'UTC', strict: false })
305-
assertTimezone(tz)
366+
assertRecurrence(cron, tz)
306367

307368
Attorney.checkSendArgs([name, data, { ...rest }])
308369
Attorney.assertKey(key)

src/types.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,25 @@ export interface QueueResult extends Queue {
701701

702702
export type ScheduleOptions = SendOptions & { tz?: string, key?: string }
703703

704+
export interface PreviewScheduleOptions {
705+
/**
706+
* Time zone the expression is evaluated in.
707+
* @default 'UTC'
708+
*/
709+
tz?: string;
710+
/**
711+
* Reference point the walk starts from. Occurrences are strictly after it, so passing the last
712+
* occurrence of one page back in yields the next page.
713+
* @default database time (the instance clock plus the cached skew)
714+
*/
715+
from?: Date;
716+
/**
717+
* How many occurrences to return. Must be an integer between 1 and 1000.
718+
* @default 5
719+
*/
720+
count?: number;
721+
}
722+
704723
/**
705724
* How long a worker waits between fetches. The delay before each fetch is chosen by
706725
* precedence — **burst → notify → base**:

test/scheduleReadTest.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { expect } from 'vitest'
2+
import * as helper from './testHelper.ts'
3+
import Timekeeper from '../src/timekeeper.ts'
4+
import { ctx } from './hooks.ts'
5+
6+
describe('getSchedule', function () {
7+
it('should return a single schedule by queue name and key', async function () {
8+
ctx.boss = await helper.start({ ...ctx.bossConfig })
9+
10+
await ctx.boss.schedule(ctx.schema, '* * * * *')
11+
await ctx.boss.schedule(ctx.schema, '0 1 * * *', null, { key: 'a' })
12+
13+
const schedule = await ctx.boss.getSchedule(ctx.schema, 'a')
14+
15+
helper.assertTruthy(schedule)
16+
expect(schedule.name).toBe(ctx.schema)
17+
expect(schedule.key).toBe('a')
18+
expect(schedule.cron).toBe('0 1 * * *')
19+
})
20+
21+
it('should return the default-key schedule when no key is given', async function () {
22+
ctx.boss = await helper.start({ ...ctx.bossConfig })
23+
24+
await ctx.boss.schedule(ctx.schema, '* * * * *')
25+
await ctx.boss.schedule(ctx.schema, '0 1 * * *', null, { key: 'a' })
26+
27+
const schedule = await ctx.boss.getSchedule(ctx.schema)
28+
29+
helper.assertTruthy(schedule)
30+
expect(schedule.key).toBe('')
31+
expect(schedule.cron).toBe('* * * * *')
32+
})
33+
34+
it('should return null when the schedule does not exist', async function () {
35+
ctx.boss = await helper.start({ ...ctx.bossConfig })
36+
37+
await ctx.boss.schedule(ctx.schema, '* * * * *')
38+
39+
expect(await ctx.boss.getSchedule(ctx.schema, 'nope')).toBeNull()
40+
})
41+
42+
it('should reject a missing queue name', async function () {
43+
ctx.boss = await helper.start({ ...ctx.bossConfig })
44+
45+
await expect(async () => {
46+
// @ts-expect-error deliberately calling without the required queue name
47+
await ctx.boss.getSchedule()
48+
}).rejects.toThrow()
49+
})
50+
})
51+
52+
// Pure computation: no database, no running instance.
53+
describe('previewSchedule', function () {
54+
function makeTk (config: object = {}) {
55+
const db = { executeSql: async () => ({ rows: [] }) }
56+
return new Timekeeper(db as any, {} as any, { schema: 'test', ...config } as any)
57+
}
58+
59+
it('should return the next occurrences after the reference date', function () {
60+
const tk = makeTk()
61+
62+
const occurrences = tk.previewSchedule('0 3 * * *', { from: new Date('2026-03-01T00:00:00Z'), count: 3 })
63+
64+
expect(occurrences.map(d => d.toISOString())).toEqual([
65+
'2026-03-01T03:00:00.000Z',
66+
'2026-03-02T03:00:00.000Z',
67+
'2026-03-03T03:00:00.000Z'
68+
])
69+
})
70+
71+
it('should evaluate the expression in the supplied time zone', function () {
72+
const tk = makeTk()
73+
74+
const [first] = tk.previewSchedule('0 3 * * *', {
75+
tz: 'America/Chicago',
76+
from: new Date('2026-03-01T00:00:00Z'),
77+
count: 1
78+
})
79+
80+
// 3am US central on March 1 is still standard time (UTC-6)
81+
expect(first.toISOString()).toBe('2026-03-01T09:00:00.000Z')
82+
})
83+
84+
it('should default to five occurrences', function () {
85+
const tk = makeTk()
86+
87+
expect(tk.previewSchedule('* * * * *').length).toBe(5)
88+
})
89+
90+
it('should exclude the reference date itself so pages do not overlap', function () {
91+
const tk = makeTk()
92+
93+
const from = new Date('2026-03-01T03:00:00Z')
94+
const [first] = tk.previewSchedule('0 3 * * *', { from, count: 1 })
95+
96+
expect(first.toISOString()).toBe('2026-03-02T03:00:00.000Z')
97+
98+
// handing the last occurrence back in yields the next page
99+
const [next] = tk.previewSchedule('0 3 * * *', { from: first, count: 1 })
100+
expect(next.toISOString()).toBe('2026-03-03T03:00:00.000Z')
101+
})
102+
103+
it('should start from database time when no reference date is given', function () {
104+
const tk = makeTk()
105+
106+
// an hour of clock skew has to move the first occurrence of an hourly expression
107+
tk.clockSkew = 60 * 60 * 1000
108+
109+
const [skewed] = tk.previewSchedule('0 * * * *', { count: 1 })
110+
111+
tk.clockSkew = 0
112+
113+
const [local] = tk.previewSchedule('0 * * * *', { count: 1 })
114+
115+
expect(skewed.getTime()).toBeGreaterThan(local.getTime())
116+
})
117+
118+
it('should reject an expression schedule() would reject', function () {
119+
const tk = makeTk()
120+
121+
expect(() => tk.previewSchedule('bogus')).toThrow()
122+
})
123+
124+
it('should reject an unusable time zone', function () {
125+
const tk = makeTk()
126+
127+
expect(() => tk.previewSchedule('* * * * *', { tz: 'Mars/Phobos' })).toThrow(/time zone/)
128+
})
129+
130+
it('should reject a count outside the supported range', function () {
131+
const tk = makeTk()
132+
133+
expect(() => tk.previewSchedule('* * * * *', { count: 0 })).toThrow(/count/)
134+
expect(() => tk.previewSchedule('* * * * *', { count: 1001 })).toThrow(/count/)
135+
expect(() => tk.previewSchedule('* * * * *', { count: 1.5 })).toThrow(/count/)
136+
})
137+
138+
it('should reject an invalid reference date', function () {
139+
const tk = makeTk()
140+
141+
expect(() => tk.previewSchedule('* * * * *', { from: new Date('nope') })).toThrow(/from/)
142+
})
143+
144+
it('should agree with the expression a schedule was stored with', async function () {
145+
ctx.boss = await helper.start({ ...ctx.bossConfig })
146+
147+
await ctx.boss.schedule(ctx.schema, '0 3 * * *', null, { key: 'daily', tz: 'America/Chicago' })
148+
149+
const schedule = await ctx.boss.getSchedule(ctx.schema, 'daily')
150+
151+
helper.assertTruthy(schedule)
152+
153+
const occurrences = ctx.boss.previewSchedule(schedule.cron, {
154+
tz: schedule.timezone,
155+
from: new Date('2026-03-01T00:00:00Z'),
156+
count: 2
157+
})
158+
159+
expect(occurrences.map(d => d.toISOString())).toEqual([
160+
'2026-03-01T09:00:00.000Z',
161+
'2026-03-02T09:00:00.000Z'
162+
])
163+
})
164+
})

0 commit comments

Comments
 (0)