@zudojs/queue 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -69,8 +69,8 @@ await queue.close();
69
69
  - Draining shutdown with a bounded `closeTimeout`, then cooperative abort
70
70
  - Stalled-job reclaim (`stalledAfter`, `maxStalledCount`) for consumers that die
71
71
  mid-job
72
- - Bounded retention of settled jobs, so a long-lived queue does not grow without
73
- limit
72
+ - Bounded retention of settled jobs and of dead-lettered jobs, so a long-lived
73
+ queue does not grow without limit
74
74
 
75
75
  ## Workers, timeouts and context
76
76
 
@@ -120,6 +120,10 @@ Keep captured values small and serializable (ids, not live objects). With
120
120
  `@zudojs/tenancy`, capture the tenant id and restore it with the tenant context
121
121
  storage's `run`.
122
122
 
123
+ The `zudo:context` metadata key is reserved: it is written only by the queue's
124
+ own carriers and is stripped from any `metadata` passed to `add()`, so an
125
+ enqueuer cannot choose the context its job runs under.
126
+
123
127
  Errors with no caller to receive them (a worker's failing poll, a throwing event
124
128
  listener) go to `logger.error` when a logger is configured, and otherwise to
125
129
  `process.emitWarning`, never to `console`.
@@ -13,10 +13,15 @@ export declare const CONTEXT_METADATA_KEY = "zudo:context";
13
13
  /**
14
14
  * Captures every carrier's value into a metadata record.
15
15
  *
16
+ * {@link CONTEXT_METADATA_KEY} is owned by the queue: a value the caller put
17
+ * under that key is always discarded, whether or not a carrier captured
18
+ * anything, so an enqueuer cannot forge the tenant, correlation id or trace
19
+ * the processor runs under.
20
+ *
16
21
  * @param carriers - The queue's context carriers.
17
22
  * @param metadata - The job's own metadata, if any.
18
23
  * @returns The metadata with a {@link CONTEXT_METADATA_KEY} entry added, or
19
- * the input unchanged when no carrier captured anything.
24
+ * without one when no carrier captured anything.
20
25
  */
21
26
  export declare function captureContext(carriers: readonly QueueContextCarrier[] | undefined, metadata: Record<string, unknown> | undefined): Record<string, unknown> | undefined;
22
27
  /**
@@ -8,17 +8,37 @@
8
8
  * `{ [carrier.key]: value }` record.
9
9
  */
10
10
  export const CONTEXT_METADATA_KEY = "zudo:context";
11
+ /**
12
+ * Strips the reserved context key from caller-supplied metadata.
13
+ *
14
+ * The key is owned by the queue on every path: whatever the enqueuer put
15
+ * there is dropped, so `add()` metadata can never supply a context record of
16
+ * its own. Returns the input untouched when there is nothing to strip, so the
17
+ * common case still allocates nothing.
18
+ */
19
+ function withoutStoredContext(metadata) {
20
+ if (!metadata || !Object.hasOwn(metadata, CONTEXT_METADATA_KEY)) {
21
+ return metadata;
22
+ }
23
+ const { [CONTEXT_METADATA_KEY]: _discarded, ...rest } = metadata;
24
+ return rest;
25
+ }
11
26
  /**
12
27
  * Captures every carrier's value into a metadata record.
13
28
  *
29
+ * {@link CONTEXT_METADATA_KEY} is owned by the queue: a value the caller put
30
+ * under that key is always discarded, whether or not a carrier captured
31
+ * anything, so an enqueuer cannot forge the tenant, correlation id or trace
32
+ * the processor runs under.
33
+ *
14
34
  * @param carriers - The queue's context carriers.
15
35
  * @param metadata - The job's own metadata, if any.
16
36
  * @returns The metadata with a {@link CONTEXT_METADATA_KEY} entry added, or
17
- * the input unchanged when no carrier captured anything.
37
+ * without one when no carrier captured anything.
18
38
  */
