Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
20 changes: 20 additions & 0 deletions src/classes/job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,26 @@ export class Job<
return options as RedisJobOptions;
}

/**
* Creates a clone of the job with new options merged with the existing ones.
*
* @param opts - job options to be shallowly merged with the existing ones.
* @returns New Job instance.
*/
cloneWithOpts(opts: JobsOptions): Job<DataType, ReturnType, NameType> {
return new Job<DataType, ReturnType, NameType>(
this.queue,
this.name,
this.data,
{
...this.opts,
timestamp: this.timestamp,
...opts,
},
this.id,
);
Comment on lines +588 to +598

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

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

cloneWithOpts currently constructs a brand-new Job instance via new Job(...), which reinitializes runtime state (e.g. progress, attemptsMade, attemptsStarted, stalledCounter, processedOn, finishedOn, failedReason, stacktrace, etc.). Since this method is now used for sandbox processing, the JSON sent to the child (via asJSONSandbox()) can lose these fields, changing observable behavior inside processors (and potentially retry/attempt logic). Consider cloning by copying the existing instance fields and only replacing opts (or provide an API to generate asJSONSandbox() with overridden opts) so the cloned job preserves all current job state.

Suggested change
return new Job<DataType, ReturnType, NameType>(
this.queue,
this.name,
this.data,
{
...this.opts,
timestamp: this.timestamp,
...opts,
},
this.id,
);
const cloned = Object.assign(
Object.create(Object.getPrototypeOf(this)),
this,
) as Job<DataType, ReturnType, NameType>;
cloned.opts = {
...this.opts,
timestamp: this.timestamp,
...opts,
};
return cloned;

Copilot uses AI. Check for mistakes.
}

