Skip to content

Commit 6724b4c

Browse files
committed
fix(schedule): address review of lastJobId
proxy: scheduleSchema gains createdOn, updatedOn and lastJobId, which types.Schedule now requires. Without them the workspace failed to typecheck and the new fields were missing from the OpenAPI contract. timekeeper: reduce a batch to one occurrence per (name, key) before the UPDATE. Postgres leaves the choice of source row unspecified when several join one target, so a worker that picked up two minute buckets for one schedule could store the older job id. timekeeper: report send-it failures through a helper that cannot throw. An 'error' event with no listener is a throw by Node's contract, and index.ts re-promotes this one onto the PgBoss instance, so the report could escape the handler, fail the send-it job and replay the batch. The annotation failure now names the schedules it covers, which a bare driver error did not distinguish from a forwarding failure. timekeeper: the send-it singleton key is JSON.stringify([name, key]) rather than `${name}__${key}`. Underscores are legal in both a queue name and a schedule key, so ('report_', 'daily') and ('report', '_daily') collided and the 60s window dropped one of them. A mixed-version deployment can fire a schedule twice in the minute a rollout straddles. plans, timekeeper: one named FiredSchedule type, with a camelCase jobId quoted in the recordset like every other JSON payload crossing to SQL. docs, types: null means "no run recorded", not "never fired". The annotation is a separate statement from the send, and the column is new, so a long-running schedule reads null until its first fire after an upgrade. tests: the database tests poll instead of sleeping 4s and check attribution through each job's payload, so an every-minute schedule firing twice inside the window no longer fails them. The handler unit tests move beside the existing onSendIt coverage in scheduleTest.ts and share its fixture, and gain a partly-failed batch, a batch repeating one schedule, and a missing error listener. package.json: version 12.30.0, matching the release the migration declares.
1 parent 755e24f commit 6724b4c

8 files changed

Lines changed: 288 additions & 152 deletions

File tree

docs/api/scheduling.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ Each schedule carries the following properties.
101101
| `options` | `send()` options applied to each job |
102102
| `createdOn` | When the schedule was first stored |
103103
| `updatedOn` | When the definition was last changed |
104-
| `lastJobId` | Id of the job the schedule most recently created, or `null` if it has not fired yet |
104+
| `lastJobId` | Id of the job the schedule most recently created |
105105

