@zudojs/queue
Background job and asynchronous task infrastructure. Provides an in-memory queue with processors, workers, retry policies, dead letter handling, middleware, serialization, and event emission.
OVERVIEW
Some work should not happen while a user waits. Sending an email, resizing an image or calling a slow third-party API can take seconds, and if it fails you want to try again later. A job queue is a list of such tasks that your program works through in the background, one at a time or a few at once.
@zudojs/queue gives you that list. You add a job (a name plus some data), you register a processor (the function that does the work for that name), and the queue runs the processor for each job. If the processor throws, the queue can retry with a delay and, when every attempt fails, park the job in a dead-letter store so it is not silently lost.
Everything in this package runs in memory inside one Node.js process. Jobs disappear when the process exits. That is ideal for development, tests and small apps; a durable backend would need a custom Queue implementation.
- → Work that can finish after the request returns (emails, exports, webhooks)
- → Work that fails sometimes and should be retried automatically
- → Limiting how many slow tasks run at the same time
- → Running a task once, later (
delay)
- → The caller needs the result right now: just
awaitthe function - → The task repeats on a timetable ("every night at 2am"): use @zudojs/scheduler
- → Jobs must survive a crash or restart: this package is in-memory only
INSTALLATION
Install the package. The three packages it depends on are pulled in automatically.
@zudojs/errors (error classes), @zudojs/constants (ID and timestamp types) and @zudojs/serialization (JSON payload handling). Import error classes from @zudojs/errors; this package does not re-export them.
QUICK START
This example creates a queue, registers a processor for jobs named "send-email", adds one job, and waits for it to finish.
What you should see: waiting, then the "Sending ..." line, then completed. The queue starts polling for work the moment you call process(), so you never have to start it by hand.
process() and add() runs the job before it exits. An idle queue, a paused one, jobs no processor handles and a closed queue never hold the process open. Pass keepAlive: false in QueueOptions to get the old unreferenced timer back. (Before v1.4.0 such a script exited with code 0 before the job ran.)
createInMemoryQueue is a QueueName, not a plain string. Wrap the name in createQueueName() or TypeScript will reject it.
JOBS AND PROCESSORS
A job is one unit of work. It has a name (which processor should run it), data (the input), and a state that the queue moves through as work happens. You never build a job by hand; queue.add() builds it and returns it.
A processor is an async function registered with queue.process(name, fn). The queue calls it with two arguments: the job, and a JobContext. The context carries an AbortSignal that fires if the job times out or the queue shuts down, the 1-based attemptNumber, and an updateProgress() method.
Inside a processor, job.attempt counts the attempts that have already failed, so it starts at 0: a job with attempts: 3 runs with job.attempt equal to 0, then 1, then 2. For a human-readable count use the context's attemptNumber (new in v1.4.0), which is 1-based and always job.attempt + 1, the same convention as ctx.attempt in @zudojs/scheduler. job.maxAttempts holds the total.
Job states
The JobState enum lists every state. Its values are lowercase strings, so job.state === "completed" works.
| State | Meaning |
|---|---|
waiting | Added and ready to run |
scheduled | Added with a delay or scheduledAt; not due yet |
active | A processor is running it right now |
completed | The processor returned normally |
failed | The processor threw (briefly, before retry or dead-letter) |
retrying | Failed, waiting out the backoff delay before the next attempt |
dead_letter | Every attempt failed; the job is in the dead-letter store |
Reporting progress and honouring cancellation
This processor reports progress with createJobProgress() and stops early if the signal is aborted.
A processor returns either nothing or a JobResult, built with createJobResult(data, durationMs) or createJobErrorResult(message, durationMs). Only a result with success: false, or a thrown error, counts as a failure. Each job gets a 30-second timeout unless you pass timeout.
waiting forever and nothing warns you. Register the processor first, or check queue.getProcessor(name).
JOB OPTIONS
The third argument to queue.add() is a JobOptions object. Every field is optional. You can also set defaults for the whole queue with QueueOptions.defaultJobOptions.
| Option | What it does | Default |
|---|---|---|
attempts | How many times to try before giving up | 1 (no retry) |
backoff | How long to wait between attempts (see Retries) | exponential, 1s to 30s |
delay | Milliseconds to wait before the job may run | 0 |
scheduledAt | A Date before which the job may not run | now |
priority | Higher numbers run first; within a priority, jobs run in the order they became runnable (when added, or when a delayed job came due). A retried job keeps its place | 50 |
timeout | Milliseconds before a running job is aborted and failed. A timeout aborts context.signal; the job's slot and its retry wait up to timeoutGraceMs (default 5000) for the processor to stop, so honour the signal. | 30000 |
deduplicationKey | Reject a second job with the same key while the first exists | none |
metadata | Any extra data you want stored on the job. The key zudo:context is reserved by the queue and stripped from whatever you pass (see below) | none |
metadata["zudo:context"] (exported as CONTEXT_METADATA_KEY) belongs to the queue's context carriers. Since v1.3.0 the queue drops whatever the caller put there before storing the job, whether or not a carrier captured anything. Before v1.3.0, an add() made with no ambient context kept the caller's record verbatim and the queue replayed it around the middleware and the processor — so an enqueuer could choose the tenant, correlation id or trace the job ran under. Context that a carrier genuinely captured is unaffected; put your own data under any other key.
Dates and other non-JSON values in data
add() copies the payload through the queue's serializer. Since v1.4.0 the default JsonSerializer preserves types, so a Date in data reaches the processor as a Date, matching what Queue<{ dueAt: Date }> promises. BigInt, Map, Set, Uint8Array and Error round-trip too. Output for plain JSON data is unchanged. No options are needed:
The default uses the tagged format of @zudojs/serialization. createJsonSerializer() also defaults to preserveTypes: true; pass serializer: createJsonSerializer({ preserveTypes: false }) for plain JSON, where a Date becomes its ISO string (type such fields as string). Class instances still lose their prototype through JSON; to keep them, pass serializer: PassthroughSerializer (or serializePayloads: false) and the in-memory queue stores the payload by reference. Before v1.4.0 the default was plain JSON: a Date arrived as a string, a BigInt made add() throw, and a Map or Set arrived as {}.
This example uses priority, delay and deduplication together. JobPriorityLevels is a set of named numbers you can use instead of guessing.
getNextJob() only peeks; it does not change the job. The duplicate add() throws a JobDuplicateError from @zudojs/errors.
RETRIES AND BACKOFF
A retry means running the same job again after it fails. Backoff is the pause before each retry. Pausing matters: if a service is down, hitting it again instantly just fails again, and hundreds of jobs doing that at once make things worse.
Set attempts to more than 1 and the queue retries. Add a backoff to control the pause. Two shapes exist: fixed (same pause every time) and exponential (the pause doubles each time, up to maxDelay). A jitter setting adds randomness so failed jobs do not all retry in the same instant.
This processor fails twice and succeeds on the third attempt, with a 50 ms fixed pause between tries.
You can also compute delays yourself. calculateRetryDelay(attempt, backoff) is the function the queue uses internally.
attempts but no backoff, the queue uses exponential backoff starting at 1 second, capped at 30 seconds, with full jitter. Retries never happen with zero delay by accident.
DEAD LETTER
When a job has used all its attempts, the queue does not delete it. It moves the job to a dead-letter store: a holding area for jobs that could not be done. You can inspect them, log them or re-add them by hand. The term comes from postal services, where undeliverable mail goes to a "dead letter office".
Every queue has an in-memory dead-letter store by default. Read it with getDeadLetterJobs().
Each entry is a DeadLetterJob: a copy of the job as it was when it ran out of attempts (so its state still reads "failed"), the error, the number of attempts, and a reason string. To keep dead jobs somewhere else, pass your own DeadLetterStore as QueueOptions.deadLetterStore; createInMemoryDeadLetterStore() shows the shape to copy.
Retention is bounded
Since v1.3.0 the default in-memory store keeps the most recent 1000 dead-lettered jobs (DEFAULT_DEAD_LETTER_JOBS) and evicts the oldest past that. Before v1.3.0 it was unbounded, so a queue that dead-lettered steadily grew until the process ran out of memory. Build the store yourself to choose a different cap.
QueueOptions.deadLetterStore is now typed DeadLetterStore<unknown>. In v1.4.0 it was DeadLetterStore<never>, so both createInMemoryDeadLetterStore() and createInMemoryDeadLetterStore<Email>() for a Queue<Email> were rejected with TS2322, and the workaround was the type argument <never>. Either store is now accepted with no annotation. A store still annotated <never> compiles, so you can drop it at your own pace.
close() clears the dead-letter store the queue created for itself, so a closed queue holds onto nothing. A store you passed in as deadLetterStore is left alone, as before — you own its contents and its lifetime.
getStats().failed counts jobs currently in failed or dead_letter state. A job that will be retried shows up there for a moment too. Use deadLettered for the lifetime total.
WORKERS
A worker is a loop that repeatedly asks a queue "anything to do?", claims one job, and runs it. The in-memory queue already contains such a loop, which is why the Quick Start needed no worker. Creating a Worker switches that loop off as a consumer (queue.setAutoProcess(false)), so the worker is the only thing running jobs and worker.stop() really stops consumption. A separate Worker is useful when you want its own concurrency limit, its own middleware, start/stop control, or per-worker statistics.
A worker never runs a processor itself. It calls queue.claimNextJob() (which marks the job active so nobody else takes it) and then queue.runJob(), so retries, dead-lettering and middleware all still apply.
Start a worker, let it process a job, then stop it cleanly.
Worker options
| Option | What it does | Default |
|---|---|---|
concurrency | How many jobs this worker runs at the same time | 1 |
pollInterval | Milliseconds between re-checks while idle. The in-memory queue wakes a started worker as soon as a job becomes runnable (Queue.onJobReady), so this only bounds how often an idle worker looks again | 100 |
keepAlive | Whether a started worker holds the Node.js process open until stop() or forceStop(). false leaves its timers unreferenced | true |
timeoutMs | Timeout for jobs that carry none of their own | queue default (30 s) |
middleware | Extra middleware run after the queue's own | none |
drainTimeout | How long stop() waits for running jobs before forcing | 30000 |
onError | Called for poll failures and drain timeouts | logger.error, else process.emitWarning |
logger | Receives errors when no onError is given | none |
start() twice. A worker can only start from created or stopped; anything else throws WorkerLifecycleError. Check worker.isRunning() first.
MIDDLEWARE
Middleware is a function that wraps every processor call. It receives a context with the job and a next() function; calling next() runs the rest of the chain and, finally, the processor. Use it for things every job needs, such as logging or timing, without repeating code in each processor.
Pass middleware in QueueOptions.middleware. This example adds the built-in logging middleware and one custom timer.
What you should see: "Job processing started" with the job details, then "send-email took 0ms", then "Job processing completed". Middleware runs in array order, outermost first.
next() exactly once. Calling it twice throws, because it would run the processor twice. Forgetting it entirely means the processor never runs and the job is marked completed.
EVENTS
The queue can tell you when things happen: a job was created, started, completed, failed, cancelled, or will retry, and a worker started, stopped or hit an error. Since v1.4.0 every queue has an event emitter (an object you can subscribe to) out of the box: a queue created without one gets an in-memory emitter, reachable as queue.events. Before v1.4.0 the default was a silent no-op. To share one emitter between queues, or to plug in your own, create it and hand it to the queue as QueueOptions.eventEmitter.
Subscribe with on(event, handler). It returns a function that unsubscribes.
| Event | Payload | When |
|---|---|---|
job:created | { job } | add() stored the job |
job:started | { job } | A processor is about to run |
job:progress | { job, progress } | The processor called updateProgress() |
job:completed | { job, result } | The processor returned; result is a JobResult's data, else undefined |
job:failed | { job, error } | The processor threw (fires on every failed attempt) |
job:retrying | { job, attempt } | A retry has been scheduled |
job:cancelled | { job } | A running job was aborted from outside — a draining worker, close(), or a consumer's signal. Not fired for a job that merely timed out |
worker:started | { workerId } | worker.start() brought the worker up |
worker:stopped | { workerId } | The worker stopped, through stop() or forceStop(). A forceStop() on a worker that never started reports nothing, so a readiness listener never sees a transition that did not happen |
worker:error | { workerId, error } | A worker poll failed or its drain timed out |
Worker events and Queue.events
Before v1.3.0 the last four rows above were declared in QueueEventMap but nothing emitted them, so subscribing to worker:started for readiness never fired. They fire now. A worker reports its own lifecycle on the emitter belonging to the queue it consumes, reached through the new Queue.events property — you do not pass the emitter to createWorker.
Queue.events is optional on the Queue interface, so reach it with queue.events?.. The in-memory queue always has one: a queue created without an eventEmitter exposes its own in-memory emitter, so you can subscribe with no set-up at all:
logger.error when a logger is configured — either createInMemoryQueueEventEmitter({ logger }) or QueueOptions.logger, which the queue hands to the emitter it was given — and to process.emitWarning otherwise. It is never written to console. Pass onHandlerError to take it over entirely.
PAUSING, STATS AND SHUTDOWN
pause() stops the queue from starting new jobs; resume() starts them again. By default a paused queue also rejects add(); set pauseRejectsAdd: false in QueueOptions to keep accepting jobs while paused.
close() waits for running jobs (up to closeTimeout, 30 s), cancels pending timers and forgets every job. Always call it when your program shuts down. After closing, add() throws QueueDisposedError.
Use createQueueManager() when you have several queues and want to close them all at once.
getQueue() only applies options when it creates the queue. Passing options for a queue that already exists throws a QueueError.
API REFERENCE
Everything below is exported from @zudojs/queue unless a note says otherwise.
Functions
| Name | What it does | Notes |
|---|---|---|
createInMemoryQueue(name, options?) | Creates a queue that stores jobs in memory | name is a QueueName; returns Queue |
createQueue(name, options?) | Same as above | Deprecated alias; prefer createInMemoryQueue |
createQueueName(string) | Brands a string as a QueueName | Also createJobName, createJobId and the isQueueName, isJobName, isJobId guards |
createWorker(id, queue, options?) | Creates a worker that polls the queue | Call start() to begin |
createQueueManager() | Creates and tracks several queues by name | getQueue, getExistingQueue, hasQueue, getQueueNames, closeAll |
createQueueRegistry() | Stores queues you created elsewhere | register, get, has, getAll, unregister, clear, closeAll |
createFixedBackoff(delay, options?) | Backoff with the same delay each retry | options.jitter: "none", "full" or "equal" |
createExponentialBackoff(delay, options?) | Backoff that multiplies each retry | maxDelay, multiplier (default 2), jitter |
createBackoffOptions(type, delay, options?) | Builds a BackoffOptions from a BackoffType | Used by the two helpers above |
calculateRetryDelay(attempt, backoff?) | Milliseconds to wait before the given attempt | Returns 0 without a backoff |
shouldRetry(attempt, maxAttempts) | attempt < maxAttempts | |
createInMemoryQueueEventEmitter(options?) | Emitter you can subscribe to with on() | options.onHandlerError, options.logger (a throwing listener's error, default process.emitWarning); class InMemoryQueueEventEmitter also exported, with setLogger() and removeAllListeners() |
createNoopQueueEventEmitter() | Emitter that drops every event | Pass as eventEmitter to silence a queue; the default was this before v1.4.0 |
createLoggingMiddleware(logger?) | Logs start, completion and failure of each job | logger.info(message, data) |
createTimeoutMiddleware(ms, onTimeout?) | Fails a job that runs longer than ms | The queue already applies one per job |
createMiddlewareChain(middleware[]) | Combines several middleware into one | |
createInMemoryDeadLetterStore(options?) | Dead-letter store backed by a Map, bounded to the most recent options.maxEntries | The queue's default. maxEntries defaults to DEFAULT_DEAD_LETTER_JOBS (1000); Number.POSITIVE_INFINITY is unbounded |
moveToDeadLetter(store, job, error, options?) | Adds a job to a dead-letter store | The queue calls this for you |
createJobProgress(percent, options?) | Builds a JobProgress for ctx.updateProgress() | Clamps to 0..100 |
createJobResult(data, durationMs) | Builds a successful JobResult | Also createJobErrorResult(error, durationMs) |
createProcessorRegistry() | Standalone name-to-processor map | Not used by Queue; the queue keeps its own |
createJsonSerializer(options?) | JSON serializer with space / preserveTypes (default true) | Constants JsonSerializer (default, type-preserving) and PassthroughSerializer (stores payloads by reference) |
Queue methods
| Name | What it does | Notes |
|---|---|---|
add(name, data, options?) | Stores a job and returns it, and wakes the poller and any idle worker at once | Throws when paused (by default), closed, duplicate, or the payload cannot be serialized (a cyclic object, for example) |
process(name, processor) | Registers the processor for a job name and starts polling | One processor per name; a later call replaces it |
getJob(id) | Current copy of a job, or null | Jobs are immutable; re-fetch to see new state |
getNextJob() | Peeks at the next runnable job | Does not claim it |
claimNextJob() / releaseJob(id) / runJob(job, options?) | Building blocks for a custom worker | Use createWorker unless you need these |
getStats() | Counts per state plus lifetime totals | processed, succeeded, errored, retried, deadLettered |
getDeadLetterJobs() | All DeadLetterJob entries | |
pause() / resume() / isPaused() | Stop and restart processing | |
close() / isDisposed() | Drain and shut down | Idempotent; clears a dead-letter store the queue created itself, not one you supplied |
events | The emitter this queue publishes on | Optional on the interface; in-memory by default. A worker uses it to emit worker:started / worker:stopped / worker:error |
onJobReady(listener) | Calls listener whenever a job may have become runnable; returns an unsubscribe function | Optional on the interface; the in-memory queue implements it. A Worker subscribes on start() and unsubscribes on stop() |
Queue options
| Name | What it does | Default |
|---|---|---|
concurrency | Jobs the queue's own loop runs at once | 1 |
pollInterval | Milliseconds between polls of the queue's own loop. When set, every poll uses it. Either way, add(), a delayed job coming due, an elapsed retry backoff, resume() and a finished job wake the loop at once | unset: 50 ms, backing off to 2000 ms while idle |
keepAlive | Whether pending work (waiting, delayed, retrying or running jobs a processor can run) holds the Node.js process open. false leaves every timer unreferenced | true |
defaultJobOptions | JobOptions applied to every add() | none |
middleware | Middleware run around every processor | [] |
eventEmitter | Where lifecycle events go | an in-memory emitter, exposed as queue.events |
deadLetterStore | Where exhausted jobs go | in-memory, last 1000 |
logger | Destination for errors with no caller to receive them; also handed to an InMemoryQueueEventEmitter so a throwing listener reaches logger.error | none (process.emitWarning) |
serializer / serializePayloads | Payloads are copied through the serializer on add() (the default keeps Date, BigInt, Map, Set, Uint8Array and Error); set serializePayloads: false or use PassthroughSerializer to store by reference | JsonSerializer / true |
pauseRejectsAdd | Whether add() throws while paused | true |
retainSettledJobs | Finished jobs kept before the oldest are dropped | 1000 |
closeTimeout | How long close() waits for running jobs | 30000 |
stalledAfter / maxStalledCount | Reclaim a job left active by a dead consumer; dead-letter after N stalls | 0 (off) / 3 |
autoProcess | Whether the queue's own loop claims and runs jobs; creating a Worker turns it off | true |
timeoutGraceMs | After a timeout, how long the slot and the retry wait for the processor to settle | 5000 |
contextCarriers | Context (tenant, correlation id, trace ids) carried from add() into the processor; see captureContext / runWithContext and the README section "Carrying context across the queue" | none |
add() and every other event that makes a job runnable wake the queue's loop (and any started Worker) immediately, so a job added after an idle spell starts within milliseconds instead of after up to 2 seconds, and 40 instant jobs at concurrency 1 finish in tens of milliseconds. Before v1.4.0 pollInterval applied only to the first poll.
Types and constants
| Name | What it is | Notes |
|---|---|---|
Queue, QueueOptions, QueueStats | The queue interface and its option/stat shapes | Also QueueManager, QueueRegistry, QueueInfo |
Job, JobOptions, BackoffOptions | A job and the options accepted by add() | |
Processor, JobContext, JobResult, JobProgress | The processor function type and what it receives/returns | |
Worker, WorkerOptions, WorkerStats | Worker interface, options and stats | |
QueueMiddleware, QueueMiddlewareContext | Middleware function and its ctx | |
QueueEventEmitter, QueueEventMap | Emitter interface and the event-name-to-payload map | |
DeadLetterJob, DeadLetterStore | Dead-letter entry and store interface | |
Serializer | serialize(data): string / deserialize(string) | Optional passthrough: true tells the in-memory queue to store payloads by reference |
JobState, WorkerState, BackoffType | Enums of lowercase string values | BackoffType.FIXED, BackoffType.EXPONENTIAL |
JobPriorityLevels | LOW 10, NORMAL 50, HIGH 100, CRITICAL 200 | Frozen object |
DEFAULT_JOB_OPTIONS | { attempts: 1, timeout: 30000 } | Also mergeJobOptions(options?) |
DEFAULT_DEAD_LETTER_JOBS | 1000 — how many dead-lettered jobs the default store retains | Override per store with createInMemoryDeadLetterStore({ maxEntries }) |
CONTEXT_METADATA_KEY | "zudo:context" — the metadata key the queue's context carriers own | Stripped from any metadata passed to add() |
QueueLogger | What QueueOptions.logger / WorkerOptions.logger accept | Structurally compatible with @zudojs/logger and with console |
Errors
These are thrown by the queue but live in @zudojs/errors. Import them from there.
| Name | When | Notes |
|---|---|---|
QueueError | Base class; also add() on a paused queue | All others extend it |
QueueDisposedError | add(), process() or resume() after close() | |
JobDuplicateError | deduplicationKey already in use | |
JobSerializationError | Payload cannot be serialized | Cyclic objects, for example |
JobTimeoutError | A job ran past its timeout | Counts as a failed attempt |
JobMaxAttemptsError | Recorded on the job when it is dead-lettered | Appears as DeadLetterJob.error |
JobStalledError | A job was reclaimed after stalledAfter | Needs stalledAfter > 0 |
WorkerLifecycleError | worker.start() from the wrong state, or a drain timeout |
COMMON MISTAKES
- → Passing a plain string as the queue name. TypeScript reports a type error on
createInMemoryQueue("emails"). Wrap it:createQueueName("emails"). - → Importing errors from
@zudojs/queue. The import isundefinedat runtime andinstanceofchecks silently fail. ImportJobTimeoutErrorand friends from@zudojs/errors. - → Reading
job.statefrom the objectadd()returned. Jobs are immutable snapshots, so it stays"waiting"forever. Callqueue.getJob(job.id)to see the current state. - → Expecting
attempts: 3to retry instantly. Without abackoffthe default is exponential starting at 1 second, so a test that waits 200 ms sees only one attempt. PasscreateFixedBackoff(10)in tests. - → Adding class instances or cyclic objects. The default serializer keeps
Date,BigInt,Map,Set,Uint8ArrayandError, but class instances lose their methods and cyclic objects throwJobSerializationError. Put IDs and plain data in the job, or passserializer: PassthroughSerializerto store payloads by reference. - → Treating
job.attemptas 1-based. It is 0 on the first run. Use the processor context'sctx.attemptNumber(1-based, equal tojob.attempt + 1) for a human-readable count. - → Forgetting
close(). Jobs you never awaited are abandoned and pending retries are lost when the process exits. Close every queue (ormanager.closeAll()) during shutdown.
COMPLETE EXPORT INDEX
Every name @zudojs/queue exports from its package root at v1.3.0 — 87 in total, generated from the package’s own entry point rather than written by hand. The sections above explain the ones you reach for most; this is the exhaustive list, so nothing shipped is undocumented. Names not covered above are typically internal helpers and supporting types.
Show all 87 exports
InMemoryQueue InMemoryQueueEventEmitterassertProcessor calculateRetryDelay captureContext createBackoffOptions createExponentialBackoff createFixedBackoff createInMemoryDeadLetterStore createInMemoryQueue createInMemoryQueueEventEmitter createJob createJobContext createJobErrorResult createJobId createJobName createJobProgress createJobResult createJsonSerializer createLoggingMiddleware createMiddlewareChain createNoopQueueEventEmitter createProcessorRegistry createQueue createQueueManager createQueueName createQueueRegistry createTimeoutMiddleware createWorker incrementJobAttempt isJob isJobContext isJobId isJobName isProcessor isQueue isQueueName isWorker mergeJobOptions moveToDeadLetter runWithContext shouldRetry updateJobStateBackoffOptions DeadLetterJob DeadLetterStore InMemoryDeadLetterStoreOptions Job JobContext JobInput JobOptions JobProgress JobResult ProcessorInfo ProcessorRegistry Queue QueueContextCarrier QueueEventEmitter QueueInfo QueueLogger QueueManager QueueMiddlewareContext QueueOptions QueueRegistry QueueStats Serializer Worker WorkerOptions WorkerStatsBackoffStrategy JobId JobName JobPriority Processor QueueEventMap QueueMiddleware QueueName WorkerLifecycleStateCONTEXT_METADATA_KEY DEFAULT_DEAD_LETTER_JOBS DEFAULT_JOB_OPTIONS JobPriorityLevels JsonSerializer PassthroughSerializerBackoffType JobState WorkerState