Skip to content

Commit 0cc98f2

Browse files
committed
fix(worker): queue job completions
1 parent 96c0a89 commit 0cc98f2

4 files changed

Lines changed: 358 additions & 34 deletions

File tree

src/job_pool.ts

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { AcquiredJob } from './contracts/adapter.js'
33
/**
44
* Entry representing an active job in the pool.
55
*/
6-
interface PoolEntry {
6+
export interface PoolEntry {
77
/** Promise that resolves when the job completes */
88
promise: Promise<void>
99
/** The acquired job data */
@@ -12,6 +12,11 @@ interface PoolEntry {
1212
queue: string
1313
}
1414

15+
interface CompletedEntry {
16+
id: string
17+
entry: PoolEntry
18+
}
19+
1520
/**
1621
* Manages concurrent job execution with a fixed pool size.
1722
*
@@ -31,6 +36,9 @@ interface PoolEntry {
3136
*/
3237
export class JobPool {
3338
#activeJobs = new Map<string, PoolEntry>()
39+
#completedEntries: CompletedEntry[] = []
40+
#completedHead = 0
41+
#completionAvailable?: PromiseWithResolvers<void>
3442

3543
/** Number of currently running jobs */
3644
get size() {
@@ -69,8 +77,12 @@ export class JobPool {
6977
* @param promise - Promise that resolves when the job completes
7078
*/
7179
add(job: AcquiredJob, queue: string, promise: Promise<void>) {
72-
void promise.catch(() => {})
73-
this.#activeJobs.set(job.id, { promise, job, queue })
80+
const entry = { promise, job, queue }
81+
this.#activeJobs.set(job.id, entry)
82+
void promise.then(
83+
() => this.#enqueueCompletion(job.id, entry),
84+
() => this.#enqueueCompletion(job.id, entry)
85+
)
7486
}
7587

7688
/**
@@ -100,27 +112,26 @@ export class JobPool {
100112
/**
101113
* Wait for the next job to complete and return it.
102114
*
103-
* Uses `Promise.race()` internally, so the fastest job wins.
104-
* The completed job is removed from the pool.
115+
* Completions are queued in settlement order and remain available until a
116+
* consumer asks for them. The completed job is removed from the pool.
105117
*
106118
* @returns The first job to complete (success or failure)
107119
*/
108120
async waitForNextCompletion(): Promise<PoolEntry> {
109-
const completedJobId = await Promise.race(
110-
[...this.#activeJobs.entries()].map(async ([id, { promise }]) => {
111-
try {
112-
await promise
113-
} catch {
114-
// Errors are handled in Worker#execute
115-
}
116-
return id
117-
})
118-
)
121+
while (true) {
122+
while (this.#completedHead >= this.#completedEntries.length) {
123+
this.#completionAvailable ??= Promise.withResolvers<void>()
124+
await this.#completionAvailable.promise
125+
}
126+
127+
const completed = this.#completedEntries[this.#completedHead++]!
128+
this.#compactCompletedJobs()
119129

120-
const completed = this.#activeJobs.get(completedJobId)!
121-
this.#activeJobs.delete(completedJobId)
130+
if (this.#activeJobs.get(completed.id) !== completed.entry) continue
122131

123-
return completed
132+
this.#activeJobs.delete(completed.id)
133+
return completed.entry
134+
}
124135
}
125136

126137
/**
@@ -140,5 +151,24 @@ export class JobPool {
140151

141152
await Promise.all(promises)
142153
this.#activeJobs.clear()
154+
this.#completedEntries = []
155+
this.#completedHead = 0
156+
}
157+
158+
#enqueueCompletion(jobId: string, entry: PoolEntry): void {
159+
if (this.#activeJobs.get(jobId) !== entry) return
160+
161+
this.#completedEntries.push({ id: jobId, entry })
162+
this.#completionAvailable?.resolve()
163+
this.#completionAvailable = undefined
164+
}
165+
166+
#compactCompletedJobs(): void {
167+
if (this.#completedHead < 1_024 || this.#completedHead * 2 < this.#completedEntries.length) {
168+
return
169+
}
170+
171+
this.#completedEntries = this.#completedEntries.slice(this.#completedHead)
172+
this.#completedHead = 0
143173
}
144174
}

src/worker_session.ts

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { setTimeout } from 'node:timers/promises'
22
import debug from './debug.js'
33
import * as errors from './exceptions.js'
44
import { parse } from './utils.js'
5-
import { JobPool } from './job_pool.js'
5+
import { JobPool, type PoolEntry } from './job_pool.js'
66
import { WorkerHeartbeat, type HeartbeatOperationWrapper } from './worker_heartbeat.js'
77
import { Locator } from './locator.js'
88
import type { SingleJobDispatchRequest } from './job_dispatch_runtime.js'
@@ -71,6 +71,9 @@ export class WorkerSession {
7171
#stopOperation?: Promise<void>
7272
#pool = new JobPool()
7373
#fillOperation?: Promise<PoolFillResult>
74+
#completionOperation?: Promise<PoolEntry>
75+
#completedEntry?: PoolEntry
76+
#completionDelayController?: AbortController
7477
#lastStalledCheck = 0
7578
#delayController?: AbortController
7679

@@ -308,19 +311,12 @@ export class WorkerSession {
308311
}
309312

310313
const hasCapacity = this.#pool.hasCapacity(this.#settings.concurrency)
311-
const result = await Promise.race([
312-
this.#pool
313-
.waitForNextCompletion()
314-
.then((completed) => ({ kind: 'completed' as const, completed })),
315-
...(hasCapacity
316-
? [setTimeout(this.#settings.idleDelay).then(() => ({ kind: 'tick' as const }))]
317-
: []),
318-
])
314+
const completed = await this.#waitForCompletion(hasCapacity)
319315

320316
if (!this.#running) break
321-
if (result.kind === 'tick') continue
317+
if (!completed) continue
322318

323-
yield { type: 'completed', queue: result.completed.queue, job: result.completed.job }
319+
yield { type: 'completed', queue: completed.queue, job: completed.job }
324320
} catch (error) {
325321
if (!this.#running) break
326322

@@ -338,6 +334,56 @@ export class WorkerSession {
338334
}
339335
}
340336

337+
async #waitForCompletion(hasCapacity: boolean): Promise<PoolEntry | null> {
338+
const completionOperation = this.#getCompletionOperation()
339+
340+
if (!hasCapacity || this.#completedEntry) {
341+
return this.#consumeCompletion(completionOperation)
342+
}
343+
344+
const controller = new AbortController()
345+
this.#completionDelayController = controller
346+
347+
try {
348+
await setTimeout(this.#settings.idleDelay, undefined, { signal: controller.signal })
349+
} catch (error) {
350+
if (!controller.signal.aborted) {
351+
throw error
352+
}
353+
} finally {
354+
if (this.#completionDelayController === controller) {
355+
this.#completionDelayController = undefined
356+
}
357+
}
358+
359+
return this.#completedEntry ? this.#consumeCompletion(completionOperation) : null
360+
}
361+
362+
#getCompletionOperation(): Promise<PoolEntry> {
363+
if (this.#completionOperation) return this.#completionOperation
364+
365+
const completionOperation = this.#pool.waitForNextCompletion().then((completed) => {
366+
if (this.#completionOperation === completionOperation) {
367+
this.#completedEntry = completed
368+
this.#completionDelayController?.abort()
369+
}
370+
return completed
371+
})
372+
this.#completionOperation = completionOperation
373+
return completionOperation
374+
}
375+
376+
async #consumeCompletion(completionOperation: Promise<PoolEntry>): Promise<PoolEntry> {
377+
const completed = this.#completedEntry ?? (await completionOperation)
378+
379+
if (this.#completionOperation === completionOperation) {
380+
this.#completionOperation = undefined
381+
this.#completedEntry = undefined
382+
}
383+
384+
return completed
385+
}
386+
341387
async #fillPool(): Promise<PoolFillResult> {
342388
const slotsAvailable = this.#settings.concurrency - this.#pool.size
343389

tests/job_pool.spec.ts

Lines changed: 133 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,13 @@ test.group('JobPool', () => {
3838
cleanup,
3939
}) => {
4040
const execution = Promise.withResolvers<void>()
41-
const originalCatch = execution.promise.catch.bind(execution.promise)
41+
const originalThen = execution.promise.then.bind(execution.promise)
4242
let observed = false
4343

44-
execution.promise.catch = ((...args: Parameters<Promise<void>['catch']>) => {
45-
observed = true
46-
return originalCatch(...args)
47-
}) as Promise<void>['catch']
44+
execution.promise.then = ((onFulfilled, onRejected) => {
45+
observed = onRejected !== undefined
46+
return originalThen(onFulfilled, onRejected)
47+
}) as Promise<void>['then']
4848

4949
const pool = new JobPool()
5050
cleanup(async () => {
@@ -57,6 +57,41 @@ test.group('JobPool', () => {
5757
assert.isTrue(observed)
5858
})
5959

60+
test('attaches one settlement handler while a completion wait remains pending', async ({
61+
assert,
62+
cleanup,
63+
}) => {
64+
const execution = Promise.withResolvers<void>()
65+
let attachments = 0
66+
const observablePromise = {
67+
then(onFulfilled, onRejected) {
68+
attachments++
69+
return execution.promise.then(onFulfilled, onRejected)
70+
},
71+
catch(onRejected) {
72+
return this.then(undefined, onRejected)
73+
},
74+
} as Promise<void>
75+
76+
const pool = new JobPool()
77+
cleanup(async () => {
78+
execution.resolve()
79+
await pool.drain()
80+
})
81+
82+
pool.add(createJob('observed-once'), 'default', observablePromise)
83+
const completion = pool.waitForNextCompletion()
84+
85+
for (let tick = 0; tick < 10; tick++) {
86+
await setTimeout(0)
87+
}
88+
89+
assert.equal(attachments, 1)
90+
91+
execution.resolve()
92+
await completion
93+
})
94+
6095
test('should check capacity correctly', ({ assert }) => {
6196
const pool = new JobPool()
6297

@@ -85,6 +120,81 @@ test.group('JobPool', () => {
85120
assert.equal(pool.size, 1)
86121
})
87122

123+
test('returns a completion queued before waiting begins', async ({ assert }) => {
124+
const pool = new JobPool()
125+
pool.add(createJob('already-completed'), 'default', Promise.resolve())
126+
127+
await setTimeout(0)
128+
129+
const completed = await pool.waitForNextCompletion()
130+
assert.equal(completed.job.id, 'already-completed')
131+
assert.isTrue(pool.isEmpty())
132+
})
133+
134+
test('returns a newer job that settles after waiting begins', async ({ assert }) => {
135+
const pool = new JobPool()
136+
const longExecution = Promise.withResolvers<void>()
137+
pool.add(createJob('long-running'), 'default', longExecution.promise)
138+
139+
const completion = pool.waitForNextCompletion()
140+
pool.add(createJob('newer-fast-job'), 'default', Promise.resolve())
141+
142+
assert.equal((await completion).job.id, 'newer-fast-job')
143+
longExecution.resolve()
144+
await pool.drain()
145+
})
146+
147+
test('ignores a stale completion after an active job id is replaced', async ({ assert }) => {
148+
const pool = new JobPool()
149+
const staleExecution = Promise.withResolvers<void>()
150+
const currentExecution = Promise.withResolvers<void>()
151+
152+
pool.add(createJob('reused-id'), 'stale', staleExecution.promise)
153+
staleExecution.resolve()
154+
await setTimeout(0)
155+
pool.add(createJob('reused-id'), 'current', currentExecution.promise)
156+
157+
let delivered = false
158+
const completion = pool.waitForNextCompletion().then((entry) => {
159+
delivered = true
160+
return entry
161+
})
162+
163+
await setTimeout(0)
164+
assert.isFalse(delivered)
165+
166+
currentExecution.resolve()
167+
assert.equal((await completion).queue, 'current')
168+
assert.isTrue(pool.isEmpty())
169+
})
170+
171+
test('returns every job once in settlement order', async ({ assert }) => {
172+
const pool = new JobPool()
173+
const first = Promise.withResolvers<void>()
174+
const second = Promise.withResolvers<void>()
175+
const third = Promise.withResolvers<void>()
176+
177+
pool.add(createJob('first'), 'default', first.promise)
178+
pool.add(createJob('second'), 'default', second.promise)
179+
pool.add(createJob('third'), 'default', third.promise)
180+
181+
second.resolve()
182+
await setTimeout(0)
183+
first.resolve()
184+
await setTimeout(0)
185+
third.resolve()
186+
187+
assert.deepEqual(
188+
[
189+
(await pool.waitForNextCompletion()).job.id,
190+
(await pool.waitForNextCompletion()).job.id,
191+
(await pool.waitForNextCompletion()).job.id,
192+
],
193+
['second', 'first', 'third']
194+
)
195+
assert.isTrue(pool.isEmpty())
196+
})
197+
88198
test('should remove job from pool after completion', async ({ assert }) => {
89199
const pool = new JobPool()
90200

@@ -173,4 +283,22 @@ test.group('JobPool', () => {
173283

174284
assert.isTrue(pool.isEmpty())
175285
})
286+
287+
test('drain clears completions that settled before they were consumed', async ({ assert }) => {
288+
const pool = new JobPool()
289+
const running = Promise.withResolvers<void>()
290+
291+
pool.add(createJob('settled'), 'default', Promise.resolve())
292+
pool.add(createJob('running'), 'default', running.promise)
293+
await setTimeout(0)
294+
295+
const drain = pool.drain()
296+
running.resolve()
297+
await drain
298+
299+
assert.isTrue(pool.isEmpty())
300+
301+
pool.add(createJob('after-drain'), 'default', Promise.resolve())
302+
assert.equal((await pool.waitForNextCompletion()).job.id, 'after-drain')
303+
})
176304
})

0 commit comments

Comments
 (0)