Skip to content

Commit 177ebd5

Browse files
authored
feat: renew in-flight job timestamps via a worker heartbeat (#17)
* feat: renew in-flight job timestamps via a worker heartbeat Long-running jobs could be executed twice from a single enqueue. When a handler runs longer than `stalledThreshold`, the stalled-recovery path re-delivers it to a free slot because nothing ever refreshes `acquiredAt` between claim and completion — so the threshold acts as a hard cap on job runtime rather than a crash-detection window. Add a heartbeat that periodically renews the acquired timestamp of jobs currently in the pool: - `renewJobs(queue, jobIds)` on the Adapter contract, implemented for the Redis (Lua `HSET` over the active hash), Knex (UPDATE ... WHERE status = 'active'), Fake and Sync adapters. Only entries still active are renewed, so a job that was already recovered or finalized is never resurrected by a late heartbeat. - A dedicated worker `setInterval` (~`stalledThreshold / 2`) that renews the in-flight job ids. It must be a separate timer: at full concurrency the process loop blocks on `waitForNextCompletion()` with no idle tick, so the loop is not cycling exactly when long jobs are in flight. - The heartbeat is cleared in `stop()` (after draining, so jobs that are still finishing keep being renewed until they complete) as well as in the process() generator's `finally`, guaranteeing deterministic cleanup whether the worker was driven via start() or processCycle(). `#startHeartbeat` is idempotent so the timer can never leak if the loop is re-entered. "Stalled" now means the worker actually died again, so `stalledThreshold` can stay small without re-delivering healthy long-running jobs. Tests cover renewJobs across all adapters (renew keeps an active job from recovery, never resurrects an already-recovered job, is queue-scoped) and two worker-level tests: a long-running job at full capacity is renewed by the heartbeat and executes exactly once, and the heartbeat stops firing once the worker is stopped. * fix: only renew job leases owned by the calling worker renewJobs previously checked only that a job was still active (HEXISTS), so a slow-but-alive worker whose job had been recovered and re-acquired by another worker would keep renewing the new owner's lease — preventing recovery from re-delivering it if that owner later died. Enforce ownership using the worker id the adapter already holds from setWorkerId (as pop does), without changing the renewJobs signature: - Redis: RENEW_JOBS_SCRIPT skips entries whose workerId doesn't match. - Knex: renew UPDATE gains a WHERE worker_id clause. - Fake/memory adapters: record the worker id on pop and filter on it. Add a cross-worker driver test asserting a worker cannot renew a lease owned by another worker, while the legitimate owner still can.
1 parent 4a576a3 commit 177ebd5

12 files changed

Lines changed: 547 additions & 43 deletions

src/contracts/adapter.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,24 @@ export interface Adapter {
9090
maxStalledCount: number
9191
): Promise<number>
9292

93+
/**
94+
* Renew the acquired timestamp of in-flight jobs (heartbeat).
95+
*
96+
* A worker calls this periodically for the jobs it is actively processing
97+
* so that long-running handlers are not mistaken for stalled jobs and
98+
* re-delivered while they are still running. Only jobs that are still active
99+
* AND still owned by the calling worker (the one set via setWorkerId) are
100+
* renewed; jobs that have already been recovered/completed, or have since
101+
* been re-acquired by another worker, are skipped. This prevents a slow
102+
* worker from resurrecting a job or sabotaging the recovery of the worker
103+
* that legitimately owns it now with a late heartbeat.
104+
*
105+
* @param queue - The queue the jobs belong to
106+
* @param jobIds - The ids of the jobs currently being processed
107+
* @returns Number of jobs whose timestamp was renewed
108+
*/
109+
renewJobs(queue: string, jobIds: string[]): Promise<number>
110+
93111
/**
94112
* Mark a job as completed and remove it from the queue.
95113
*

src/drivers/fake_adapter.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ interface ActiveJob {
2828
job: JobData
2929
acquiredAt: number
3030
queue: string
31+
workerId: string
3132
}
3233

3334
interface DelayedJob {
@@ -81,6 +82,7 @@ export class FakeAdapter implements Adapter {
8182
#pushedJobs: FakeJobRecord[] = []
8283
#dedupIndex = new Map<string, Map<string, DedupEntry>>()
8384
#onDispose?: () => void
85+
#workerId: string = ''
8486

8587
/**
8688
* Set the function to call when the fake is disposed
@@ -94,7 +96,9 @@ export class FakeAdapter implements Adapter {
9496
this.#onDispose?.()
9597
}
9698

97-
setWorkerId(_workerId: string): void {}
99+
setWorkerId(workerId: string): void {
100+
this.#workerId = workerId
101+
}
98102

99103
getPushedJobs(): FakeJobRecord[] {
100104
return [...this.#pushedJobs]
@@ -240,7 +244,7 @@ export class FakeAdapter implements Adapter {
240244
}
241245

242246
const acquiredAt = Date.now()
243-
this.#activeJobs.set(job.id, { job, acquiredAt, queue })
247+
this.#activeJobs.set(job.id, { job, acquiredAt, queue, workerId: this.#workerId })
244248

245249
return { ...job, acquiredAt }
246250
}
@@ -345,6 +349,21 @@ export class FakeAdapter implements Adapter {
345349
return recovered
346350
}
347351

352+
async renewJobs(queue: string, jobIds: string[]): Promise<number> {
353+
const now = Date.now()
354+
let renewed = 0
355+
356+
for (const jobId of jobIds) {
357+
const active = this.#activeJobs.get(jobId)
358+
if (active && active.queue === queue && active.workerId === this.#workerId) {
359+
active.acquiredAt = now
360+
renewed++
361+
}
362+
}
363+
364+
return renewed
365+
}
366+
348367
async getJob(jobId: string, queue: string): Promise<JobRecord | null> {
349368
const active = this.#activeJobs.get(jobId)
350369
if (active && active.queue === queue) {

src/drivers/knex_adapter.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,26 @@ export class KnexAdapter implements Adapter {
630630
})
631631
}
632632

633+
async renewJobs(queue: string, jobIds: string[]): Promise<number> {
634+
if (jobIds.length === 0) {
635+
return 0
636+
}
637+
638+
const now = Date.now()
639+
640+
// Only renew jobs that are still active AND still owned by this worker; a
641+
// job that was already recovered, finalized, or re-acquired by another
642+
// worker will not match and is therefore never resurrected.
643+
const renewed = await this.#connection(this.#jobsTable)
644+
.where('queue', queue)
645+
.where('status', 'active')
646+
.where('worker_id', this.#workerId)
647+
.whereIn('id', jobIds)
648+
.update({ acquired_at: now })
649+
650+
return renewed
651+
}
652+
633653
async upsertSchedule(config: ScheduleConfig): Promise<string> {
634654
const id = config.id ?? randomUUID()
635655

src/drivers/redis_adapter.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
PUSH_JOB_SCRIPT,
2525
RECOVER_STALLED_JOBS_SCRIPT,
2626
REMOVE_JOB_SCRIPT,
27+
RENEW_JOBS_SCRIPT,
2728
RETRY_JOB_SCRIPT,
2829
} from './redis_scripts.js'
2930

@@ -404,6 +405,26 @@ export class RedisAdapter implements Adapter {
404405
return recovered as number
405406
}
406407

408+
async renewJobs(queue: string, jobIds: string[]): Promise<number> {
409+
if (jobIds.length === 0) {
410+
return 0
411+
}
412+
413+
const keys = this.#getKeys(queue)
414+
const now = Date.now()
415+
416+
const renewed = await this.#connection.eval(
417+
RENEW_JOBS_SCRIPT,
418+
1,
419+
keys.active,
420+
now.toString(),
421+
this.#workerId,
422+
...jobIds
423+
)
424+
425+
return renewed as number
426+
}
427+
407428
async upsertSchedule(config: ScheduleConfig): Promise<string> {
408429
const id = config.id ?? randomUUID()
409430
const now = Date.now()

src/drivers/redis_scripts.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,37 @@ ${REDIS_JOB_STORAGE_LUA}
401401
return recovered
402402
`
403403

404+
/**
405+
* Lua script for renewing the acquired timestamp of in-flight jobs (heartbeat).
406+
* Only entries still present in the active hash AND still owned by the calling
407+
* worker are renewed, so a job that was already recovered, finalized, or
408+
* re-acquired by another worker is never resurrected by a late heartbeat.
409+
* Preserves the existing worker info, updating only acquiredAt.
410+
* Returns the number of jobs renewed.
411+
*/
412+
export const RENEW_JOBS_SCRIPT = `
413+
local active_key = KEYS[1]
414+
local now = tonumber(ARGV[1])
415+
local worker_id = ARGV[2]
416+
417+
local renewed = 0
418+
for i = 3, #ARGV do
419+
local job_id = ARGV[i]
420+
local active_data = redis.call('HGET', active_key, job_id)
421+
if active_data then
422+
local active = cjson.decode(active_data)
423+
-- Only the worker that currently owns the lease may renew it.
424+
if active.workerId == worker_id then
425+
active.acquiredAt = now
426+
redis.call('HSET', active_key, job_id, cjson.encode(active))
427+
renewed = renewed + 1
428+
end
429+
end
430+
end
431+
432+
return renewed
433+
`
434+
404435
/**
405436
* Lua script for getting a job record with its status.
406437
*/

src/drivers/sync_adapter.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ export class SyncAdapter implements Adapter {
110110
return Promise.resolve(0)
111111
}
112112

113+
renewJobs(_queue: string, _jobIds: string[]): Promise<number> {
114+
// SyncAdapter executes jobs immediately - there is nothing to renew
115+
return Promise.resolve(0)
116+
}
117+
113118
getJob(_jobId: string, _queue: string): Promise<null> {
114119
return Promise.resolve(null)
115120
}

src/job_pool.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,30 @@ export class JobPool {
6767
this.#activeJobs.set(job.id, { promise, job, queue })
6868
}
6969

70+
/**
71+
* Get the ids of all currently running jobs, grouped by the queue they
72+
* came from.
73+
*
74+
* Used by the worker heartbeat to renew the acquired timestamp of in-flight
75+
* jobs so long-running handlers are not mistaken for stalled jobs.
76+
*
77+
* @returns A map of queue name to the job ids running for that queue
78+
*/
79+
activeJobIdsByQueue(): Map<string, string[]> {
80+
const byQueue = new Map<string, string[]>()
81+
82+
for (const { job, queue } of this.#activeJobs.values()) {
83+
const ids = byQueue.get(queue)
84+
if (ids) {
85+
ids.push(job.id)
86+
} else {
87+
byQueue.set(queue, [job.id])
88+
}
89+
}
90+
91+
return byQueue
92+
}
93+
7094
/**
7195
* Wait for the next job to complete and return it.
7296
*

0 commit comments

Comments
 (0)