19
39
  export function captureContext(carriers, metadata) {
20
40
  if (!carriers || carriers.length === 0)
21
- return metadata;
41
+ return withoutStoredContext(metadata);
22
42
  const captured = {};
23
43
  let any = false;
24
44
  for (const carrier of carriers) {
@@ -29,7 +49,7 @@ export function captureContext(carriers, metadata) {
29
49
  any = true;
30
50
  }
31
51
  if (!any)
32
- return metadata;
52
+ return withoutStoredContext(metadata);
33
53
  return { ...metadata, [CONTEXT_METADATA_KEY]: Object.freeze(captured) };
34
54
  }
35
55
  /**
@@ -1,9 +1,34 @@
1
1
  import type { Job } from "../job/job.type.js";
2
2
  import type { DeadLetterStore } from "./deadLetter.type.js";
3
+ /**
4
+ * Dead-lettered jobs retained by the in-memory store when no cap is given.
5
+ *
6
+ * Mirrors the queue's own `DEFAULT_RETAINED_JOBS`: the dead letter store
7
+ * keeps a full copy of every failed job — payload, metadata and error — so an
8
+ * unbounded one grows for the life of the process at whatever the failure
9
+ * rate happens to be.
10
+ */
11
+ export declare const DEFAULT_DEAD_LETTER_JOBS = 1000;
12
+ /**
13
+ * Options for {@link createInMemoryDeadLetterStore}.
14
+ */
15
+ export interface InMemoryDeadLetterStoreOptions {
16
+ /**
17
+ * Maximum number of dead-lettered jobs retained before the oldest are
18
+ * evicted. Defaults to {@link DEFAULT_DEAD_LETTER_JOBS}. Pass
19
+ * `Number.POSITIVE_INFINITY` for the previous unbounded behaviour.
20
+ */
21
+ readonly maxEntries?: number;
22
+ }
3
23
  /**
4
24
  * Creates an in-memory dead letter store.
25
+ *
26
+ * Retention is bounded: past `maxEntries` the oldest entry is evicted, so a
27
+ * long-lived queue with a steady failure rate does not grow without limit.
28
+ *
29
+ * @param options - Retention options.
5
30
  */
6
- export declare function createInMemoryDeadLetterStore<TData = unknown>(): DeadLetterStore<TData>;
31
+ export declare function createInMemoryDeadLetterStore<TData = unknown>(options?: InMemoryDeadLetterStoreOptions): DeadLetterStore<TData>;
7
32
  /**
8
33
  * Moves a failed job to the dead letter store.
9
34
  */
@@ -1,12 +1,38 @@
1
1
  import { toQueueError } from "@zudojs/errors";
2
+ /**
3
+ * Dead-lettered jobs retained by the in-memory store when no cap is given.
4
+ *
5
+ * Mirrors the queue's own `DEFAULT_RETAINED_JOBS`: the dead letter store
6
+ * keeps a full copy of every failed job — payload, metadata and error — so an
7
+ * unbounded one grows for the life of the process at whatever the failure
8
+ * rate happens to be.
9
+ */
10
+ export const DEFAULT_DEAD_LETTER_JOBS = 1_000;
2
11
  /**
3
12
  * Creates an in-memory dead letter store.
13
+ *
14
+ * Retention is bounded: past `maxEntries` the oldest entry is evicted, so a
15
+ * long-lived queue with a steady failure rate does not grow without limit.
16
+ *
17
+ * @param options - Retention options.
4
18
  */
5
- export function createInMemoryDeadLetterStore() {
19
+ export function createInMemoryDeadLetterStore(options) {
6
20
  const store = new Map();
21
+ const configured = options?.maxEntries ?? DEFAULT_DEAD_LETTER_JOBS;
22
+ const maxEntries = configured > 0 ? configured : DEFAULT_DEAD_LETTER_JOBS;
7
23
  return {
8
24
  async add(deadLetterJob) {
25
+ // Re-inserting an existing id must not count as a new entry, so
26
+ // delete first: a Map keeps its original insertion order otherwise,
27
+ // which would also evict the wrong entry.
28
+ store.delete(deadLetterJob.job.id);
9
29
  store.set(deadLetterJob.job.id, deadLetterJob);
30
+ while (store.size > maxEntries) {
31
+ const oldest = store.keys().next().value;
32
+ if (oldest === undefined)
33
+ break;
34
+ store.delete(oldest);
35
+ }
10
36
  },
11
37
  async get(jobId) {
12
38
  return store.get(jobId) ?? null;
@@ -4,6 +4,7 @@
4
4
  * Provides storage and management for jobs that have
5
5
  * exceeded their retry attempts.
6
6
  */
7
- export { createInMemoryDeadLetterStore, moveToDeadLetter, } from "./deadLetter.core.js";
7
+ export { DEFAULT_DEAD_LETTER_JOBS, createInMemoryDeadLetterStore, moveToDeadLetter, } from "./deadLetter.core.js";
8
+ export type { InMemoryDeadLetterStoreOptions } from "./deadLetter.core.js";
8
9
  export type { DeadLetterJob, DeadLetterStore } from "./deadLetter.type.js";
9
10
  //# sourceMappingURL=index.d.ts.map
@@ -4,5 +4,5 @@
4
4
  * Provides storage and management for jobs that have
5
5
  * exceeded their retry attempts.
6
6
  */
7
- export { createInMemoryDeadLetterStore, moveToDeadLetter, } from "./deadLetter.core.js";
7
+ export { DEFAULT_DEAD_LETTER_JOBS, createInMemoryDeadLetterStore, moveToDeadLetter, } from "./deadLetter.core.js";
8
8
  //# sourceMappingURL=index.js.map
@@ -4,6 +4,7 @@ import type { Queue, QueueOptions, QueueStats } from "../queue/queue.type.js";
4
4
  import type { Processor } from "../processor/processor.type.js";
5
5
  import type { JobOptions } from "../jobOptions/jobOptions.type.js";
6
6
  import type { QueueMiddleware } from "../middleware/middleware.type.js";
7
+ import type { QueueEventEmitter } from "../queueEmitter/queueEmitter.type.js";
7
8
  import type { DeadLetterJob } from "../deadLetter/deadLetter.type.js";
8
9
  /**
9
10
  * In-memory queue implementation.
@@ -32,6 +33,11 @@ export declare class InMemoryQueue<TData = unknown> implements Queue<TData> {
32
33
  private readonly stalledCounts;
33
34
  private readonly deduplicationIndex;
34
35
  private readonly deadLetterStore;
36
+ /**
37
+ * Whether this queue created its own dead letter store. A store handed in
38
+ * by the caller outlives the queue and is theirs to clear.
39
+ */
40
+ private readonly ownsDeadLetterStore;
35
41
  private readonly emitter;
36
42
  private readonly counters;
37
43
  private emptySince;
@@ -46,6 +52,13 @@ export declare class InMemoryQueue<TData = unknown> implements Queue<TData> {
46
52
  * concurrency apply to every job.
47
53
  */
48
54
  setAutoProcess(enabled: boolean): void;
55
+ /**
56
+ * The emitter this queue publishes lifecycle events on.
57
+ *
58
+ * A no-op emitter when the queue was created without one, so a worker can
59
+ * report its lifecycle unconditionally.
60
+ */
61
+ get events(): QueueEventEmitter;
49
62
  add(jobName: string, data: TData, options?: JobOptions): Promise<Job<TData>>;
50
63
  process(name: string, processor: Processor<TData>): void;
51
64
  getJob(jobId: JobId): Promise<Job<TData> | null>;
@@ -4,8 +4,8 @@ import { assertProcessor } from "../processor/processor.type.js";
4
4
  import { createJob, updateJobState } from "../job/job.core.js";
5
5
  import { JobState as JobStateEnum, createJobName, } from "../jobTypes/jobTypes.type.js";
6
6
  import { JsonSerializer } from "../serializer/serializer.core.js";
7
- import { createInMemoryDeadLetterStore } from "../deadLetter/deadLetter.core.js";
8
- import { createNoopQueueEventEmitter } from "../queueEmitter/queueEmitter.core.js";
7
+ import { DEFAULT_DEAD_LETTER_JOBS, createInMemoryDeadLetterStore, } from "../deadLetter/deadLetter.core.js";
8
+ import { InMemoryQueueEventEmitter, createNoopQueueEventEmitter, } from "../queueEmitter/queueEmitter.core.js";
9
9
  import { processJob } from "./inMemoryQueue.processing.js";
10
10
  import { captureContext } from "../contextCarrier/contextCarrier.core.js";
11
11
  import { scheduleJob, promoteDueScheduledJobs, } from "./inMemoryQueue.scheduling.js";
@@ -47,6 +47,11 @@ export class InMemoryQueue {
47
47
  stalledCounts = new Map();
48
48
  deduplicationIndex = new Map();
49
49
  deadLetterStore;
50
+ /**
51
+ * Whether this queue created its own dead letter store. A store handed in
52
+ * by the caller outlives the queue and is theirs to clear.
53
+ */
54
+ ownsDeadLetterStore;
50
55
  emitter;
51
56
  counters = {
52
57
  processedCount: 0,
@@ -63,8 +68,19 @@ export class InMemoryQueue {
63
68
  this.serializer = this.options.serializer ?? JsonSerializer;
64
69
  this.middleware = this.options.middleware ?? [];
65
70
  this.emitter = options?.eventEmitter ?? createNoopQueueEventEmitter();
71
+ // A supplied emitter is built before the queue exists, so it cannot have
72
+ // been given the queue's logger. Hand it over, so a throwing listener is
73
+ // reported through structured logging rather than `process.emitWarning`.
74
+ if (this.options.logger &&
75
+ this.emitter instanceof InMemoryQueueEventEmitter) {
76
+ this.emitter.setLogger(this.options.logger);
77
+ }
78
+ this.ownsDeadLetterStore = this.options.deadLetterStore === undefined;
66
79
  this.deadLetterStore =
67
- this.options.deadLetterStore ?? createInMemoryDeadLetterStore();
80
+ this.options.deadLetterStore ??
81
+ createInMemoryDeadLetterStore({
82
+ maxEntries: DEFAULT_DEAD_LETTER_JOBS,
83
+ });
68
84
  this.autoProcess = this.options.autoProcess ?? true;
69
85
  }
70
86
  /**
@@ -78,6 +94,15 @@ export class InMemoryQueue {
78
94
  setAutoProcess(enabled) {
79
95
  this.autoProcess = enabled;
80
96
  }
97
+ /**
98
+ * The emitter this queue publishes lifecycle events on.
99
+ *
100
+ * A no-op emitter when the queue was created without one, so a worker can
101
+ * report its lifecycle unconditionally.
102
+ */
103
+ get events() {
104
+ return this.emitter;
105
+ }
81
106
  async add(jobName, data, options) {
82
107
  if (this.disposed)
83
108
  throw new QueueDisposedError(this.name);
@@ -277,6 +302,11 @@ export class InMemoryQueue {
277
302
  this.settledOrder.length = 0;
278
303
  this.stalledCounts.clear();
279
304
  this.inFlight.clear();
305
+ // A dead letter store the queue created dies with it; one handed in by
306
+ // the caller is theirs and is left alone.
307
+ if (this.ownsDeadLetterStore) {
308
+ await this.deadLetterStore.clear();
309
+ }
280
310
  this.activeCount = 0;
281
311
  this.paused = false;
282
312
  this.emptySince = 0;
@@ -400,17 +430,23 @@ export class InMemoryQueue {
400
430
  }
401
431
  const abortController = new AbortController();
402
432
  // A consumer's own signal (a worker draining, say) must reach the
403
- // job it dispatched.
404
- if (options?.signal) {
405
- if (options.signal.aborted) {
406
- abortController.abort(options.signal.reason);
433
+ // job it dispatched. The forwarder is held so it can be removed once
434
+ // the job settles: a worker uses one long-lived signal for every job
435
+ // it dispatches, so a listener left behind accumulates for the life
436
+ // of the worker and pins that job's controller with it.
437
+ const consumerSignal = options?.signal;
438
+ let forwardAbort;
439
+ if (consumerSignal) {
440
+ if (consumerSignal.aborted) {
441
+ abortController.abort(consumerSignal.reason);
407
442
  }
408
443
  else {
409
- options.signal.addEventListener("abort", () => {
444
+ forwardAbort = () => {
410
445
  if (!abortController.signal.aborted) {
411
- abortController.abort(options.signal?.reason);
446
+ abortController.abort(consumerSignal.reason);
412
447
  }
413
- }, { once: true });
448
+ };
449
+ consumerSignal.addEventListener("abort", forwardAbort, { once: true });
414
450
  }
415
451
  }
416
452
  this.inFlight.set(job.id, abortController);
@@ -453,6 +489,9 @@ export class InMemoryQueue {
453
489
  finally {
454
490
  this.activeCount--;
455
491
  this.inFlight.delete(job.id);
492
+ if (forwardAbort && consumerSignal) {
493
+ consumerSignal.removeEventListener("abort", forwardAbort);
494
+ }
456
495
  }
457
496
  }
458
497
  async processTick() {
@@ -48,10 +48,15 @@ export async function processJob(job, processor, options, deps) {
48
48
  },
49
49
  });
50
50
  const timeoutMs = options.timeoutMs ?? DEFAULT_JOB_OPTIONS.timeout ?? 30_000;
51
+ // Whether this job's own timeout is what aborted it. An abort from
52
+ // anywhere else — a draining worker, `close()`, a consumer's signal — is a
53
+ // cancellation, and is reported as one.
54
+ let timedOut = false;
51
55
  const timeoutMiddleware = createTimeoutMiddleware(timeoutMs, () => {
52
56
  // Let a cooperative processor observe the timeout and stop working
53
57
  // instead of running on with its result discarded.
54
58
  if (!abortController.signal.aborted) {
59
+ timedOut = true;
55
60
  abortController.abort(new Error(`Job "${updatedJob.id}" timed out after ${timeoutMs}ms.`));
56
61
  }
57
62
  });
@@ -92,6 +97,9 @@ export async function processJob(job, processor, options, deps) {
92
97
  catch (error) {
93
98
  const errorMessage = error instanceof Error ? error.message : String(error);
94
99
  await settleWithin(running, options.timeoutGraceMs ?? DEFAULT_TIMEOUT_GRACE_MS);
100
+ if (abortController.signal.aborted && !timedOut) {
101
+ emitter.emit("job:cancelled", { job: updatedJob });
102
+ }
95
103
  await handleJobFailure(updatedJob, errorMessage, deps);
96
104
  }
97
105
  finally {
@@ -138,6 +138,14 @@ export interface QueueStats {
138
138
  export interface Queue<TData = unknown> {
139
139
  /** Queue name. */
140
140
  readonly name: QueueName;
141
+ /**
142
+ * The emitter this queue publishes lifecycle events on, when it has one.
143
+ *
144
+ * Exposed so a `Worker` can report its own lifecycle
145
+ * (`worker:started`, `worker:stopped`, `worker:error`) on the same
146
+ * emitter as the jobs it runs.
147
+ */
148
+ readonly events?: QueueEventEmitter;
141
149
  /** Add a job to the queue. */
142
150
  add(name: string, data: TData, options?: JobOptions): Promise<Job<TData>>;
143
151
  /** Process jobs with a processor. */
@@ -228,6 +236,7 @@ export type QueueEventMap = {
228
236
  job: Job;
229
237
  attempt: number;
230
238
  };
239
+ /** A running job was aborted from outside — a drain, a close, a cancel. */
231
240
  "job:cancelled": {
232
241
  job: Job;
233
242
  };
@@ -1,5 +1,5 @@
1
1
  import type { QueueEventEmitter } from "./queueEmitter.type.js";
2
- import type { QueueEventMap } from "../queue/queue.type.js";
2
+ import type { QueueEventMap, QueueLogger } from "../queue/queue.type.js";
3
3
  type EventName = keyof QueueEventMap;
4
4
  type Handler<T extends EventName> = (data: QueueEventMap[T]) => void;
5
5
  /**
@@ -12,6 +12,12 @@ export interface QueueEventEmitterOptions {
12
12
  * the emitting code path.
13
13
  */
14
14
  readonly onHandlerError?: (error: unknown, event: EventName) => void;
15
+ /**
16
+ * Receives a throwing listener's error when no `onHandlerError` is given.
17
+ * Without one the failure goes to `process.emitWarning`, bypassing
18
+ * structured logging and redaction.
19
+ */
20
+ readonly logger?: QueueLogger;
15
21
  }
16
22
  /**
17
23
  * In-memory queue event emitter.
@@ -24,7 +30,20 @@ export interface QueueEventEmitterOptions {
24
30
  export declare class InMemoryQueueEventEmitter implements QueueEventEmitter {
25
31
  private readonly handlers;
26
32
  private readonly onHandlerError;
33
+ /** Whether `onHandlerError` was supplied, so `setLogger` leaves it alone. */
34
+ private readonly hasCustomHandlerError;
35
+ private logger;
27
36
  constructor(options?: QueueEventEmitterOptions);
37
+ /**
38
+ * Adopts a logger for the default handler-error report.
39
+ *
40
+ * Called by a queue that was configured with a logger, since the emitter is
41
+ * built before the queue exists and cannot have been given it. A logger or
42
+ * an `onHandlerError` supplied at construction always wins.
43
+ *
44
+ * @param logger - Destination for a throwing listener's error.
45
+ */
46
+ setLogger(logger: QueueLogger): void;
28
47
  emit<K extends EventName>(event: K, data: QueueEventMap[K]): void;
29
48
  on<K extends EventName>(event: K, handler: Handler<K>): () => void;
30
49
  /**
@@ -10,15 +10,34 @@ import { reportQueueError } from "../queue/queue.report.js";
10
10
  export class InMemoryQueueEventEmitter {
11
11
  handlers = new Map();
12
12
  onHandlerError;
13
+ /** Whether `onHandlerError` was supplied, so `setLogger` leaves it alone. */
14
+ hasCustomHandlerError;
15
+ logger;
13
16
  constructor(options = {}) {
17
+ this.hasCustomHandlerError = options.onHandlerError !== undefined;
18
+ this.logger = options.logger;
14
19
  this.onHandlerError =
15
20
  options.onHandlerError ??
16
21
  ((error, event) => {
17
22
  queueMicrotask(() => {
18
- reportQueueError(`[@zudojs/queue] Listener for "${event}" threw.`, error);
23
+ reportQueueError(`[@zudojs/queue] Listener for "${event}" threw.`, error, this.logger);
19
24
  });
20
25
  });
21
26
  }
27
+ /**
28
+ * Adopts a logger for the default handler-error report.
29
+ *
30
+ * Called by a queue that was configured with a logger, since the emitter is
31
+ * built before the queue exists and cannot have been given it. A logger or
32
+ * an `onHandlerError` supplied at construction always wins.
33
+ *
34
+ * @param logger - Destination for a throwing listener's error.
35
+ */
36
+ setLogger(logger) {
37
+ if (this.hasCustomHandlerError || this.logger)
38
+ return;
39
+ this.logger = logger;
40
+ }
22
41
  emit(event, data) {
23
42
  const handlers = this.handlers.get(event);
24
43
  if (!handlers) {
@@ -26,12 +26,29 @@ export function createWorker(id, queue, options) {
26
26
  let polling = false;
27
27
  let abortController = null;
28
28
  queue.setAutoProcess?.(false);
29
- const onError = options?.onError ??
29
+ /**
30
+ * Reports the worker's own lifecycle on the queue's emitter.
31
+ *
32
+ * `worker:started`, `worker:stopped` and `worker:error` were part of
33
+ * `QueueEventMap` with nothing emitting them, so a consumer subscribing for
34
+ * readiness never heard from the worker.
35
+ */
36
+ const emitLifecycle = (event) => {
37
+ queue.events?.emit(event, { workerId: id });
38
+ };
39
+ const reportWorkerError = options?.onError ??
30
40
  ((error) => {
31
41
  queueMicrotask(() => {
32
42
  reportQueueError(`[@zudojs/queue] Worker "${id}" poll failed.`, error, options?.logger);
33
43
  });
34
44
  });
45
+ const onError = (error) => {
46
+ queue.events?.emit("worker:error", {
47
+ workerId: id,
48
+ error: error instanceof Error ? error : new Error(String(error)),
49
+ });
50
+ reportWorkerError(error);
51
+ };
35
52
  /**
36
53
  * Arms the next poll. At most one timer is ever armed: a delayed poll
37
54
  * already pending is left alone, while an immediate poll (capacity just
@@ -157,6 +174,7 @@ export function createWorker(id, queue, options) {
157
174
  abortController = new AbortController();
158
175
  try {
159
176
  state = WorkerState.RUNNING;
177
+ emitLifecycle("worker:started");
160
178
  scheduleNextPoll(0);
161
179
  }
162
180
  catch (error) {
@@ -193,11 +211,17 @@ export function createWorker(id, queue, options) {
193
211
  }
194
212
  clearPollTimer();
195
213
  state = WorkerState.STOPPED;
214
+ emitLifecycle("worker:stopped");
196
215
  },
197
216
  async forceStop() {
217
+ const wasLive = state !== WorkerState.CREATED && state !== WorkerState.STOPPED;
198
218
  abortController?.abort();
199
219
  clearPollTimer();
200
220
  state = WorkerState.STOPPED;
221
+ // A worker that never started never stopped: reporting it would give a
222
+ // readiness listener a transition that did not happen.
223
+ if (wasLive)
224
+ emitLifecycle("worker:stopped");
201
225
  },
202
226
  isRunning() {
203
227
  return state === WorkerState.RUNNING;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/queue",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Background job and asynchronous task infrastructure with in-memory and adapter-based queue implementations.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -25,9 +25,9 @@
25
25
  "!dist/.tsbuildinfo"
26
26
  ],
27
27
  "dependencies": {
28
- "@zudojs/errors": "1.1.0",
29
- "@zudojs/constants": "1.1.0",
30
- "@zudojs/serialization": "1.1.0"
28
+ "@zudojs/errors": "1.2.0",
29
+ "@zudojs/constants": "1.1.1",
30
+ "@zudojs/serialization": "1.1.1"
31
31
  },
32
32
  "devDependencies": {
33
33
  "typescript": "7.0.2",