106106
`lastJobId` connects a schedule to its last run, so a queue's history can be inspected from the
107107
schedule that produced it.
@@ -121,6 +121,12 @@ if (schedule.lastJobId) {
121121
eventually deleted, so it may no longer exist. Re-running `schedule()` for the same `(name, key)`
122122
updates the definition and leaves `lastJobId` alone; `unschedule()` removes the row entirely.
123123
124+
It is recorded on a best-effort basis, in a separate statement once the job has been created, so
125+
`null` does not prove a schedule never fired. A schedule that last fired before the upgrade adding
126+
the column reads `null` until its next run, and so does one whose annotating statement failed after
127+
its job was already created. Treat it as a pointer to the last run pg-boss observed, not as a
128+
complete firing record: the queue's job history is the authority on what actually ran.
129+
124130
### `getSchedules(name)`
125131
126132
Returns all scheduled jobs by queue name.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "pg-boss",
3-
"version": "12.29.0",
3+
"version": "12.30.0",
44
"description": "Queueing jobs in Postgres from Node.js like a boss",
55
"type": "module",
66
"main": "./dist/index.js",

packages/proxy/src/contracts.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,9 @@ export const scheduleSchema = z.object({
249249
timezone: z.string(),
250250
data: jsonRecordSchema.optional(),
251251
options: sendOptionsSchema.optional(),
252+
createdOn: z.iso.datetime().transform((val) => new Date(val)),
253+
updatedOn: z.iso.datetime().transform((val) => new Date(val)),
254+
lastJobId: z.string().nullable(),
252255
}) satisfies z.ZodType<types.HttpSchedule>
253256

254257
export const bamStatusSummarySchema = z.object({

src/plans.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -948,13 +948,17 @@ export function getSchedulesByQueueAndKey (schema: string) {
948948
// job exists, one statement per batch rather than one per schedule, with the (name, key, job id)
949949
// triples carried in a JSON recordset.
950950
//
951+
// The caller must supply at most one record per (name, key): postgres leaves it unspecified which
952+
// source row an UPDATE ... FROM uses when several join the same target, so duplicates would make
953+
// the resulting last_job_id arbitrary rather than latest.
954+
//
951955
// updated_on is deliberately left alone: it tracks edits to the definition, and a firing schedule
952956
// has not been edited.
953957
export function setScheduleLastJobIds (schema: string) {
954958
return `
955959
UPDATE ${schema}.schedule s
956-
SET last_job_id = x.job_id
957-
FROM json_to_recordset($1::json) AS x (name text, key text, job_id uuid)
960+
SET last_job_id = x."jobId"
961+
FROM json_to_recordset($1::json) AS x (name text, key text, "jobId" uuid)
958962
WHERE s.name = x.name
959963
AND COALESCE(s.key, '') = x.key
960964
`

src/timekeeper.ts

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ const WARNING_TYPES = {
3434
// instance older than 12.30.0, which is why the handler treats it as optional rather than required.
3535
type ScheduledRequest = types.Request & { key?: string }
3636

37+
// One schedule occurrence that produced a job, as handed to plans.setScheduleLastJobIds. camelCase
38+
// to match the recordset column list the plan quotes, which is how every other JSON payload crossing
39+
// into SQL is shaped.
40+
type FiredSchedule = { name: string, key: string, jobId: string }
41+
3742
/**
3843
* Asserts that `tz` is a time zone cron evaluation can actually use.
3944
*
@@ -248,7 +253,12 @@ class Timekeeper extends EventEmitter implements types.EventsMixin {
248253
}
249254

250255
if (due) {
251-
scheduled.push({ data: { name, key, data, options }, singletonKey: `${name}__${key}`, singletonSeconds: 60 })
256+
// JSON rather than `${name}__${key}`: underscores are legal in both a queue name and a
257+
// schedule key, so the concatenation collapsed ('report_', 'daily') and ('report', '_daily')
258+
// onto one key and the 60s singleton then dropped whichever occurrence lost the race. An
259+
// instance still on the old format writes the old key, so a mixed-version deployment can
260+
// fire a schedule twice in the minute the rollout straddles.
261+
scheduled.push({ data: { name, key, data, options }, singletonKey: JSON.stringify([name, key]), singletonSeconds: 60 })
252262
}
253263
}
254264

@@ -271,6 +281,18 @@ class Timekeeper extends EventEmitter implements types.EventsMixin {
271281
return prevDiff < 60
272282
}
273283

284+
// Reports a problem the send-it handler must survive. Node treats an `error` event with no
285+
// listener as a throw, and index.ts re-promotes this one onto the PgBoss instance, so a plain
286+
// emit() here could escape the handler, fail the send-it job and replay the whole batch, sending
287+
// every occurrence in it a second time.
288+
private reportSendItError (err: unknown): void {
289+
try {
290+
this.emit(this.events.error, err)
291+
} catch {
292+
// nothing left to report it to
293+
}
294+
}
295+
274296
private async onSendIt (jobs: types.Job<ScheduledRequest>[]): Promise<void> {
275297
// async so a malformed payload rejects its own settlement rather than throwing synchronously
276298
// out of map() and taking the whole batch with it
@@ -279,12 +301,15 @@ class Timekeeper extends EventEmitter implements types.EventsMixin {
279301
return await this.manager.send(request)
280302
}))
281303

282-
const fired: { name: string, key: string, job_id: string }[] = []
304+
// Keyed on (name, key) so a batch that spans two minute buckets for the same schedule resolves
305+
// to its latest occurrence. Feeding both to the UPDATE would let postgres pick either source
306+
// row, and last_job_id could end up naming the older job.
307+
const fired = new Map<string, FiredSchedule>()
283308

284309
// Surface any failed forward so a lost cron tick isn't silent
285310
for (const [index, result] of results.entries()) {
286311
if (result.status === 'rejected') {
287-
this.emit(this.events.error, result.reason)
312+
this.reportSendItError(result.reason)
288313
continue
289314
}
290315

@@ -293,23 +318,29 @@ class Timekeeper extends EventEmitter implements types.EventsMixin {
293318
// send() resolves null when a throttle or queue policy dropped the job, so there is nothing
294319
// to point last_job_id at. `key` is absent on a payload written by an older instance.
295320
if (result.value && key !== undefined) {
296-
fired.push({ name, key, job_id: result.value })
321+
fired.set(JSON.stringify([name, key]), { name, key, jobId: result.value })
297322
}
298323
}
299324

300-
if (fired.length > 0) {
301-
await this.setLastJobIds(fired)
325+
if (fired.size > 0) {
326+
await this.setLastJobIds([...fired.values()])
302327
}
303328
}
304329

305330
// Best effort: the schedule fired and the job exists, so failing to annotate the schedule row
306331
// must not fail the send-it job and replay the occurrence. Reported through `error` instead.
307-
private async setLastJobIds (fired: { name: string, key: string, job_id: string }[]): Promise<void> {
332+
private async setLastJobIds (fired: FiredSchedule[]): Promise<void> {
308333
try {
309334
const sql = plans.setScheduleLastJobIds(this.config.schema)
310335
await this.db.executeSql(sql, [JSON.stringify(fired)])
311336
} catch (err) {
312-
this.emit(this.events.error, err)
337+
// Named, because a bare driver error here is indistinguishable from the forwarding failures
338+
// emitted above, and the two call for different responses: this one leaves the jobs created
339+
// and only the bookkeeping behind.
340+
const schedules = fired.map(({ name, key }) => `"${name}" (key "${key}")`).join(', ')
341+
const message = `Warning: schedules fired but their last job id could not be recorded for ${schedules}: ${(err as Error).message}`
342+
343+
this.reportSendItError(new Error(message, { cause: err }))
313344
}
314345
}
315346

src/types.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -903,8 +903,12 @@ export interface Schedule {
903903
createdOn: Date;
904904
updatedOn: Date;
905905
/**
906-
* Id of the job this schedule most recently created, or `null` if it has not fired since the
907-
* column was added.
906+
* Id of the job this schedule most recently created.
907+
*
908+
* Recorded on a best-effort basis, in a separate statement once the job exists, so `null` does
909+
* not mean the schedule never fired. It also reads `null` for a schedule that last fired before
910+
* the upgrade that added the column, and for one whose annotating statement lost its connection
911+
* between creating the job and recording it.
908912
*
909913
* Not a foreign key: the job is subject to the queue's retention policy and will eventually be
910914
* deleted, so an id here does not guarantee the job still exists.

0 commit comments

Comments
 (0)