/**
* Prepares a job to be passed to Sandbox.
* @returns
Expand Down
23 changes: 22 additions & 1 deletion src/classes/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,27 @@ export class Worker<
job: Job<DataType, ResultType, NameType>,
token: string,
signal?: AbortSignal,
srcPropagationMetadata?: string,
): Promise<ResultType> {
// Need to overwrite telemetry.metadata to be able to restore
// propagation context in a worker thread/fork.
// This is only necessary for sandboxed workers because OTEL doesn't
// propagate context cross-threads/cross-processes.
if (this.childPool && job.opts.telemetry) {
return this.processFn(
job.cloneWithOpts({
telemetry: {
...job.opts.telemetry,
metadata: job.opts.telemetry.omitContext
? undefined
: srcPropagationMetadata,

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

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

callProcessJob overwrites telemetry.metadata with srcPropagationMetadata even when it is undefined/empty (e.g. when this.opts.telemetry is disabled, trace() calls the callback with no args, or when getMetadata() returns an empty string). In those cases this will clear any existing user-provided job.opts.telemetry.metadata for sandboxed workers. Guard the overwrite so it only happens when you actually have a new propagation metadata to apply (and otherwise preserve the existing metadata), while still honoring omitContext.

Suggested change
: srcPropagationMetadata,
: srcPropagationMetadata
? srcPropagationMetadata
: job.opts.telemetry.metadata,

Copilot uses AI. Check for mistakes.
},
}),
token,
signal,
);
Comment on lines +446 to +457

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

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

In the sandboxed path, passing a cloned Job instance into processFn changes which Job object the sandbox bridge operates on (see sandbox.ts: progress/log/move commands call methods on the job instance passed into sandbox()). This can make progress events emit with the cloned job while completed/failed emit with the original job, and can desync in-memory fields between the two instances. Prefer keeping the original job instance for sandbox communication and only overriding the telemetry metadata in the serialized payload sent to the child.

Suggested change
return this.processFn(
job.cloneWithOpts({
telemetry: {
...job.opts.telemetry,
metadata: job.opts.telemetry.omitContext
? undefined
: srcPropagationMetadata,
},
}),
token,
signal,
);
const telemetry = job.opts.telemetry;
const originalMetadata = telemetry.metadata;
telemetry.metadata = telemetry.omitContext
? undefined
: srcPropagationMetadata;
try {
return this.processFn(job, token, signal);
} finally {
telemetry.metadata = originalMetadata;
}

Copilot uses AI. Check for mistakes.

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.

This one is actually a big problem, which makes me think that cloneWithOpts might be pointless - since I want to avoid cloning a job and then cloning that job back into original-ish job. I think it's way better to simply break the immutability of job.opts in this one case and re-assign the metadata back so it's all encapsulated in one place.

}

return this.processFn(job, token, signal);
}

Expand Down Expand Up @@ -978,7 +998,7 @@ will never work with more accuracy than 1ms. */
SpanKind.CONSUMER,
'process',
this.name,
async span => {
async (span, srcPropagationMetadata) => {
span?.setAttributes({
[TelemetryAttributes.WorkerId]: this.id,
[TelemetryAttributes.WorkerName]: this.opts.name,
Expand Down Expand Up @@ -1023,6 +1043,7 @@ will never work with more accuracy than 1ms. */
abortController
? (abortController.signal as AbortSignal)
: undefined,
srcPropagationMetadata,
);
return await this.retryIfFailed<void | Job<
DataType,
Expand Down
9 changes: 9 additions & 0 deletions tests/fixtures/fixture_processor_custom_span.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* A processor file to be used in tests.
*
*/
'use strict';

module.exports = function (job) {
return job.opts.tm;
};
73 changes: 73 additions & 0 deletions tests/job.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1987,4 +1987,77 @@ describe('Job', () => {
await worker.close();
});
});

describe('.cloneWithOpts', () => {
it('should clone one-off debounced and delayed job', async () => {
const sourceJob = await Job.create(
queue,
'test_job',
{ foo: 'bar' },
{
jobId: 'custom_job_id',
delay: 1000,
deduplication: { id: 'dedup-id' },
debounce: { id: 'debounce-id' },
backoff: { type: 'fixed', jitter: 0 },
telemetry: {
metadata: 'source_metadata',
},
},
);

const clonedJob = sourceJob.cloneWithOpts({
telemetry: {
metadata: 'target_metadata',
},
});

expect(clonedJob.asJSON()).toEqual({
...sourceJob.asJSON(),
opts: {
...sourceJob.asJSON().opts,
// timestamp for cloned job is passed as opts,
// whereas for sourceJob it's autogenerated hence the difference
timestamp: sourceJob.timestamp,
tm: clonedJob.asJSON().opts.tm,
},
});
Comment on lines +1991 to +2024

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

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

These new .cloneWithOpts tests don't currently assert that the telemetry metadata actually changes from the source value to the target value (the expectation sets tm to clonedJob.asJSON().opts.tm, which will pass even if cloneWithOpts fails to apply the override). Add assertions that sourceJob.asJSON().opts.tm === 'source_metadata' and clonedJob.asJSON().opts.tm === 'target_metadata' (and/or that omitContext behaves as expected) so the test validates the intended behavior.

Copilot uses AI. Check for mistakes.
});

it('should clone repeatable job with parent', async () => {
const parentJob = await Job.create(queue, 'parent', {});

const sourceJob = await Job.create(
queue,
'test_job',
{ foo: 'bar' },
{
jobId: 'custom_job_id',
parent: { id: parentJob.id!, queue: `${prefix}:${queueName}` },
repeat: { every: 200 },
removeDependencyOnFailure: true,
telemetry: {
metadata: 'source_metadata',
},
},
);

const clonedJob = sourceJob.cloneWithOpts({
telemetry: {
metadata: 'target_metadata',
},
});

expect(clonedJob.asJSON()).toEqual({
...sourceJob.asJSON(),
opts: {
...sourceJob.asJSON().opts,
// timestamp for cloned job is passed as opts,
// whereas for sourceJob it's autogenerated hence the difference
timestamp: sourceJob.timestamp,
tm: clonedJob.asJSON().opts.tm,
},
});
});
});
});
62 changes: 61 additions & 1 deletion tests/telemetry_interface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
} from 'vitest';

