Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions docs/gitbook/guide/workers/sandboxed-processors.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ In order to use a sandboxed processor, define the processor in a separate file:
import { SandboxedJob } from 'bullmq';

module.exports = async (job: SandboxedJob) => {
// Do something with job
// Do something with job
};
```

and pass its path to the worker constructor:

```typescript
import { Worker } from 'bullmq'
import { Worker } from 'bullmq';

const processorFile = path.join(__dirname, 'my_procesor.js');
worker = new Worker(queueName, processorFile);
Comment thread
samwisekind marked this conversation as resolved.
Outdated
Expand Down Expand Up @@ -54,12 +54,29 @@ The default mechanism for launching sandboxed workers is using Node's spawn proc
In order to enable worker threads support use the `useWorkerThreads` option when defining an external processor file:

```typescript
import { Worker } from 'bullmq'
import { Worker } from 'bullmq';

const processorFile = path.join(__dirname, 'my_procesor.js');
worker = new Worker(queueName, processorFile, { useWorkerThreads: true });
Comment thread
samwisekind marked this conversation as resolved.
Outdated
```

### Disabling Child Process Reuse

By default, BullMQ reuses sandboxed child processes (or worker threads) across jobs as it can be slow to spawn a new process for every job.

However, if you have known memory leaks in your processor or its dependencies, you can disable reuse so that each job runs in a fresh process:

```typescript
import { Worker } from 'bullmq';

const processorFile = path.join(__dirname, 'my_procesor.js');
worker = new Worker(queueName, processorFile, { reuseChildProcess: false });
Comment thread
samwisekind marked this conversation as resolved.
Outdated
```

{% hint style="warning" %}
When disabled, every job incurs the cost of spawning a new process and loading the processor module. Use this only when memory isolation matters more than throughput.
{% endhint %}

## Read more:

