@zizq-labs/zizq 0.1.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/dist/worker.js ADDED
@@ -0,0 +1,396 @@
1
+ // Copyright (c) 2026 Chris Corbyn <chris@zizq.io>
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+ /**
4
+ * In-process worker that takes jobs from the Zizq server and dispatches
5
+ * them to a single handler function.
6
+ *
7
+ * The handler can either route jobs manually (e.g. via a `switch` on
8
+ * `job.type`) or use `buildHandler([...])` to build a dispatcher
9
+ * from an array of named `JobFunction`s.
10
+ *
11
+ * @example Function-based dispatch
12
+ * ```ts
13
+ * import { Client, Worker, buildHandler } from "@zizq-labs/zizq";
14
+ *
15
+ * async function sendEmail(payload) { ... }
16
+ * sendEmail.zizqOptions = { queue: "emails" };
17
+ *
18
+ * async function generateReport(payload) { ... }
19
+ * generateReport.zizqOptions = { queue: "reports" };
20
+ *
21
+ * const client = new Client({ url: "http://localhost:7890" });
22
+ * const worker = new Worker({
23
+ * client,
24
+ * concurrency: 10,
25
+ * handler: buildHandler([sendEmail, generateReport]),
26
+ * });
27
+ *
28
+ * // Blocks until stopped.
29
+ * await worker.run();
30
+ * ```
31
+ *
32
+ * @example Manual dispatch (low-level / cross-language)
33
+ * ```ts
34
+ * const worker = new Worker({
35
+ * client,
36
+ * queues: ["payments"],
37
+ * concurrency: 5,
38
+ * handler: async (job) => {
39
+ * switch (job.type) {
40
+ * case "charge_card": return chargeCard(job.payload);
41
+ * case "send_receipt": return sendReceipt(job.payload);
42
+ * default: throw new Error(`Unknown job type: ${job.type}`);
43
+ * }
44
+ * },
45
+ * });
46
+ *
47
+ * await worker.run();
48
+ * ```
49
+ *
50
+ * @example Graceful shutdown
51
+ * ```ts
52
+ * process.on("SIGTERM", () => worker.stop());
53
+ * ```
54
+ *
55
+ * @module
56
+ */
57
+ import { ClientError, } from "./client.js";
58
+ /**
59
+ * In-process worker that takes jobs from the Zizq server and dispatches them
60
+ * to registered handlers.
61
+ *
62
+ * Manages concurrency via a prefetch-based flow control model: the server
63
+ * sends up to `prefetch` unacknowledged jobs, and the worker processes
64
+ * up to `concurrency` of them concurrently using `Promise.race` to stay
65
+ * within the limit.
66
+ *
67
+ * Jobs that complete successfully are acknowledged automatically. Jobs that
68
+ * throw are reported as failures (with error message, type, and stack trace),
69
+ * and the server handles retry scheduling based on the backoff policy.
70
+ */
71
+ export class Worker {
72
+ client;
73
+ concurrency;
74
+ prefetch;
75
+ queues;
76
+ logger;
77
+ retryInitialDelay;
78
+ retryMaxDelay;
79
+ retryMultiplier;
80
+ handler;
81
+ // Two abort controllers for a coordinated two-phase shutdown:
82
+ // - `abortController` is the user-facing signal, fired by `stop()`.
83
+ // When it fires, the worker begins draining in-flight jobs and
84
+ // flushing acks while the take stream is still alive.
85
+ // - `streamController` is the internal signal passed to the take
86
+ // request. It is only aborted *after* the drain + flush completes,
87
+ // so the server keeps jobs as in-flight during the drain and
88
+ // accepts the acks before the connection closes.
89
+ //
90
+ // Reassigned fresh on every `run()` via `resetRuntimeState()`.
91
+ abortController = new AbortController();
92
+ streamController = new AbortController();
93
+ // Set of promises for currently in-flight `processJob` tasks. Exposed
94
+ // as an instance field so the shutdown listener can drain them.
95
+ inFlight = new Set();
96
+ // Promise representing the drain + flush + stream-abort sequence
97
+ // initiated by the shutdown listener. `run()` awaits this before
98
+ // returning so callers can observe any errors from the shutdown path.
99
+ shutdownPromise = Promise.resolve();
100
+ // Set to `true` when `kill()` is called. The drain loop in
101
+ // `startShutdown` checks this to bail out early, and `kill()` itself
102
+ // aborts `streamController` directly so the take loop exits without
103
+ // waiting for the drain.
104
+ //
105
+ // `killDeferred` is a promise that resolves when `kill()` is called.
106
+ // The drain loop races against it so it wakes up immediately on kill,
107
+ // otherwise a slow in-flight job could keep the drain loop blocked
108
+ // on `Promise.allSettled` for an unbounded amount of time.
109
+ killing = false;
110
+ killDeferred = Promise.withResolvers();
111
+ // Bulk ack batching buffer.
112
+ // Success acks are buffered and flushed in a single bulk request.
113
+ // The bulk ack is scheduled via queueMicrotask and only one bulk ack runs at
114
+ // a time. While a bulk ack request is in flight, new acks accumulate and
115
+ // are sent in the next batch.
116
+ pendingAcks = [];
117
+ ackFlushInFlight = false;
118
+ ackFlushPromise = Promise.resolve();
119
+ constructor(options) {
120
+ this.client = options.client;
121
+ this.handler = options.handler;
122
+ this.concurrency = options.concurrency ?? 1;
123
+ this.prefetch = options.prefetch ?? this.concurrency;
124
+ this.logger = options.logger ?? console;
125
+ this.retryInitialDelay = options.requestRetry?.initialDelay ?? 500;
126
+ this.retryMaxDelay = options.requestRetry?.maxDelay ?? 30_000;
127
+ this.retryMultiplier = options.requestRetry?.multiplier ?? 2;
128
+ this.queues = options.queues ?? [];
129
+ }
130
+ /**
131
+ * Start processing jobs. Blocks until {@link stop} is called.
132
+ *
133
+ * Opens a streaming connection to the server's take endpoint and
134
+ * dispatches incoming jobs to the registered handlers concurrently.
135
+ *
136
+ * Automatically reconnects with exponential backoff if the connection
137
+ * drops or the server is unreachable. The backoff is reset after a
138
+ * successful connection. Uses the `requestRetry` configuration.
139
+ *
140
+ * On shutdown, waits for all in-flight jobs to complete and flushes
141
+ * pending acks before returning.
142
+ *
143
+ * @example
144
+ * ```ts
145
+ * const worker = new Worker({ client, jobs: [sendEmail] });
146
+ *
147
+ * // In another context (e.g. signal handler):
148
+ * process.on("SIGTERM", () => worker.stop());
149
+ *
150
+ * // Blocks here until stopped.
151
+ * await worker.run();
152
+ * ```
153
+ */
154
+ async run() {
155
+ this.resetRuntimeState();
156
+ // When the user calls stop(), begin an async shutdown sequence:
157
+ // drain in-flight jobs, flush acks, then abort the stream. The
158
+ // stream stays alive during the drain so the server keeps jobs
159
+ // marked in-flight and accepts the acks.
160
+ this.abortController.signal.addEventListener("abort", () => this.startShutdown(), { once: true });
161
+ let attempt = 0;
162
+ while (!this.abortController.signal.aborted) {
163
+ this.streamController = new AbortController();
164
+ try {
165
+ const takeOpts = {
166
+ prefetch: this.prefetch,
167
+ queues: this.queues.length > 0 ? this.queues : undefined,
168
+ signal: this.streamController.signal,
169
+ };
170
+ const stream = await this.client.take(takeOpts);
171
+ if (attempt > 0) {
172
+ this.logger.info(`[zizq] reconnected to ${this.client.url}`);
173
+ }
174
+ else {
175
+ this.logger.info(`[zizq] connected to ${this.client.url}`);
176
+ }
177
+ attempt = 0;
178
+ for await (const job of stream) {
179
+ // Once stop() has been called, don't dispatch any more jobs.
180
+ // The shutdown listener is draining in-flight and will abort
181
+ // the stream when done. Keep reading so heartbeats flow and
182
+ // the connection stays alive during the drain.
183
+ if (this.abortController.signal.aborted)
184
+ continue;
185
+ const task = this.processJob(job).finally(() => {
186
+ this.inFlight.delete(task);
187
+ });
188
+ this.inFlight.add(task);
189
+ // If we've hit concurrency limit, wait for one to finish.
190
+ if (this.inFlight.size >= this.concurrency) {
191
+ await Promise.race(this.inFlight);
192
+ }
193
+ }
194
+ }
195
+ catch (err) {
196
+ if (this.abortController.signal.aborted) {
197
+ // Expected — stream aborted by the shutdown listener.
198
+ }
199
+ else if (err instanceof ClientError) {
200
+ throw err;
201
+ }
202
+ else {
203
+ attempt++;
204
+ const delay = Math.min(this.retryInitialDelay * Math.pow(this.retryMultiplier, attempt - 1), this.retryMaxDelay);
205
+ this.logger.error(`[zizq] disconnected from ${this.client.url} (attempt ${attempt}, reconnecting in ${delay}ms):`, err instanceof Error ? err.message : err);
206
+ await new Promise((resolve) => setTimeout(resolve, delay));
207
+ continue;
208
+ }
209
+ }
210
+ }
211
+ // Wait for the shutdown sequence initiated by the listener to
212
+ // complete (drain + flush + stream abort).
213
+ await this.shutdownPromise;
214
+ this.logger.info("[zizq] worker stopped");
215
+ }
216
+ /**
217
+ * Reset all mutable runtime state so {@link run} can be called
218
+ * multiple times on the same Worker instance. Called from the top of
219
+ * {@link run}. Config (client, handler, concurrency, etc.) is
220
+ * preserved; only per-run state is wiped.
221
+ */
222
+ resetRuntimeState() {
223
+ this.abortController = new AbortController();
224
+ this.streamController = new AbortController();
225
+ this.inFlight = new Set();
226
+ this.shutdownPromise = Promise.resolve();
227
+ this.killing = false;
228
+ this.killDeferred = Promise.withResolvers();
229
+ this.pendingAcks = [];
230
+ this.ackFlushInFlight = false;
231
+ this.ackFlushPromise = Promise.resolve();
232
+ }
233
+ /**
234
+ * Async sequence triggered by the shutdown listener when `stop()` or
235
+ * `kill()` is called. On a graceful stop, drains in-flight jobs and
236
+ * flushes pending acks before aborting the take stream. On kill, the
237
+ * drain loop bails out early via the `killing` flag, any pending
238
+ * acks are dropped, and the stream is (already) aborted by `kill()`
239
+ * itself.
240
+ *
241
+ * The drain loop races `Promise.allSettled(inFlight)` against
242
+ * `killDeferred.promise` so that a `kill()` call mid-drain wakes the
243
+ * loop immediately, rather than having to wait for an in-flight
244
+ * handler to finish before noticing.
245
+ */
246
+ startShutdown() {
247
+ this.shutdownPromise = (async () => {
248
+ try {
249
+ // Drain in-flight jobs, bailing out early if kill() is called.
250
+ while (this.inFlight.size > 0 && !this.killing) {
251
+ await Promise.race([
252
+ Promise.allSettled([...this.inFlight]),
253
+ this.killDeferred.promise,
254
+ ]);
255
+ }
256
+ if (!this.killing) {
257
+ // Graceful path: flush any pending acks while the stream is
258
+ // still alive.
259
+ await this.ackFlushPromise;
260
+ await this.flushAcks();
261
+ }
262
+ }
263
+ finally {
264
+ // Abort the take stream. Idempotent — if kill() already aborted
265
+ // it, this call is a no-op.
266
+ this.streamController.abort();
267
+ }
268
+ })();
269
+ }
270
+ /**
271
+ * Request a graceful shutdown.
272
+ *
273
+ * Stops dispatching new jobs, waits for all in-flight job handlers to
274
+ * finish, flushes any pending acks, and then aborts the take stream.
275
+ * {@link run} returns once all of that has drained. `stop` is patient
276
+ * — there is no internal deadline. If you need to bound how long
277
+ * shutdown can take, use {@link kill} as an escalation.
278
+ *
279
+ * Callable any number of times; subsequent calls are no-ops.
280
+ */
281
+ stop() {
282
+ this.logger.info("[zizq] stopping worker...");
283
+ if (!this.abortController.signal.aborted) {
284
+ this.abortController.abort();
285
+ }
286
+ }
287
+ /**
288
+ * Force an immediate shutdown.
289
+ *
290
+ * Aborts the take stream right away and skips the drain + flush
291
+ * steps. Any in-flight job handlers that are already running will
292
+ * continue to completion. JavaScript can't cancel promises, but
293
+ * their acks will not be flushed, so the server will re-queue the
294
+ * corresponding jobs once the worker disconnects.
295
+ *
296
+ * Safe to call after `stop()` has already been called; this is the
297
+ * deadline-escalation path. A `stop()` drain that's been running too
298
+ * long will wake up immediately once `kill()` is called, and
299
+ * {@link run} returns shortly after.
300
+ */
301
+ kill() {
302
+ this.logger.info("[zizq] killing worker...");
303
+ this.killing = true;
304
+ // Wake the drain loop in startShutdown() if it's blocked on
305
+ // Promise.allSettled.
306
+ this.killDeferred.resolve();
307
+ // Close the stream immediately so the take loop exits.
308
+ this.streamController.abort();
309
+ // Trigger startShutdown if stop() hasn't been called yet.
310
+ if (!this.abortController.signal.aborted) {
311
+ this.abortController.abort();
312
+ }
313
+ }
314
+ /**
315
+ * Process a single job: dispatch to the handler, then ack or fail.
316
+ *
317
+ * Success acks are batched — the job ID is buffered and a flush is
318
+ * scheduled via `setImmediate`. This means the worker moves on to
319
+ * the next job immediately without waiting for the ack round-trip.
320
+ *
321
+ * Failures are reported individually (they carry per-job error details)
322
+ * and are retried on transient errors.
323
+ */
324
+ async processJob(job) {
325
+ try {
326
+ await this.handler(job);
327
+ this.scheduleAck(job.id);
328
+ }
329
+ catch (err) {
330
+ const failure = {
331
+ message: err instanceof Error ? err.message : String(err),
332
+ errorType: err instanceof Error ? err.constructor.name : undefined,
333
+ backtrace: err instanceof Error ? err.stack : undefined,
334
+ };
335
+ await this.withRetry(() => this.client.reportFailure(job.id, failure));
336
+ }
337
+ }
338
+ /**
339
+ * Buffer a success ack and schedule a flush.
340
+ *
341
+ * If a flush is already in flight, the ack simply accumulates in the
342
+ * buffer; it will be picked up when the current flush completes and
343
+ * schedules the next one.
344
+ */
345
+ scheduleAck(id) {
346
+ this.pendingAcks.push(id);
347
+ if (!this.ackFlushInFlight) {
348
+ this.ackFlushInFlight = true;
349
+ this.ackFlushPromise = new Promise((resolve) => {
350
+ setImmediate(() => this.flushAcks().then(resolve));
351
+ });
352
+ }
353
+ }
354
+ /**
355
+ * Send all buffered acks in a single bulk request.
356
+ *
357
+ * After the request completes (or fails with retry), if more acks have
358
+ * accumulated during the flush, schedule another flush immediately.
359
+ */
360
+ async flushAcks() {
361
+ while (this.pendingAcks.length > 0) {
362
+ const batch = this.pendingAcks;
363
+ this.pendingAcks = [];
364
+ await this.withRetry(() => this.client.reportSuccessBulk(batch));
365
+ }
366
+ this.ackFlushInFlight = false;
367
+ }
368
+ /**
369
+ * Retry an async operation with exponential backoff for transient errors.
370
+ *
371
+ * Client errors (4xx) are not retried — they indicate a permanent problem
372
+ * with the request. Connection errors and server errors (5xx) are retried
373
+ * indefinitely with exponential backoff. Retry timing is configured via
374
+ * `requestRetry` in `WorkerOptions`.
375
+ */
376
+ async withRetry(fn) {
377
+ let attempt = 0;
378
+ while (true) {
379
+ try {
380
+ await fn();
381
+ return;
382
+ }
383
+ catch (err) {
384
+ // Client errors (4xx) are not transient — don't retry.
385
+ if (err instanceof ClientError) {
386
+ this.logger.error("[zizq] ack/nack rejected:", err.message);
387
+ return;
388
+ }
389
+ attempt++;
390
+ const delay = Math.min(this.retryInitialDelay * Math.pow(this.retryMultiplier, attempt - 1), this.retryMaxDelay);
391
+ this.logger.error(`[zizq] ack/nack failed (attempt ${attempt}, retrying in ${delay}ms):`, err instanceof Error ? err.message : err);
392
+ await new Promise((resolve) => setTimeout(resolve, delay));
393
+ }
394
+ }
395
+ }
396
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@zizq-labs/zizq",
3
+ "version": "0.1.0",
4
+ "description": "Node.js client for the Zizq job queue server",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "typecheck": "tsc --noEmit",
20
+ "test": "node --test --experimental-strip-types 'src/**/*.test.ts'"
21
+ },
22
+ "engines": {
23
+ "node": ">=22.19"
24
+ },
25
+ "license": "MIT",
26
+ "dependencies": {
27
+ "@msgpack/msgpack": "^3.1.3",
28
+ "undici": "^8.0.2"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^25.5.2",
32
+ "typescript": "^5.8.0"
33
+ }
34
+ }