import { v4 } from 'uuid';
import { FlowProducer, JobScheduler, Queue, Worker } from '../src/classes';
import { FlowProducer, Job, JobScheduler, Queue, Worker } from '../src/classes';
import { removeAllQueueData } from '../src/utils';
import {
Telemetry,
Expand All @@ -29,6 +29,7 @@ import {
} from '../src/interfaces';
import * as sinon from 'sinon';
import { SpanKind, TelemetryAttributes, MetricNames } from '../src/enums';
import { pathToFileURL } from 'url';

describe('Telemetry', () => {
type ExtendedException = Exception & {
Expand Down Expand Up @@ -394,6 +395,65 @@ describe('Telemetry', () => {
await worker.close();
});

it('should correctly interact with telemetry when processing a sandboxed job', async () => {
const processFile =
__dirname + '/fixtures/fixture_processor_custom_span.js';
const processUrl = pathToFileURL(processFile);

const worker = new Worker(queueName, processUrl, {
connection,
telemetry: telemetryClient,
name: 'testWorker',
prefix,
useWorkerThreads: true,
});

const completing = new Promise<string>((resolve, reject) => {
worker.on('completed', async (_, value: string) => {
try {
resolve(value);
} catch (err) {
reject(err);
}
});
});

const startSpanSpy = sinon.spy(telemetryClient.tracer, 'startSpan');

const job = await queue.add('testJob', { foo: 'bar' });

const processContextMetadata = JSON.parse(await completing);

const addSpan = startSpanSpy.returnValues[0] as MockSpan;
expect(addSpan).toBeInstanceOf(MockSpan);
expect(addSpan.name).toBe(`add ${queueName}.${job.name}`);
expect(addSpan.options?.kind).toBe(SpanKind.PRODUCER);
expect(addSpan.attributes[TelemetryAttributes.QueueName]).toBe(
queue.name,
);
expect(addSpan.attributes[TelemetryAttributes.QueueOperation]).toBe(
'add',
);
expect(addSpan.attributes[TelemetryAttributes.JobName]).toBe(job.name);
expect(addSpan.attributes[TelemetryAttributes.JobId]).toBe(job.id);

const processSpan = startSpanSpy.returnValues[1] as MockSpan;
expect(processSpan).toBeInstanceOf(MockSpan);
expect(processSpan.name).toBe(`process ${queueName}`);
expect(processSpan.options?.kind).toBe(SpanKind.CONSUMER);
expect(processSpan.attributes[TelemetryAttributes.WorkerId]).toBe(
worker.id,
);
expect(processSpan.attributes[TelemetryAttributes.WorkerName]).toBe(
'testWorker',
);
expect(processSpan.attributes[TelemetryAttributes.JobId]).toBe(job.id);

expect(processContextMetadata['getMetadata_span']).toBe(processSpan.name);
Comment on lines +426 to +452

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

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

This test assumes startSpanSpy.returnValues[0] is always the add ... span and [1] is always the process ... span. Because the Worker autoruns on construction and calls getNextJob() (which is traced) concurrently with queue.add(), additional spans can be started before add/process, making these indices nondeterministic and the test flaky. Instead, locate spans by name/kind (e.g., find the first span whose name starts with add ${queueName}. and the one equal to process ${queueName}) or disable autorun and start the worker after the spy is attached.

Suggested change
const addSpan = startSpanSpy.returnValues[0] as MockSpan;
expect(addSpan).toBeInstanceOf(MockSpan);
expect(addSpan.name).toBe(`add ${queueName}.${job.name}`);
expect(addSpan.options?.kind).toBe(SpanKind.PRODUCER);
expect(addSpan.attributes[TelemetryAttributes.QueueName]).toBe(
queue.name,
);
expect(addSpan.attributes[TelemetryAttributes.QueueOperation]).toBe(
'add',
);
expect(addSpan.attributes[TelemetryAttributes.JobName]).toBe(job.name);
expect(addSpan.attributes[TelemetryAttributes.JobId]).toBe(job.id);
const processSpan = startSpanSpy.returnValues[1] as MockSpan;
expect(processSpan).toBeInstanceOf(MockSpan);
expect(processSpan.name).toBe(`process ${queueName}`);
expect(processSpan.options?.kind).toBe(SpanKind.CONSUMER);
expect(processSpan.attributes[TelemetryAttributes.WorkerId]).toBe(
worker.id,
);
expect(processSpan.attributes[TelemetryAttributes.WorkerName]).toBe(
'testWorker',
);
expect(processSpan.attributes[TelemetryAttributes.JobId]).toBe(job.id);
expect(processContextMetadata['getMetadata_span']).toBe(processSpan.name);
const spans = startSpanSpy.returnValues as MockSpan[];
const addSpan = spans.find(
span => span.name === `add ${queueName}.${job.name}`,
);
expect(addSpan).toBeInstanceOf(MockSpan);
expect(addSpan?.name).toBe(`add ${queueName}.${job.name}`);
expect(addSpan?.options?.kind).toBe(SpanKind.PRODUCER);
expect(addSpan?.attributes[TelemetryAttributes.QueueName]).toBe(
queue.name,
);
expect(addSpan?.attributes[TelemetryAttributes.QueueOperation]).toBe(
'add',
);
expect(addSpan?.attributes[TelemetryAttributes.JobName]).toBe(job.name);
expect(addSpan?.attributes[TelemetryAttributes.JobId]).toBe(job.id);
const processSpan = spans.find(
span => span.name === `process ${queueName}`,
);
expect(processSpan).toBeInstanceOf(MockSpan);
expect(processSpan?.name).toBe(`process ${queueName}`);
expect(processSpan?.options?.kind).toBe(SpanKind.CONSUMER);
expect(processSpan?.attributes[TelemetryAttributes.WorkerId]).toBe(
worker.id,
);
expect(processSpan?.attributes[TelemetryAttributes.WorkerName]).toBe(
'testWorker',
);
expect(processSpan?.attributes[TelemetryAttributes.JobId]).toBe(job.id);
expect(processContextMetadata['getMetadata_span']).toBe(processSpan?.name);

Copilot uses AI. Check for mistakes.

await worker.close();
});

it('should propagate context correctly between queue and worker using telemetry', async () => {
const job = await queue.add('testJob', { foo: 'bar' });

Expand Down