Skip to content

Commit 2a99ed1

Browse files
committed
fix!: reject a second Worker started in the same process
Each Worker is meant to run in its own process. Starting a second one while another ran in the process called QueueManager.init() again, which destroyed the Adapters of the first Worker: it then produced error cycles forever, without saying why. worker.start() now throws the new E_WORKER_ALREADY_RUNNING error when another Worker holds the process, before init() can touch the QueueManager. A Worker holds the process from start() until its stop() completes, after its last job is finalized; start() releases it only if initialization fails before any session exists, and a restart requested during stop() reserves it again once that stop completes. processCycle() and process(), low-level tools used by tests, are not guarded. BREAKING CHANGE: a second worker.start() while another Worker runs in the same process rejects with E_WORKER_ALREADY_RUNNING.
1 parent 0022f0e commit 2a99ed1

5 files changed

Lines changed: 248 additions & 10 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# One Worker per Process
2+
3+
## Bug Fix
4+
5+
Starting a second Worker in a process where another one was running re-initialized the
6+
`QueueManager`, which destroyed the Adapters of the first Worker. The first Worker then produced
7+
`error` cycles forever, without saying why.
8+
9+
`worker.start()` now throws the new `E_WORKER_ALREADY_RUNNING` error when another Worker is running
10+
in the process, before touching the `QueueManager`. The first Worker keeps working. A Worker holds
11+
the process from `start()` until its `stop()` completes, after its last job is finalized; then
12+
another Worker can start.
13+
14+
Run one Worker per process. A Worker listens on one Adapter: to process the queues of several
15+
Adapters, start one process per Adapter.
16+
17+
## Breaking Changes
18+
19+
A second `worker.start()` while another Worker runs in the same process now rejects with
20+
`E_WORKER_ALREADY_RUNNING` instead of starting.

‎README.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -761,8 +761,8 @@ Schedules at every boot:
761761
- it keeps its next run, unless its timing changes (cron expression, interval, timezone, `from`,
762762
`to`, or `limit`). A payload change alone does not move the next run.
763763

764-
A Schedule and every Job it dispatches stay on the same Adapter. Start a Worker for each Adapter
765-
that owns Schedules.
764+
A Schedule and every Job it dispatches stay on the same Adapter. Start a Worker, in its own
765+
process, for each Adapter that owns Schedules.
766766

767767
</details>
768768