* 💡 [Worker API Reference](https://api.docs.bullmq.io/classes/v5.Worker.html)
- 💡 [Worker API Reference](https://api.docs.bullmq.io/classes/v5.Worker.html)
14 changes: 11 additions & 3 deletions src/classes/child-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,24 @@ export class ChildPool {
free: { [key: string]: Child[] } = {};
private opts: ChildPoolOpts;

private reuseChildProcess: boolean;

constructor({
mainFile = supportCJS()
? path.join(process.cwd(), 'dist/cjs/classes/main.js')
: path.join(process.cwd(), 'dist/esm/classes/main.js'),
useWorkerThreads,
workerForkOptions,
workerThreadsOptions,
reuseChildProcess = true,
}: ChildPoolOpts) {
this.opts = {
mainFile,
useWorkerThreads,
workerForkOptions,
workerThreadsOptions,
};
this.reuseChildProcess = reuseChildProcess;
}

async retain(processFile: string): Promise<Child> {
Expand Down Expand Up @@ -67,14 +71,18 @@ export class ChildPool {
return child;
} catch (err) {
console.error(err);
this.release(child);
await this.release(child);
throw err;
}
}

release(child: Child): void {
async release(child: Child): Promise<void> {
delete this.retained[child.pid];
this.getFree(child.processFile).push(child);
if (this.reuseChildProcess) {
this.getFree(child.processFile).push(child);
} else {
Comment thread
manast marked this conversation as resolved.
await this.kill(child, 'SIGTERM');
}
Comment on lines +79 to +85

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

release() now awaits kill(). Child.kill() currently has a race where the underlying process/thread can exit between hasProcessExited() and registering the once('exit') listener, which can leave the awaited onExit promise unresolved indefinitely. With reuseChildProcess: false this path runs after every job, so this can hang job completion. Consider making Child.kill() race-safe (attach listener first, then re-check exited state and resolve/remove listener if already exited), and/or add a bounded fallback in ChildPool.kill() to avoid awaiting forever.

Copilot uses AI. Check for mistakes.

@manast manast Apr 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch, this was not even part of the PR, but nevertheless seems important for this new option to avoid workers that could get stuck forever.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@manast Happy to update the PR tackling this (and the typo comments below) or as a quick follow-up!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed with de626a0

Comment on lines +81 to +85

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When reuseChildProcess is false, release() will await a full termination with CHILD_KILL_TIMEOUT = 30_000. That extends the processor's perceived runtime and delays the job being moved to completed/failed, potentially reducing throughput or causing stalls if shutdown is slow. Consider using a shorter timeout for the per-job termination path and/or making the timeout configurable for this option.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure this is an issue in practice.

}

remove(child: Child): void {
Expand Down
2 changes: 1 addition & 1 deletion src/classes/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ const sandbox = <T, R, N extends string>(
child.off('message', msgHandler);
child.off('exit', exitHandler);
if (child.exitCode === null && child.signalCode === null) {
childPool.release(child);
await childPool.release(child);
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/classes/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ export class Worker<
useWorkerThreads: this.opts.useWorkerThreads,
workerForkOptions: this.opts.workerForkOptions,
workerThreadsOptions: this.opts.workerThreadsOptions,
reuseChildProcess: this.opts.reuseChildProcess,
});

this.createSandbox(processor);
Expand Down
9 changes: 9 additions & 0 deletions src/interfaces/sandboxed-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,13 @@ export interface SandboxedOptions {
* @see {@link https://nodejs.org/api/worker_threads.html#new-workerfilename-options}
*/
workerThreadsOptions?: WorkerThreadsOptions;

/**
* When false, sandboxed child processes (or worker threads) are terminated after
* each job instead of being reused.
* This can be used to prevent known memory leaks from accumulating across jobs.
*
* @defaultValue true
*/
reuseChildProcess?: boolean;
}
35 changes: 32 additions & 3 deletions tests/child-pool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ function sandboxProcessTests(
const processor = __dirname + '/fixtures/fixture_processor_bar.js';
const child = await pool.retain(processor, NoopProc);
expect(child).toBeTruthy();
pool.release(child);
await pool.release(child);
expect(Object.keys(pool.retained)).toHaveLength(0);
const newChild = await pool.retain(processor, NoopProc);
expect(child).toEqual(newChild);
Expand All @@ -45,7 +45,7 @@ function sandboxProcessTests(
const processor = __dirname + '/fixtures/fixture_processor_bar.js';
let child = await pool.retain(processor, NoopProc);
expect(child).toBeTruthy();
pool.release(child);
await pool.release(child);
expect(Object.keys(pool.retained)).toHaveLength(0);
let newChild = await pool.retain(processor, NoopProc);
expect(child).toEqual(newChild);
Expand Down Expand Up @@ -100,11 +100,40 @@ function sandboxProcessTests(
]);

expect(children).toHaveLength(6);
pool.release(children[0]);
await pool.release(children[0]);
const child = await pool.retain(processor);
expect(children).toContain(child);
});

it('should kill child on release when reuseChildProcess is false', async () => {
const noReusePool = new ChildPool({
mainFile,
useWorkerThreads,
reuseChildProcess: false,
});
const processor = __dirname + '/fixtures/fixture_processor_bar.js';
const child = await noReusePool.retain(processor, NoopProc);
expect(child).toBeTruthy();
await noReusePool.release(child);
expect(Object.keys(noReusePool.retained)).toHaveLength(0);
expect(noReusePool.getAllFree()).toHaveLength(0);
await noReusePool.clean();
});

it('should return a new child after release when reuseChildProcess is false', async () => {
const noReusePool = new ChildPool({
mainFile,
useWorkerThreads,
reuseChildProcess: false,
});
const processor = __dirname + '/fixtures/fixture_processor_bar.js';
const child = await noReusePool.retain(processor, NoopProc);
await noReusePool.release(child);
const newChild = await noReusePool.retain(processor, NoopProc);
expect(child).not.toEqual(newChild);
await noReusePool.clean();
});

it('should consume execArgv array from process', async () => {
const processor = __dirname + '/fixtures/fixture_processor_bar.js';
process.execArgv.push('--no-warnings');
Expand Down
103 changes: 103 additions & 0 deletions tests/sandboxed_process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,109 @@ function sandboxProcessTests(
await worker.close();
});

it('should reuse child process by default', async () => {
const processFile = __dirname + '/fixtures/fixture_processor.js';

const worker = new Worker(queueName, processFile, {
connection,
prefix,
drainDelay: 1,
useWorkerThreads,
});

let completedCount = 0;
const pids: number[] = [];

const childPool = worker['childPool'];
const origRetain = childPool.retain.bind(childPool);
childPool.retain = async (...args: any[]) => {
const child = await origRetain(...args);
pids.push(child.pid);
return child;
};

const completing = new Promise<void>((resolve, reject) => {
worker.on('completed', async (job: Job) => {
try {
completedCount++;
expect(Object.keys(worker['childPool'].retained)).toHaveLength(0);
expect(worker['childPool'].free[processFile]).toHaveLength(1);

if (completedCount === 2) {
resolve();
}
} catch (err) {
reject(err);
}
});
});

await Promise.all([
queue.add('test', { foo: 'bar' }),
queue.add('test', { foo: 'baz' }),
]);

await completing;

const [pid1, pid2] = pids;
expect(pids).toHaveLength(2);
expect(pid1).toEqual(pid2);

await worker.close();
});

it('should not reuse child process when reuseChildProcess is false', async () => {
const processFile = __dirname + '/fixtures/fixture_processor.js';

const worker = new Worker(queueName, processFile, {
connection,
prefix,
drainDelay: 1,
useWorkerThreads,
reuseChildProcess: false,
});

let completedCount = 0;
const pids: number[] = [];

const childPool = worker['childPool'];
const origRetain = childPool.retain.bind(childPool);
childPool.retain = async (...args: any[]) => {
const child = await origRetain(...args);
pids.push(child.pid);
return child;
};

const completing = new Promise<void>((resolve, reject) => {
worker.on('completed', async (job: Job) => {
try {
completedCount++;
expect(Object.keys(worker['childPool'].retained)).toHaveLength(0);
expect(worker['childPool'].getAllFree()).toHaveLength(0);

if (completedCount === 2) {
resolve();
}
} catch (err) {
reject(err);
}
});
});

await Promise.all([
queue.add('test', { foo: 'bar' }),
queue.add('test', { foo: 'baz' }),
]);

await completing;

const [pid1, pid2] = pids;
expect(pids).toHaveLength(2);
expect(pid1).not.toEqual(pid2);

await worker.close();
});

it('should process and complete when passing a URL', async () => {
const processFile = __dirname + '/fixtures/fixture_processor.js';
const processUrl = pathToFileURL(processFile);
Expand Down
Loading