@@ -818,6 +818,11 @@ const config = {
818818
}
819819
```
820820

821+
Run one Worker per process. A Worker listens on one Adapter, so to process the queues of several
822+
Adapters, start one process per Adapter. Starting a second Worker while another one runs in the
823+
same process throws `E_WORKER_ALREADY_RUNNING`: initializing it would destroy the Adapters of the
824+
first one. A Worker holds the process until its `stop()` completes.
825+
821826
A job is stalled when its worker stops renewing it for longer than `stalledThreshold`, usually
822827
because the process crashed. A stalled job goes back to the queue up to `maxStalledCount` times.
823828
After that, the worker that detects it fails it like any other permanent failure: `failed()`

‎src/exceptions.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ export const E_INVALID_TIMEOUT = createError<[timeout: string]>(
5656
500
5757
)
5858

59+
export const E_WORKER_ALREADY_RUNNING = createError(
60+
'Another Worker is already running in this process. Run one Worker per process.',
61+
'E_WORKER_ALREADY_RUNNING',
62+
500
63+
)
64+
5965
export const E_QUEUE_NOT_INITIALIZED = createError(
6066
'QueueManager is not initialized. Call QueueManager.init() before using it.',
6167
'E_QUEUE_NOT_INITIALIZED',

‎src/worker.ts‎

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ export class Worker {
6060
#session?: WorkerSession
6161
#stopOperation?: Promise<void>
6262
#shutdownGeneration = 0
63+
64+
/** The Worker whose `start()` is running in this process. */
65+
static #running?: Worker
6366
#shutdownHandler?: () => Promise<void>
6467

6568
/** Unique identifier for this worker instance. */
@@ -142,23 +145,55 @@ export class Worker {
142145
* This method blocks until the Worker is stopped. Jobs are processed
143146
* concurrently up to the configured concurrency limit.
144147
*
148+
* Run one Worker per process: starting a second one while another runs
149+
* throws, since initializing it would destroy the Adapters of the first.
150+
* A Worker holds the process from `start()` until its `stop()` completes.
151+
*
145152
* @param queues - Queue names to process in priority order
146153
*/
147154
async start(queues: string[] = ['default']): Promise<void> {
148-
while (this.#stopOperation) {
149-
await this.#stopOperation
150-
}
155+
this.#reserveProcess()
151156

152-
const shutdownGeneration = this.#shutdownGeneration
153-
await this.init()
157+
let session: WorkerSession
154158

155-
if (shutdownGeneration !== this.#shutdownGeneration) return
159+
try {
160+
while (this.#stopOperation) {
161+
await this.#stopOperation
162+
}
163+
164+
// A stop that completed meanwhile released the process.
165+
this.#reserveProcess()
166+
167+
const shutdownGeneration = this.#shutdownGeneration
168+
await this.init()
169+
170+
if (shutdownGeneration !== this.#shutdownGeneration) return
171+
172+
session = this.#useSession(queues)
173+
this.#setupGracefulShutdown()
174+
} catch (error) {
175+
// Nothing runs for this Worker yet: another one can start.
176+
if (!this.#session) this.#releaseProcess()
177+
throw error
178+
}
156179

157-
const session = this.#useSession(queues)
158-
this.#setupGracefulShutdown()
159180
await session.start()
160181
}
161182

183+
#reserveProcess(): void {
184+
if (Worker.#running && Worker.#running !== this) {
185+
throw new errors.E_WORKER_ALREADY_RUNNING()
186+
}
187+
188+
Worker.#running = this
189+
}
190+
191+
#releaseProcess(): void {
192+
if (Worker.#running === this) {
193+
Worker.#running = undefined
194+
}
195+
}
196+
162197
/**
163198
* Stop the active session and wait until it reaches quiescence.
164199
*
@@ -204,6 +239,9 @@ export class Worker {
204239
}
205240

206241
this.#removeShutdownHandlers()
242+
243+
// Every job has been finalized: the Adapters are no longer needed.
244+
this.#releaseProcess()
207245
}
208246

209247
/**

‎tests/worker.spec.ts‎

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2850,3 +2850,172 @@ test.group('Worker | Scheduler Integration', () => {
28502850
assert.includeMembers(executedJobs, ['scheduled', 'regular'])
28512851
})
28522852
})
2853+
2854+
test.group('Worker | one per process', (group) => {
2855+
group.each.teardown(() => Locator.clear())
2856+
2857+
const config = (adapter: MemoryAdapter) => ({
2858+
default: 'memory',
2859+
adapters: { memory: () => adapter },
2860+
logger: new MemoryLogger(),
2861+
worker: { idleDelay: 5, gracefulShutdown: false },
2862+
})
2863+
2864+
async function waitFor(condition: () => boolean) {
2865+
for (let i = 0; i < 200 && !condition(); i++) {
2866+
await setTimeout(5)
2867+
}
2868+
}
2869+
2870+
test('start() rejects a second Worker and keeps the first one working', async ({
2871+
assert,
2872+
cleanup,
2873+
}) => {
2874+
let executed = 0
2875+
2876+
class CountedJob extends Job {
2877+
async execute() {
2878+
executed++
2879+
}
2880+
}
2881+
2882+
Locator.register('CountedJob', CountedJob)
2883+
const adapter = new MemoryAdapter()
2884+
const first = new Worker(config(adapter))
2885+
const second = new Worker(config(adapter))
2886+
cleanup(() => first.stop())
2887+
2888+
const running = first.start(['default'])
2889+
await setTimeout(20)
2890+
2891+
await assert.rejects(() => second.start(['default']), errors.E_WORKER_ALREADY_RUNNING)
2892+
2893+
// Starting the second Worker did not re-initialize the QueueManager and
2894+
// destroy the Adapter of the first one.
2895+
await adapter.push({ id: 'after-second', name: 'CountedJob', payload: {}, attempts: 0 })
2896+
await waitFor(() => executed === 1)
2897+
assert.equal(executed, 1)
2898+
2899+
await first.stop()
2900+
await running
2901+
})
2902+
2903+
test('start() accepts another Worker once the first one stopped', async ({ cleanup }) => {
2904+
const adapter = new MemoryAdapter()
2905+
const first = new Worker(config(adapter))
2906+
const second = new Worker(config(adapter))
2907+
cleanup(async () => {
2908+
await first.stop()
2909+
await second.stop()
2910+
})
2911+
2912+
const firstRun = first.start(['default'])
2913+
await setTimeout(20)
2914+
await first.stop()
2915+
await firstRun
2916+
2917+
const secondRun = second.start(['default'])
2918+
await setTimeout(20)
2919+
await second.stop()
2920+
await secondRun
2921+
})
2922+
2923+
test('a second start() of the same Worker keeps the process reserved', async ({
2924+
assert,
2925+
cleanup,
2926+
}) => {
2927+
const first = new Worker(config(new MemoryAdapter()))
2928+
const other = new Worker(config(new MemoryAdapter()))
2929+
cleanup(() => first.stop())
2930+
2931+
const running = first.start(['default'])
2932+
await setTimeout(20)
2933+
2934+
// The loop already runs: this call returns at once and must not release the process.
2935+
await first.start(['default'])
2936+
await assert.rejects(() => other.start(['default']), errors.E_WORKER_ALREADY_RUNNING)
2937+
2938+
await first.stop()
2939+
await running
2940+
})
2941+
2942+
test('the process stays reserved until stop() has finalized every job', async ({
2943+
assert,
2944+
cleanup,
2945+
}) => {
2946+
class BlockedJob extends Job {
2947+
async execute() {}
2948+
}
2949+
2950+
const fixture = createWorkerFixture()
2951+
fixture.adapter.finalizations.block(1)
2952+
const other = new Worker(config(new MemoryAdapter()))
2953+
cleanup(() => fixture.cleanup())
2954+
2955+
await fixture.push(BlockedJob, { id: 'blocked-finalization' })
2956+
const running = fixture.start()
2957+
await fixture.adapter.finalizations.waitForStarted(1)
2958+
2959+
// The stop has begun, but the finalization still needs the Adapter.
2960+
const stop = trackPromise(fixture.worker.stop())
2961+
await setTimeout(10)
2962+
await assert.rejects(() => other.start(['default']), errors.E_WORKER_ALREADY_RUNNING)
2963+
assert.isFalse(stop.settled)
2964+
2965+
fixture.adapter.finalizations.release(1)
2966+
await stop.promise
2967+
await running
2968+
2969+
const otherRun = other.start(['default'])
2970+
await setTimeout(20)
2971+
await other.stop()
2972+
await otherRun
2973+
})
2974+
2975+
test('a restart requested during stop() reserves the process again', async ({
2976+
assert,
2977+
cleanup,
2978+
}) => {
2979+
class BlockedJob extends Job {
2980+
async execute() {}
2981+
}
2982+
2983+
const fixture = createWorkerFixture()
2984+
fixture.adapter.finalizations.block(1)
2985+
const other = new Worker(config(new MemoryAdapter()))
2986+
cleanup(() => fixture.cleanup())
2987+
2988+
await fixture.push(BlockedJob, { id: 'blocked-before-restart' })
2989+
const firstRun = fixture.start()
2990+
await fixture.adapter.finalizations.waitForStarted(1)
2991+
2992+
const stop = trackPromise(fixture.worker.stop())
2993+
const restart = fixture.start()
2994+
fixture.adapter.finalizations.release(1)
2995+
await stop.promise
2996+
await firstRun
2997+
await fixture.adapter.acquisitions.waitForStarted(2)
2998+
2999+
// The restarted Worker holds the process again.
3000+
await assert.rejects(() => other.start(['default']), errors.E_WORKER_ALREADY_RUNNING)
3001+
3002+
await fixture.worker.stop()
3003+
await restart
3004+
})
3005+
3006+
test('a start that fails to initialize does not block the next Worker', async ({
3007+
assert,
3008+
cleanup,
3009+
}) => {
3010+
const broken = new Worker({ default: 'memory', adapters: {} })
3011+
await assert.rejects(() => broken.start(['default']), errors.E_CONFIGURATION_ERROR)
3012+
3013+
const worker = new Worker(config(new MemoryAdapter()))
3014+
cleanup(() => worker.stop())
3015+
3016+
const run = worker.start(['default'])
3017+
await setTimeout(20)
3018+
await worker.stop()
3019+
await run
3020+
})
3021+
})

0 commit comments

Comments
 (0)