bunqueue-client 0.1.6 → 0.1.8
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/CHANGELOG.md +68 -2
- package/README.md +31 -4
- package/dist/bunqueue/bunqueue-api.d.ts +4 -0
- package/dist/bunqueue/bunqueue-api.js +2 -2
- package/dist/bunqueue/bunqueue.js +12 -2
- package/dist/frame.d.ts +1 -1
- package/dist/frame.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/queue-admin.js +16 -2
- package/dist/queue-control.d.ts +5 -0
- package/dist/queue-control.js +24 -5
- package/dist/queue-query.js +7 -3
- package/dist/worker-base.d.ts +13 -3
- package/dist/worker-base.js +15 -8
- package/dist/worker-types.d.ts +30 -2
- package/dist/worker.d.ts +1 -1
- package/dist/worker.js +22 -9
- package/package.json +2 -1
- package/src/bunqueue/bunqueue-api.ts +6 -6
- package/src/bunqueue/bunqueue.ts +12 -1
- package/src/frame.ts +1 -1
- package/src/index.ts +7 -2
- package/src/queue-admin.ts +15 -2
- package/src/queue-control.ts +28 -5
- package/src/queue-query.ts +6 -2
- package/src/worker-base.ts +48 -12
- package/src/worker-types.ts +28 -2
- package/src/worker.ts +23 -11
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,72 @@ All notable changes to `bunqueue-client` (TypeScript SDK) are documented here.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.1.8] - 2026-07-14
|
|
9
|
+
|
|
10
|
+
Spec-alignment audit against the core protocol. Every fix ships with a repro
|
|
11
|
+
test in `tests/e2e-spec-align.ts`.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **`heartbeatIntervalS: 0` now disables heartbeats.** Previously it armed
|
|
16
|
+
`setInterval(fn, 0)`, flooding the server with hundreds of `Heartbeat`
|
|
17
|
+
commands per second. `0` (or negative) now matches the official client's
|
|
18
|
+
"0 = disabled" semantics.
|
|
19
|
+
- **`batchSize` is clamped to the server maximum (1000).** The server rejects
|
|
20
|
+
`PULLB` with `count > 1000`; an unclamped `batchSize` combined with
|
|
21
|
+
`concurrency > 1000` wedged the pull loop in a permanent error cycle.
|
|
22
|
+
- **Simple Mode `cron()`/`every()` forward the execution `limit`.** The option
|
|
23
|
+
was silently dropped (the "client drops a wire-supported field" class,
|
|
24
|
+
#111); it now reaches the scheduler as wire `maxLimit`, matching the
|
|
25
|
+
official client's signature.
|
|
26
|
+
- **`waitForJob()` clamps `ttlMs` to the server cap (600000).** Larger values
|
|
27
|
+
were rejected by the server with "timeout must be at most 600000" instead
|
|
28
|
+
of waiting.
|
|
29
|
+
- **`PROTOCOL_VERSION` bumped to 2**, matching the version the server
|
|
30
|
+
advertises in `Hello`.
|
|
31
|
+
|
|
32
|
+
## [0.1.7] - 2026-07-10
|
|
33
|
+
|
|
34
|
+
Audit fixes: typed worker events, error-path hygiene and two more members of
|
|
35
|
+
the "client drops a wire-supported field" class (#111).
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- **Typed Worker events.** `worker.on('completed', (job, result) => ...)` now
|
|
40
|
+
gets typed `Job<T>`/`R`/`Error` parameters in strict mode instead of
|
|
41
|
+
`unknown[]` (TS18046). The new `WorkerEventMap<T, R>` covers `ready`,
|
|
42
|
+
`active`, `completed`, `failed`, `progress`, `error`, `drained`, `cancelled`
|
|
43
|
+
and `closed`; unknown event names keep a generic overload, so existing code
|
|
44
|
+
compiles unchanged. (H1)
|
|
45
|
+
- `"prepublishOnly": "bun run build"` so a publish can never ship a stale
|
|
46
|
+
`dist/`. (H3)
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- **Bunqueue constructor crash vector.** `new Bunqueue(..., { dlq })` fired
|
|
51
|
+
`setDlqConfig` with no rejection handler: an unreachable server at
|
|
52
|
+
construction time killed the process with an unhandled rejection. The
|
|
53
|
+
failure now routes to the worker's `'error'` event (swallowed when no
|
|
54
|
+
listener is attached, matching `pause()`/`resume()`). (H2)
|
|
55
|
+
- **ACK/completed asymmetry.** In the non-batched path the worker emitted
|
|
56
|
+
`'completed'` and incremented `processed` even when the ACK never reached
|
|
57
|
+
the server. Both the ACK and FAIL paths now mirror the batched semantics:
|
|
58
|
+
on a wire failure only `'error'` fires, with no counter increment. Errors
|
|
59
|
+
emitted on `'error'` are now always `Error` instances. (M1)
|
|
60
|
+
- **Not-found swallowing.** `getJobScheduler`, `getJob` and
|
|
61
|
+
`getJobByCustomId` caught every error (including `ConnectionClosedError`
|
|
62
|
+
and `CommandTimeoutError`) and returned `null`. The catch is narrowed to a
|
|
63
|
+
`CommandError` matching `/not found/i`; everything else rethrows. (M2)
|
|
64
|
+
- **Scheduler template priority/deduplication dropped.**
|
|
65
|
+
`upsertJobScheduler` put `priority` inside `jobOptions`, where the server's
|
|
66
|
+
`CronJobOptions` ignores it, and never sent the template's deduplication.
|
|
67
|
+
Both now travel as the top-level `priority`/`uniqueKey`/`dedup` Cron fields
|
|
68
|
+
the handler reads, matching the reference client. (#111 class, F3)
|
|
69
|
+
- **moveJobToFailed lost the stack and the unrecoverable flag.** It sent only
|
|
70
|
+
`error.message`; when given an `Error` it now sends the leading stack lines
|
|
71
|
+
and `unrecoverable: true` for `UnrecoverableError`, mirroring the worker
|
|
72
|
+
FAIL path. (#111 class, F4)
|
|
73
|
+
|
|
8
74
|
## [0.1.6] - 2026-07-09
|
|
9
75
|
|
|
10
76
|
Enterprise-grade hardening. All additive and backward-compatible; defaults are
|
|
@@ -33,8 +99,8 @@ unchanged (observability is silent, backpressure unbounded, ACK batching off).
|
|
|
33
99
|
### CI
|
|
34
100
|
|
|
35
101
|
- GitHub Actions runs both SDK suites on every `sdk/`/`src/` change (TypeScript
|
|
36
|
-
on Bun + Node, Python 3.10/3.12); an npm release workflow publishes
|
|
37
|
-
build provenance.
|
|
102
|
+
on Bun + Node + Deno, Python 3.10/3.12); an npm release workflow publishes
|
|
103
|
+
with build provenance, gated on the e2e suite.
|
|
38
104
|
|
|
39
105
|
## [0.1.5] - 2026-07-08
|
|
40
106
|
|
package/README.md
CHANGED
|
@@ -1,16 +1,43 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
<a href="https://bunqueue.dev">
|
|
4
|
+
<img src="https://raw.githubusercontent.com/egeominotti/bunqueue/main/.github/logo.png" alt="bunqueue logo" width="110" />
|
|
5
|
+
</a>
|
|
6
|
+
|
|
1
7
|
# bunqueue-client
|
|
2
8
|
|
|
3
|
-
|
|
9
|
+
**The official TypeScript client for [bunqueue](https://bunqueue.dev), the high performance job queue server.**
|
|
10
|
+
|
|
11
|
+
Native TCP protocol (msgpack, pipelined), full parity with the built in Bun client, one runtime dependency.
|
|
12
|
+
Runs everywhere: Node.js, Bun, Deno and Cloudflare Workers.
|
|
13
|
+
|
|
14
|
+
[](https://www.npmjs.com/package/bunqueue-client)
|
|
15
|
+
[](https://www.npmjs.com/package/bunqueue-client)
|
|
16
|
+
[](https://github.com/egeominotti/bunqueue/blob/main/sdk/typescript/LICENSE)
|
|
17
|
+
[](#compatibility)
|
|
18
|
+
|
|
19
|
+
[Documentation](https://bunqueue.dev/guide/sdks/) · [Server](https://github.com/egeominotti/bunqueue) · [Changelog](https://github.com/egeominotti/bunqueue/blob/main/sdk/typescript/CHANGELOG.md) · [Python SDK](https://github.com/egeominotti/bunqueue/tree/main/sdk/python)
|
|
20
|
+
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
---
|
|
4
24
|
|
|
5
25
|
The bunqueue server runs on Bun, distributed as a binary or a Docker image. This client allows any Node.js, Bun, Deno, or Cloudflare Workers service to produce and consume jobs against it: one queue, any language, any runtime.
|
|
6
26
|
|
|
27
|
+
## Why bunqueue-client
|
|
28
|
+
|
|
29
|
+
- **Full API surface.** Queues, workers, flows (parent/children trees), schedulers, DLQ, rate limits, webhooks, Simple Mode: 110+ public methods, each covered by an e2e test against a real server.
|
|
30
|
+
- **Cross runtime by design.** Only `node:*` builtins, zero `Bun.*` globals, a single dependency (`msgpackr`). The same package runs on Node 20+, Bun, Deno 2 and Cloudflare Workers (`nodejs_compat`).
|
|
31
|
+
- **Production semantics.** Lock leasing with heartbeat renewal, at least once delivery with retries and backoff, unrecoverable failures straight to the DLQ, reconnection with half open detection, opt in ACK batching and connection pooling.
|
|
32
|
+
- **Typed end to end.** Generic `Queue<T>` / `Worker<T, R>`, typed worker events, structured telemetry hooks for your metrics stack.
|
|
33
|
+
|
|
7
34
|
## Compatibility
|
|
8
35
|
|
|
9
36
|
| Runtime | Status | Notes |
|
|
10
37
|
|---|---|---|
|
|
11
|
-
| Node.js 20 or later | Supported,
|
|
12
|
-
| Bun | Supported,
|
|
13
|
-
| Deno 2 or later | Supported,
|
|
38
|
+
| Node.js 20 or later | Supported, 110/110 e2e and 8/8 integration tests | ESM. TypeScript files run directly on Node 22 or later via `--experimental-strip-types` |
|
|
39
|
+
| Bun | Supported, 110/110 e2e and 8/8 integration tests | No additional configuration required |
|
|
40
|
+
| Deno 2 or later | Supported, 110/110 e2e and 8/8 integration tests | Uses `node:` builtins and the npm `msgpackr` package |
|
|
14
41
|
| tsx, ts-node, vitest, jest | Supported | These environments execute on Node.js |
|
|
15
42
|
| Cloudflare Workers | Supported, 16/16 e2e tests inside workerd, including Simple Mode and the full API surface | Requires the `nodejs_compat` compatibility flag. The runtime is request scoped, so long lived worker loops are not available: consume in batches from Cron Triggers or Durable Object alarms, a pattern covered by the test suite. TLS connections require a publicly trusted certificate |
|
|
16
43
|
| Browser | Not supported | Raw TCP sockets are unavailable. Use the server HTTP API instead |
|
|
@@ -13,9 +13,11 @@ type Ctx = Bunqueue<any, any>;
|
|
|
13
13
|
export declare const bunqueueApi: {
|
|
14
14
|
cron(this: Ctx, id: string, pattern: string, data?: unknown, opts?: {
|
|
15
15
|
timezone?: string;
|
|
16
|
+
limit?: number;
|
|
16
17
|
jobOpts?: JobOptions;
|
|
17
18
|
}): Promise<Raw | null>;
|
|
18
19
|
every(this: Ctx, id: string, intervalMs: number, data?: unknown, opts?: {
|
|
20
|
+
limit?: number;
|
|
19
21
|
jobOpts?: JobOptions;
|
|
20
22
|
}): Promise<Raw | null>;
|
|
21
23
|
removeCron(this: Ctx, id: string): Promise<void>;
|
|
@@ -50,9 +52,11 @@ export declare const bunqueueApi: {
|
|
|
50
52
|
export interface BunqueueApi<T = unknown, R = unknown> {
|
|
51
53
|
cron(id: string, pattern: string, data?: T, opts?: {
|
|
52
54
|
timezone?: string;
|
|
55
|
+
limit?: number;
|
|
53
56
|
jobOpts?: JobOptions;
|
|
54
57
|
}): Promise<Raw | null>;
|
|
55
58
|
every(id: string, intervalMs: number, data?: T, opts?: {
|
|
59
|
+
limit?: number;
|
|
56
60
|
jobOpts?: JobOptions;
|
|
57
61
|
}): Promise<Raw | null>;
|
|
58
62
|
removeCron(id: string): Promise<void>;
|
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
export const bunqueueApi = {
|
|
7
7
|
// --------------------------------------------------------------------- cron
|
|
8
8
|
async cron(id, pattern, data, opts) {
|
|
9
|
-
await this.queue.upsertJobScheduler(id, { pattern, tz: opts?.timezone }, { name: id, data, opts: opts?.jobOpts });
|
|
9
|
+
await this.queue.upsertJobScheduler(id, { pattern, tz: opts?.timezone, limit: opts?.limit }, { name: id, data, opts: opts?.jobOpts });
|
|
10
10
|
return this.queue.getJobScheduler(id);
|
|
11
11
|
},
|
|
12
12
|
async every(id, intervalMs, data, opts) {
|
|
13
|
-
await this.queue.upsertJobScheduler(id, { every: intervalMs }, { name: id, data, opts: opts?.jobOpts });
|
|
13
|
+
await this.queue.upsertJobScheduler(id, { every: intervalMs, limit: opts?.limit }, { name: id, data, opts: opts?.jobOpts });
|
|
14
14
|
return this.queue.getJobScheduler(id);
|
|
15
15
|
},
|
|
16
16
|
removeCron(id) {
|
|
@@ -76,8 +76,18 @@ export class Bunqueue {
|
|
|
76
76
|
});
|
|
77
77
|
// DLQ & rate limit manager
|
|
78
78
|
this.dlqrl = new DlqRateLimitManager(this.queue);
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
// Fire-and-forget config push: without the catch, an unreachable server at
|
|
80
|
+
// construction time becomes an unhandled rejection that kills the process.
|
|
81
|
+
// Route the failure to the worker's 'error' event (the channel every other
|
|
82
|
+
// background command failure uses); with no listener attached, swallow it
|
|
83
|
+
// like pause()/resume() do — an unlistened 'error' emit would itself throw.
|
|
84
|
+
if (opts.dlq) {
|
|
85
|
+
void this.dlqrl.setDlqConfig(opts.dlq).catch((err) => {
|
|
86
|
+
if (this.worker.listenerCount('error') > 0) {
|
|
87
|
+
this.worker.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
81
91
|
// Subsystems
|
|
82
92
|
this.cb = opts.circuitBreaker
|
|
83
93
|
? new WorkerCircuitBreaker(opts.circuitBreaker, this.worker)
|
package/dist/frame.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Wire framing for the bunqueue TCP protocol: 4-byte big-endian length
|
|
3
3
|
* prefix + msgpack payload. Runtime-neutral (plain Buffer operations).
|
|
4
4
|
*/
|
|
5
|
-
export declare const PROTOCOL_VERSION =
|
|
5
|
+
export declare const PROTOCOL_VERSION = 2;
|
|
6
6
|
export declare const MAX_FRAME_SIZE: number;
|
|
7
7
|
/** Drop undefined-valued keys so the msgpack frame stays minimal. */
|
|
8
8
|
export declare function compact<T extends Record<string, unknown>>(obj: T): T;
|
package/dist/frame.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* prefix + msgpack payload. Runtime-neutral (plain Buffer operations).
|
|
4
4
|
*/
|
|
5
5
|
import { ConnectionClosedError } from './errors.js';
|
|
6
|
-
export const PROTOCOL_VERSION =
|
|
6
|
+
export const PROTOCOL_VERSION = 2;
|
|
7
7
|
export const MAX_FRAME_SIZE = 64 * 1024 * 1024; // mirror server-side limit
|
|
8
8
|
/** Drop undefined-valued keys so the msgpack frame stays minimal. */
|
|
9
9
|
export function compact(obj) {
|
package/dist/index.d.ts
CHANGED
|
@@ -23,5 +23,5 @@ export type { SchedulerOptions } from './queue-admin.js';
|
|
|
23
23
|
export type { BatchResponse, CountResponse, DataResponse, JobCountsResponse, JobResponse, JobsResponse, OkResponse, PausedResponse, ProgressResponse, PulledJobResponse, PulledJobsResponse, ResultResponse, StateResponse, WaitJobResponse, } from './responses.js';
|
|
24
24
|
export type { BackoffOptions, DeduplicationOptions, JobCounts, JobOptions, JobStateName, RepeatOptions, } from './types.js';
|
|
25
25
|
export { Worker } from './worker.js';
|
|
26
|
-
export type { AckBatchOptions, Processor, WorkerOptions } from './worker-types.js';
|
|
27
|
-
export declare const __version__ = "0.1.
|
|
26
|
+
export type { AckBatchOptions, Processor, WorkerEventMap, WorkerOptions, } from './worker-types.js';
|
|
27
|
+
export declare const __version__ = "0.1.7";
|
package/dist/index.js
CHANGED
package/dist/queue-admin.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Queue admin surface: DLQ, stall/DLQ configs, rate limits, schedulers,
|
|
3
3
|
* monitoring and webhooks. Merged onto Queue.prototype by queue.ts.
|
|
4
4
|
*/
|
|
5
|
+
import { CommandError } from './errors.js';
|
|
5
6
|
import { compact } from './frame.js';
|
|
6
7
|
import { jobPayload, wireJobOptions } from './types.js';
|
|
7
8
|
export const adminMethods = {
|
|
@@ -50,6 +51,10 @@ export const adminMethods = {
|
|
|
50
51
|
// ---------------------------------------------------------------- scheduler
|
|
51
52
|
/** Create/update a recurring job scheduler (cron pattern or fixed interval). */
|
|
52
53
|
async upsertJobScheduler(schedulerId, repeat, template = {}) {
|
|
54
|
+
// Priority and deduplication of spawned jobs travel as TOP-LEVEL Cron
|
|
55
|
+
// fields (the handler reads cmd.priority/uniqueKey/dedup); inside
|
|
56
|
+
// jobOptions the server's CronJobOptions silently ignores them.
|
|
57
|
+
const dedup = template.opts?.deduplication;
|
|
53
58
|
await this.call(compact({
|
|
54
59
|
cmd: 'Cron',
|
|
55
60
|
name: schedulerId,
|
|
@@ -57,9 +62,14 @@ export const adminMethods = {
|
|
|
57
62
|
data: jobPayload(template.name ?? schedulerId, template.data ?? {}),
|
|
58
63
|
schedule: repeat.pattern,
|
|
59
64
|
repeatEvery: repeat.every,
|
|
65
|
+
priority: template.opts?.priority,
|
|
60
66
|
timezone: repeat.tz,
|
|
61
67
|
immediately: repeat.immediately,
|
|
62
68
|
maxLimit: repeat.limit,
|
|
69
|
+
uniqueKey: dedup?.id,
|
|
70
|
+
dedup: dedup
|
|
71
|
+
? compact({ ttl: dedup.ttl, extend: dedup.extend, replace: dedup.replace })
|
|
72
|
+
: undefined,
|
|
63
73
|
skipMissedOnRestart: repeat.skipMissedOnRestart,
|
|
64
74
|
skipIfNoWorker: repeat.skipIfNoWorker,
|
|
65
75
|
preventOverlap: repeat.preventOverlap,
|
|
@@ -74,8 +84,12 @@ export const adminMethods = {
|
|
|
74
84
|
const response = await this.call({ cmd: 'CronGet', name: schedulerId });
|
|
75
85
|
return (response.cron ?? response.data ?? null);
|
|
76
86
|
}
|
|
77
|
-
catch {
|
|
78
|
-
|
|
87
|
+
catch (err) {
|
|
88
|
+
// Only 'Cron job not found' maps to null; connection loss, timeouts and
|
|
89
|
+
// real server errors must surface, not masquerade as a missing scheduler.
|
|
90
|
+
if (err instanceof CommandError && /not found/i.test(err.message))
|
|
91
|
+
return null;
|
|
92
|
+
throw err;
|
|
79
93
|
}
|
|
80
94
|
},
|
|
81
95
|
async getJobSchedulers() {
|
package/dist/queue-control.d.ts
CHANGED
|
@@ -38,6 +38,11 @@ export declare const controlMethods: {
|
|
|
38
38
|
moveJobToDelayed(this: Ctx, id: string, delayMs: number): Promise<void>;
|
|
39
39
|
extendJobLock(this: Ctx, id: string, token: string, durationMs: number): Promise<void>;
|
|
40
40
|
moveJobToCompleted(this: Ctx, id: string, returnValue: unknown, token?: string): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Explicit failure path: mirrors the worker's FAIL wire so the stacktrace
|
|
43
|
+
* and the UnrecoverableError "do not retry" intent are persisted (#111
|
|
44
|
+
* silent-loss class), not just the message.
|
|
45
|
+
*/
|
|
41
46
|
moveJobToFailed(this: Ctx, id: string, error: Error | string, token?: string): Promise<void>;
|
|
42
47
|
};
|
|
43
48
|
export type QueueControlApi = typeof controlMethods;
|
package/dist/queue-control.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* Queue control surface: pause/drain/clean, promotion, retry and per-job
|
|
3
3
|
* mutations. Methods are merged onto Queue.prototype by queue.ts.
|
|
4
4
|
*/
|
|
5
|
+
import { UnrecoverableError } from './errors.js';
|
|
5
6
|
import { compact } from './frame.js';
|
|
7
|
+
import { MAX_STACK_LINES } from './worker-types.js';
|
|
6
8
|
export const controlMethods = {
|
|
7
9
|
async pause() {
|
|
8
10
|
await this.call({ cmd: 'Pause', queue: this.name });
|
|
@@ -47,9 +49,9 @@ export const controlMethods = {
|
|
|
47
49
|
await this.call({ cmd: 'RetryCompleted', queue: this.name });
|
|
48
50
|
return;
|
|
49
51
|
}
|
|
50
|
-
// `count`
|
|
51
|
-
//
|
|
52
|
-
await this.call({ cmd: 'RetryDlq', queue: this.name });
|
|
52
|
+
// `count` caps how many DLQ entries are retried (server >= 2.8.29). Older
|
|
53
|
+
// servers ignore the field and retry the whole DLQ — forward-compatible.
|
|
54
|
+
await this.call(compact({ cmd: 'RetryDlq', queue: this.name, count: opts.count }));
|
|
53
55
|
},
|
|
54
56
|
async retryCompleted(id) {
|
|
55
57
|
await this.call(compact({ cmd: 'RetryCompleted', queue: this.name, id }));
|
|
@@ -79,8 +81,25 @@ export const controlMethods = {
|
|
|
79
81
|
async moveJobToCompleted(id, returnValue, token) {
|
|
80
82
|
await this.call(compact({ cmd: 'ACK', id, result: returnValue, token }));
|
|
81
83
|
},
|
|
84
|
+
/**
|
|
85
|
+
* Explicit failure path: mirrors the worker's FAIL wire so the stacktrace
|
|
86
|
+
* and the UnrecoverableError "do not retry" intent are persisted (#111
|
|
87
|
+
* silent-loss class), not just the message.
|
|
88
|
+
*/
|
|
82
89
|
async moveJobToFailed(id, error, token) {
|
|
83
|
-
const
|
|
84
|
-
|
|
90
|
+
const err = typeof error === 'string' ? undefined : error;
|
|
91
|
+
const message = err ? err.message || err.name : error;
|
|
92
|
+
// Keep the FIRST lines (message + throw site), like the worker path.
|
|
93
|
+
const stack = err
|
|
94
|
+
? (err.stack ?? err.message).split('\n').slice(0, MAX_STACK_LINES)
|
|
95
|
+
: undefined;
|
|
96
|
+
await this.call(compact({
|
|
97
|
+
cmd: 'FAIL',
|
|
98
|
+
id,
|
|
99
|
+
error: message,
|
|
100
|
+
stack,
|
|
101
|
+
unrecoverable: err instanceof UnrecoverableError ? true : undefined,
|
|
102
|
+
token,
|
|
103
|
+
}));
|
|
85
104
|
},
|
|
86
105
|
};
|
package/dist/queue-query.js
CHANGED
|
@@ -16,8 +16,10 @@ export const queryMethods = {
|
|
|
16
16
|
return response.job ? new Job(response.job, this.connection) : null;
|
|
17
17
|
}
|
|
18
18
|
catch (err) {
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
// Only the server's 'Job not found' maps to null; connection loss,
|
|
20
|
+
// timeouts and other server errors must surface.
|
|
21
|
+
if (err instanceof CommandError && /not found/i.test(err.message))
|
|
22
|
+
return null;
|
|
21
23
|
throw err;
|
|
22
24
|
}
|
|
23
25
|
},
|
|
@@ -27,7 +29,7 @@ export const queryMethods = {
|
|
|
27
29
|
return response.job ? new Job(response.job, this.connection) : null;
|
|
28
30
|
}
|
|
29
31
|
catch (err) {
|
|
30
|
-
if (err instanceof CommandError)
|
|
32
|
+
if (err instanceof CommandError && /not found/i.test(err.message))
|
|
31
33
|
return null;
|
|
32
34
|
throw err;
|
|
33
35
|
}
|
|
@@ -104,6 +106,8 @@ export const queryMethods = {
|
|
|
104
106
|
* will not complete), everything else throws CommandTimeoutError.
|
|
105
107
|
*/
|
|
106
108
|
async waitForJob(id, ttlMs = 30_000) {
|
|
109
|
+
// The server validates 0 <= timeout <= 600000: clamp instead of erroring.
|
|
110
|
+
ttlMs = Math.min(Math.max(ttlMs, 0), 600_000);
|
|
107
111
|
const response = await this.call({ cmd: 'WaitJob', id, timeout: ttlMs }, ttlMs + 5000);
|
|
108
112
|
if (response.completed !== true) {
|
|
109
113
|
let state;
|
package/dist/worker-base.d.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { EventEmitter } from 'node:events';
|
|
6
6
|
import { Connection } from './connection.js';
|
|
7
|
-
import { type WorkerOptions } from './worker-types.js';
|
|
8
|
-
export declare class WorkerBase extends EventEmitter {
|
|
7
|
+
import { type WorkerEventMap, type WorkerOptions } from './worker-types.js';
|
|
8
|
+
export declare class WorkerBase<T = unknown, R = unknown> extends EventEmitter {
|
|
9
9
|
readonly queue: string;
|
|
10
10
|
readonly concurrency: number;
|
|
11
11
|
readonly batchSize: number;
|
|
@@ -41,9 +41,16 @@ export declare class WorkerBase extends EventEmitter {
|
|
|
41
41
|
* 'ready' is replayed to listeners attached after it fired: with autorun the
|
|
42
42
|
* loop starts inside the constructor, so a plain once-only event could be
|
|
43
43
|
* missed by `new Worker(...).on('ready', ...)` patterns.
|
|
44
|
+
*
|
|
45
|
+
* The overloads give the known worker events typed parameters (see
|
|
46
|
+
* WorkerEventMap); unknown event names keep the generic signature.
|
|
44
47
|
*/
|
|
48
|
+
on<E extends keyof WorkerEventMap<T, R>>(event: E, listener: WorkerEventMap<T, R>[E]): this;
|
|
45
49
|
on(event: string | symbol, listener: (...args: unknown[]) => void): this;
|
|
50
|
+
once<E extends keyof WorkerEventMap<T, R>>(event: E, listener: WorkerEventMap<T, R>[E]): this;
|
|
46
51
|
once(event: string | symbol, listener: (...args: unknown[]) => void): this;
|
|
52
|
+
off<E extends keyof WorkerEventMap<T, R>>(event: E, listener: WorkerEventMap<T, R>[E]): this;
|
|
53
|
+
off(event: string | symbol, listener: (...args: unknown[]) => void): this;
|
|
47
54
|
/**
|
|
48
55
|
* Cooperative cancel of a locally active job (mirrors the official client):
|
|
49
56
|
* marks the job and emits 'cancelled'; the processor is expected to check
|
|
@@ -56,9 +63,12 @@ export declare class WorkerBase extends EventEmitter {
|
|
|
56
63
|
* With `force` the wait for in-flight jobs is skipped (parity with the
|
|
57
64
|
* official client's `close(force)`). */
|
|
58
65
|
close(force?: boolean): Promise<void>;
|
|
66
|
+
/** Dispatch a command, routing failures to 'error'. Returns whether the
|
|
67
|
+
* command reached the server — callers gate success-only side effects
|
|
68
|
+
* ('completed'/'failed' emits, counters) on it. */
|
|
59
69
|
protected safeCall(command: Record<string, unknown> & {
|
|
60
70
|
cmd: string;
|
|
61
|
-
}): Promise<
|
|
71
|
+
}): Promise<boolean>;
|
|
62
72
|
/** Hook run during close() before draining in-flight jobs (see Worker). */
|
|
63
73
|
protected beforeClose(): Promise<void>;
|
|
64
74
|
}
|
package/dist/worker-base.js
CHANGED
|
@@ -6,7 +6,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
6
6
|
import { EventEmitter } from 'node:events';
|
|
7
7
|
import { hostname } from 'node:os';
|
|
8
8
|
import { Connection } from './connection.js';
|
|
9
|
-
import { MAX_POLL_TIMEOUT_MS, sleep } from './worker-types.js';
|
|
9
|
+
import { MAX_POLL_TIMEOUT_MS, sleep, } from './worker-types.js';
|
|
10
10
|
export class WorkerBase extends EventEmitter {
|
|
11
11
|
queue;
|
|
12
12
|
concurrency;
|
|
@@ -38,7 +38,11 @@ export class WorkerBase extends EventEmitter {
|
|
|
38
38
|
throw new Error('concurrency must be >= 1');
|
|
39
39
|
this.queue = queue;
|
|
40
40
|
this.concurrency = opts.concurrency ?? 4;
|
|
41
|
-
|
|
41
|
+
// The server rejects PULLB count > 1000 (handlers/core.ts) — an unclamped
|
|
42
|
+
// batchSize would wedge the pull loop in a permanent error cycle. The
|
|
43
|
+
// finite-guard also catches NaN, which would otherwise pass both bounds.
|
|
44
|
+
const rawBatch = opts.batchSize ?? 10;
|
|
45
|
+
this.batchSize = Number.isFinite(rawBatch) ? Math.min(Math.max(1, rawBatch), 1000) : 10;
|
|
42
46
|
this.pollTimeoutMs = Math.min(opts.pollTimeoutMs ?? 5000, MAX_POLL_TIMEOUT_MS);
|
|
43
47
|
this.lockTtlMs = opts.lockTtlMs ?? 30_000;
|
|
44
48
|
this.heartbeatIntervalS = opts.heartbeatIntervalS ?? 10;
|
|
@@ -74,11 +78,6 @@ export class WorkerBase extends EventEmitter {
|
|
|
74
78
|
async waitUntilReady() {
|
|
75
79
|
await this.readyPromise;
|
|
76
80
|
}
|
|
77
|
-
/**
|
|
78
|
-
* 'ready' is replayed to listeners attached after it fired: with autorun the
|
|
79
|
-
* loop starts inside the constructor, so a plain once-only event could be
|
|
80
|
-
* missed by `new Worker(...).on('ready', ...)` patterns.
|
|
81
|
-
*/
|
|
82
81
|
on(event, listener) {
|
|
83
82
|
if (event === 'ready' && this.readyFired)
|
|
84
83
|
listener();
|
|
@@ -91,6 +90,9 @@ export class WorkerBase extends EventEmitter {
|
|
|
91
90
|
}
|
|
92
91
|
return super.once(event, listener);
|
|
93
92
|
}
|
|
93
|
+
off(event, listener) {
|
|
94
|
+
return super.off(event, listener);
|
|
95
|
+
}
|
|
94
96
|
/**
|
|
95
97
|
* Cooperative cancel of a locally active job (mirrors the official client):
|
|
96
98
|
* marks the job and emits 'cancelled'; the processor is expected to check
|
|
@@ -139,12 +141,17 @@ export class WorkerBase extends EventEmitter {
|
|
|
139
141
|
this.running = false;
|
|
140
142
|
this.emit('closed');
|
|
141
143
|
}
|
|
144
|
+
/** Dispatch a command, routing failures to 'error'. Returns whether the
|
|
145
|
+
* command reached the server — callers gate success-only side effects
|
|
146
|
+
* ('completed'/'failed' emits, counters) on it. */
|
|
142
147
|
async safeCall(command) {
|
|
143
148
|
try {
|
|
144
149
|
await this.connection.call(command);
|
|
150
|
+
return true;
|
|
145
151
|
}
|
|
146
152
|
catch (err) {
|
|
147
|
-
this.emit('error', err);
|
|
153
|
+
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
154
|
+
return false;
|
|
148
155
|
}
|
|
149
156
|
}
|
|
150
157
|
/** Hook run during close() before draining in-flight jobs (see Worker). */
|
package/dist/worker-types.d.ts
CHANGED
|
@@ -3,6 +3,34 @@ import type { TlsOption } from './connection.js';
|
|
|
3
3
|
import type { Job } from './job.js';
|
|
4
4
|
import type { Observability } from './observability.js';
|
|
5
5
|
export type Processor<T = unknown, R = unknown> = (job: Job<T>) => R | Promise<R>;
|
|
6
|
+
/**
|
|
7
|
+
* Typed Worker event map: listeners registered via `on`/`once`/`off` for these
|
|
8
|
+
* names get typed job/result/error parameters in strict mode. Unknown event
|
|
9
|
+
* names fall back to a generic `(...args: unknown[])` overload.
|
|
10
|
+
*/
|
|
11
|
+
export interface WorkerEventMap<T = unknown, R = unknown> {
|
|
12
|
+
/** Worker registered and pull loop started (replayed to late listeners). */
|
|
13
|
+
ready: () => void;
|
|
14
|
+
/** A job was pulled and handed to the processor. */
|
|
15
|
+
active: (job: Job<T>) => void;
|
|
16
|
+
/** Processor resolved AND the ACK reached the server. */
|
|
17
|
+
completed: (job: Job<T>, result: R) => void;
|
|
18
|
+
/** Processor threw AND the FAIL reached the server. */
|
|
19
|
+
failed: (job: Job<T>, error: Error) => void;
|
|
20
|
+
/** job.updateProgress() was called from the processor. */
|
|
21
|
+
progress: (job: Job<T>, progress: number) => void;
|
|
22
|
+
/** Connection/command error (pull loop, ACK/FAIL, heartbeat, ...). */
|
|
23
|
+
error: (error: Error) => void;
|
|
24
|
+
/** The queue went from busy to empty (no active jobs, nothing pulled). */
|
|
25
|
+
drained: () => void;
|
|
26
|
+
/** Cooperative cancel was requested for a locally active job. */
|
|
27
|
+
cancelled: (info: {
|
|
28
|
+
jobId: string;
|
|
29
|
+
reason: string;
|
|
30
|
+
}) => void;
|
|
31
|
+
/** close() finished. */
|
|
32
|
+
closed: () => void;
|
|
33
|
+
}
|
|
6
34
|
export interface AckBatchOptions {
|
|
7
35
|
/** Batch ACKs into ACKB round-trips (default false; opt-in for throughput). */
|
|
8
36
|
enabled?: boolean;
|
|
@@ -23,13 +51,13 @@ export interface WorkerOptions extends Observability {
|
|
|
23
51
|
ackBatch?: AckBatchOptions;
|
|
24
52
|
/** Max jobs processed in parallel (default 4). */
|
|
25
53
|
concurrency?: number;
|
|
26
|
-
/** Max jobs fetched per PULLB (default 10, capped by free slots). */
|
|
54
|
+
/** Max jobs fetched per PULLB (default 10, capped by free slots and the server max 1000). */
|
|
27
55
|
batchSize?: number;
|
|
28
56
|
/** Server-side long-poll timeout in ms (default 5000, max 30000). */
|
|
29
57
|
pollTimeoutMs?: number;
|
|
30
58
|
/** Job lock TTL in ms (default 30000). */
|
|
31
59
|
lockTtlMs?: number;
|
|
32
|
-
/** Worker + job heartbeat interval in seconds (default 10). */
|
|
60
|
+
/** Worker + job heartbeat interval in seconds (default 10, 0 = disabled). */
|
|
33
61
|
heartbeatIntervalS?: number;
|
|
34
62
|
/** Start the loop at construction (default true, mirrors the TS client). */
|
|
35
63
|
autorun?: boolean;
|
package/dist/worker.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { WorkerBase } from './worker-base.js';
|
|
9
9
|
import { type Processor, type WorkerOptions } from './worker-types.js';
|
|
10
|
-
export declare class Worker<T = unknown, R = unknown> extends WorkerBase {
|
|
10
|
+
export declare class Worker<T = unknown, R = unknown> extends WorkerBase<T, R> {
|
|
11
11
|
private readonly processor;
|
|
12
12
|
private readonly ackBatcher;
|
|
13
13
|
constructor(queue: string, processor: Processor<T, R>, opts?: WorkerOptions);
|
package/dist/worker.js
CHANGED
|
@@ -36,7 +36,7 @@ export class Worker extends WorkerBase {
|
|
|
36
36
|
return;
|
|
37
37
|
this.running = true;
|
|
38
38
|
this.loopPromise = this.loop().catch((err) => {
|
|
39
|
-
this.emit('error', err);
|
|
39
|
+
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
40
40
|
});
|
|
41
41
|
}
|
|
42
42
|
// -------------------------------------------------------------------- loop
|
|
@@ -57,7 +57,7 @@ export class Worker extends WorkerBase {
|
|
|
57
57
|
backoffIdx = 0;
|
|
58
58
|
}
|
|
59
59
|
catch (err) {
|
|
60
|
-
this.emit('error', err);
|
|
60
|
+
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
61
61
|
if (err instanceof ConnectionClosedError || err instanceof CommandTimeoutError) {
|
|
62
62
|
const delay = RECONNECT_BACKOFF_MS[Math.min(backoffIdx, RECONNECT_BACKOFF_MS.length - 1)];
|
|
63
63
|
backoffIdx += 1;
|
|
@@ -123,7 +123,7 @@ export class Worker extends WorkerBase {
|
|
|
123
123
|
onSettled: (err) => {
|
|
124
124
|
this.finishJob(job.id);
|
|
125
125
|
if (err) {
|
|
126
|
-
this.emit('error', err);
|
|
126
|
+
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
127
127
|
}
|
|
128
128
|
else {
|
|
129
129
|
this.processed += 1;
|
|
@@ -133,20 +133,23 @@ export class Worker extends WorkerBase {
|
|
|
133
133
|
});
|
|
134
134
|
return;
|
|
135
135
|
}
|
|
136
|
-
this.
|
|
137
|
-
await this.safeCall(compact({ cmd: 'ACK', id: job.id, token, result: result ?? undefined }));
|
|
136
|
+
const acked = await this.safeCall(compact({ cmd: 'ACK', id: job.id, token, result: result ?? undefined }));
|
|
138
137
|
// Free the slot BEFORE emitting: a throwing 'completed' listener must
|
|
139
138
|
// not leak the active slot (same rationale as the batched path).
|
|
140
139
|
this.finishJob(job.id);
|
|
141
|
-
|
|
140
|
+
// Mirror the batched path: a failed ACK already emitted 'error' — do not
|
|
141
|
+
// also claim completion (no 'completed', no processed++).
|
|
142
|
+
if (acked) {
|
|
143
|
+
this.processed += 1;
|
|
144
|
+
this.emit('completed', job, result);
|
|
145
|
+
}
|
|
142
146
|
}
|
|
143
147
|
catch (err) {
|
|
144
|
-
this.failedCount += 1;
|
|
145
148
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
146
149
|
// Keep the FIRST lines: in a JS stack the message + throw site lead, so
|
|
147
150
|
// slice(0,N) preserves them (slice(-N) would drop them on long stacks).
|
|
148
151
|
const stack = (error.stack ?? error.message).split('\n').slice(0, MAX_STACK_LINES);
|
|
149
|
-
await this.safeCall(compact({
|
|
152
|
+
const failed = await this.safeCall(compact({
|
|
150
153
|
cmd: 'FAIL',
|
|
151
154
|
id: job.id,
|
|
152
155
|
token,
|
|
@@ -155,7 +158,12 @@ export class Worker extends WorkerBase {
|
|
|
155
158
|
unrecoverable: err instanceof UnrecoverableError ? true : undefined,
|
|
156
159
|
}));
|
|
157
160
|
this.finishJob(job.id);
|
|
158
|
-
|
|
161
|
+
// Same asymmetry guard as the ACK path: if the FAIL never reached the
|
|
162
|
+
// server, only 'error' fires (the lock expiry will retry the job).
|
|
163
|
+
if (failed) {
|
|
164
|
+
this.failedCount += 1;
|
|
165
|
+
this.emit('failed', job, error);
|
|
166
|
+
}
|
|
159
167
|
}
|
|
160
168
|
}
|
|
161
169
|
finishJob(id) {
|
|
@@ -164,6 +172,11 @@ export class Worker extends WorkerBase {
|
|
|
164
172
|
}
|
|
165
173
|
// --------------------------------------------------------------- heartbeat
|
|
166
174
|
startHeartbeat() {
|
|
175
|
+
// 0, negative or non-finite (NaN coerces to interval 0) disables
|
|
176
|
+
// heartbeats — setInterval(fn, 0) would fire every macrotask and flood
|
|
177
|
+
// the server with Heartbeat commands.
|
|
178
|
+
if (!(Number.isFinite(this.heartbeatIntervalS) && this.heartbeatIntervalS > 0))
|
|
179
|
+
return;
|
|
167
180
|
this.heartbeatTimer = setInterval(() => {
|
|
168
181
|
void (async () => {
|
|
169
182
|
await this.safeCall({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunqueue-client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Cross-runtime TypeScript client for the bunqueue job queue server — Node.js, Bun, Deno and Cloudflare Workers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"scripts": {
|
|
27
27
|
"build": "tsc -p tsconfig.json",
|
|
28
|
+
"prepublishOnly": "bun run build",
|
|
28
29
|
"test": "bun tests/e2e.ts",
|
|
29
30
|
"test:integration": "bun tests/integration.ts",
|
|
30
31
|
"lint": "biome lint src tests",
|
|
@@ -22,11 +22,11 @@ export const bunqueueApi = {
|
|
|
22
22
|
id: string,
|
|
23
23
|
pattern: string,
|
|
24
24
|
data?: unknown,
|
|
25
|
-
opts?: { timezone?: string; jobOpts?: JobOptions }
|
|
25
|
+
opts?: { timezone?: string; limit?: number; jobOpts?: JobOptions }
|
|
26
26
|
): Promise<Raw | null> {
|
|
27
27
|
await this.queue.upsertJobScheduler(
|
|
28
28
|
id,
|
|
29
|
-
{ pattern, tz: opts?.timezone },
|
|
29
|
+
{ pattern, tz: opts?.timezone, limit: opts?.limit },
|
|
30
30
|
{ name: id, data, opts: opts?.jobOpts }
|
|
31
31
|
);
|
|
32
32
|
return this.queue.getJobScheduler(id);
|
|
@@ -37,11 +37,11 @@ export const bunqueueApi = {
|
|
|
37
37
|
id: string,
|
|
38
38
|
intervalMs: number,
|
|
39
39
|
data?: unknown,
|
|
40
|
-
opts?: { jobOpts?: JobOptions }
|
|
40
|
+
opts?: { limit?: number; jobOpts?: JobOptions }
|
|
41
41
|
): Promise<Raw | null> {
|
|
42
42
|
await this.queue.upsertJobScheduler(
|
|
43
43
|
id,
|
|
44
|
-
{ every: intervalMs },
|
|
44
|
+
{ every: intervalMs, limit: opts?.limit },
|
|
45
45
|
{ name: id, data, opts: opts?.jobOpts }
|
|
46
46
|
);
|
|
47
47
|
return this.queue.getJobScheduler(id);
|
|
@@ -173,13 +173,13 @@ export interface BunqueueApi<T = unknown, R = unknown> {
|
|
|
173
173
|
id: string,
|
|
174
174
|
pattern: string,
|
|
175
175
|
data?: T,
|
|
176
|
-
opts?: { timezone?: string; jobOpts?: JobOptions }
|
|
176
|
+
opts?: { timezone?: string; limit?: number; jobOpts?: JobOptions }
|
|
177
177
|
): Promise<Raw | null>;
|
|
178
178
|
every(
|
|
179
179
|
id: string,
|
|
180
180
|
intervalMs: number,
|
|
181
181
|
data?: T,
|
|
182
|
-
opts?: { jobOpts?: JobOptions }
|
|
182
|
+
opts?: { limit?: number; jobOpts?: JobOptions }
|
|
183
183
|
): Promise<Raw | null>;
|
|
184
184
|
removeCron(id: string): Promise<void>;
|
|
185
185
|
listCrons(): Promise<Raw[]>;
|
package/src/bunqueue/bunqueue.ts
CHANGED
|
@@ -90,7 +90,18 @@ export class Bunqueue<T = unknown, R = unknown> {
|
|
|
90
90
|
|
|
91
91
|
// DLQ & rate limit manager
|
|
92
92
|
this.dlqrl = new DlqRateLimitManager<T>(this.queue);
|
|
93
|
-
|
|
93
|
+
// Fire-and-forget config push: without the catch, an unreachable server at
|
|
94
|
+
// construction time becomes an unhandled rejection that kills the process.
|
|
95
|
+
// Route the failure to the worker's 'error' event (the channel every other
|
|
96
|
+
// background command failure uses); with no listener attached, swallow it
|
|
97
|
+
// like pause()/resume() do — an unlistened 'error' emit would itself throw.
|
|
98
|
+
if (opts.dlq) {
|
|
99
|
+
void this.dlqrl.setDlqConfig(opts.dlq).catch((err: unknown) => {
|
|
100
|
+
if (this.worker.listenerCount('error') > 0) {
|
|
101
|
+
this.worker.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
94
105
|
|
|
95
106
|
// Subsystems
|
|
96
107
|
this.cb = opts.circuitBreaker
|
package/src/frame.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { ConnectionClosedError } from './errors.js';
|
|
7
7
|
|
|
8
|
-
export const PROTOCOL_VERSION =
|
|
8
|
+
export const PROTOCOL_VERSION = 2;
|
|
9
9
|
export const MAX_FRAME_SIZE = 64 * 1024 * 1024; // mirror server-side limit
|
|
10
10
|
|
|
11
11
|
/** Drop undefined-valued keys so the msgpack frame stays minimal. */
|
package/src/index.ts
CHANGED
|
@@ -83,6 +83,11 @@ export type {
|
|
|
83
83
|
RepeatOptions,
|
|
84
84
|
} from './types.js';
|
|
85
85
|
export { Worker } from './worker.js';
|
|
86
|
-
export type {
|
|
86
|
+
export type {
|
|
87
|
+
AckBatchOptions,
|
|
88
|
+
Processor,
|
|
89
|
+
WorkerEventMap,
|
|
90
|
+
WorkerOptions,
|
|
91
|
+
} from './worker-types.js';
|
|
87
92
|
|
|
88
|
-
export const __version__ = '0.1.
|
|
93
|
+
export const __version__ = '0.1.7';
|
package/src/queue-admin.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* monitoring and webhooks. Merged onto Queue.prototype by queue.ts.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { CommandError } from './errors.js';
|
|
6
7
|
import { compact } from './frame.js';
|
|
7
8
|
import type { Queue } from './queue.js';
|
|
8
9
|
import { type JobOptions, jobPayload, wireJobOptions } from './types.js';
|
|
@@ -95,6 +96,10 @@ export const adminMethods = {
|
|
|
95
96
|
repeat: SchedulerOptions,
|
|
96
97
|
template: { name?: string; data?: unknown; opts?: JobOptions } = {}
|
|
97
98
|
): Promise<void> {
|
|
99
|
+
// Priority and deduplication of spawned jobs travel as TOP-LEVEL Cron
|
|
100
|
+
// fields (the handler reads cmd.priority/uniqueKey/dedup); inside
|
|
101
|
+
// jobOptions the server's CronJobOptions silently ignores them.
|
|
102
|
+
const dedup = template.opts?.deduplication;
|
|
98
103
|
await this.call(
|
|
99
104
|
compact({
|
|
100
105
|
cmd: 'Cron',
|
|
@@ -103,9 +108,14 @@ export const adminMethods = {
|
|
|
103
108
|
data: jobPayload(template.name ?? schedulerId, template.data ?? {}),
|
|
104
109
|
schedule: repeat.pattern,
|
|
105
110
|
repeatEvery: repeat.every,
|
|
111
|
+
priority: template.opts?.priority,
|
|
106
112
|
timezone: repeat.tz,
|
|
107
113
|
immediately: repeat.immediately,
|
|
108
114
|
maxLimit: repeat.limit,
|
|
115
|
+
uniqueKey: dedup?.id,
|
|
116
|
+
dedup: dedup
|
|
117
|
+
? compact({ ttl: dedup.ttl, extend: dedup.extend, replace: dedup.replace })
|
|
118
|
+
: undefined,
|
|
109
119
|
skipMissedOnRestart: repeat.skipMissedOnRestart,
|
|
110
120
|
skipIfNoWorker: repeat.skipIfNoWorker,
|
|
111
121
|
preventOverlap: repeat.preventOverlap,
|
|
@@ -122,8 +132,11 @@ export const adminMethods = {
|
|
|
122
132
|
try {
|
|
123
133
|
const response = await this.call({ cmd: 'CronGet', name: schedulerId });
|
|
124
134
|
return (response.cron ?? response.data ?? null) as Raw | null;
|
|
125
|
-
} catch {
|
|
126
|
-
|
|
135
|
+
} catch (err) {
|
|
136
|
+
// Only 'Cron job not found' maps to null; connection loss, timeouts and
|
|
137
|
+
// real server errors must surface, not masquerade as a missing scheduler.
|
|
138
|
+
if (err instanceof CommandError && /not found/i.test(err.message)) return null;
|
|
139
|
+
throw err;
|
|
127
140
|
}
|
|
128
141
|
},
|
|
129
142
|
|
package/src/queue-control.ts
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
* mutations. Methods are merged onto Queue.prototype by queue.ts.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { UnrecoverableError } from './errors.js';
|
|
6
7
|
import { compact } from './frame.js';
|
|
7
8
|
import type { Queue } from './queue.js';
|
|
8
9
|
import type { CountResponse, PausedResponse } from './responses.js';
|
|
9
10
|
import type { JobStateName } from './types.js';
|
|
11
|
+
import { MAX_STACK_LINES } from './worker-types.js';
|
|
10
12
|
|
|
11
13
|
type Ctx = Queue<unknown>;
|
|
12
14
|
|
|
@@ -72,9 +74,11 @@ export const controlMethods = {
|
|
|
72
74
|
await this.call({ cmd: 'RetryCompleted', queue: this.name });
|
|
73
75
|
return;
|
|
74
76
|
}
|
|
75
|
-
// `count`
|
|
76
|
-
//
|
|
77
|
-
await this.call(
|
|
77
|
+
// `count` caps how many DLQ entries are retried (server >= 2.8.29). Older
|
|
78
|
+
// servers ignore the field and retry the whole DLQ — forward-compatible.
|
|
79
|
+
await this.call(
|
|
80
|
+
compact({ cmd: 'RetryDlq', queue: this.name, count: opts.count }) as { cmd: string }
|
|
81
|
+
);
|
|
78
82
|
},
|
|
79
83
|
|
|
80
84
|
async retryCompleted(this: Ctx, id?: string): Promise<void> {
|
|
@@ -129,14 +133,33 @@ export const controlMethods = {
|
|
|
129
133
|
await this.call(compact({ cmd: 'ACK', id, result: returnValue, token }) as { cmd: string });
|
|
130
134
|
},
|
|
131
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Explicit failure path: mirrors the worker's FAIL wire so the stacktrace
|
|
138
|
+
* and the UnrecoverableError "do not retry" intent are persisted (#111
|
|
139
|
+
* silent-loss class), not just the message.
|
|
140
|
+
*/
|
|
132
141
|
async moveJobToFailed(
|
|
133
142
|
this: Ctx,
|
|
134
143
|
id: string,
|
|
135
144
|
error: Error | string,
|
|
136
145
|
token?: string
|
|
137
146
|
): Promise<void> {
|
|
138
|
-
const
|
|
139
|
-
|
|
147
|
+
const err = typeof error === 'string' ? undefined : error;
|
|
148
|
+
const message = err ? err.message || err.name : (error as string);
|
|
149
|
+
// Keep the FIRST lines (message + throw site), like the worker path.
|
|
150
|
+
const stack = err
|
|
151
|
+
? (err.stack ?? err.message).split('\n').slice(0, MAX_STACK_LINES)
|
|
152
|
+
: undefined;
|
|
153
|
+
await this.call(
|
|
154
|
+
compact({
|
|
155
|
+
cmd: 'FAIL',
|
|
156
|
+
id,
|
|
157
|
+
error: message,
|
|
158
|
+
stack,
|
|
159
|
+
unrecoverable: err instanceof UnrecoverableError ? true : undefined,
|
|
160
|
+
token,
|
|
161
|
+
}) as { cmd: string }
|
|
162
|
+
);
|
|
140
163
|
},
|
|
141
164
|
};
|
|
142
165
|
|
package/src/queue-query.ts
CHANGED
|
@@ -34,7 +34,9 @@ export const queryMethods = {
|
|
|
34
34
|
const response = await this.call<JobResponse>({ cmd: 'GetJob', id });
|
|
35
35
|
return response.job ? new Job<T>(response.job, this.connection) : null;
|
|
36
36
|
} catch (err) {
|
|
37
|
-
|
|
37
|
+
// Only the server's 'Job not found' maps to null; connection loss,
|
|
38
|
+
// timeouts and other server errors must surface.
|
|
39
|
+
if (err instanceof CommandError && /not found/i.test(err.message)) return null;
|
|
38
40
|
throw err;
|
|
39
41
|
}
|
|
40
42
|
},
|
|
@@ -44,7 +46,7 @@ export const queryMethods = {
|
|
|
44
46
|
const response = await this.call<JobResponse>({ cmd: 'GetJobByCustomId', customId });
|
|
45
47
|
return response.job ? new Job<T>(response.job, this.connection) : null;
|
|
46
48
|
} catch (err) {
|
|
47
|
-
if (err instanceof CommandError) return null;
|
|
49
|
+
if (err instanceof CommandError && /not found/i.test(err.message)) return null;
|
|
48
50
|
throw err;
|
|
49
51
|
}
|
|
50
52
|
},
|
|
@@ -146,6 +148,8 @@ export const queryMethods = {
|
|
|
146
148
|
* will not complete), everything else throws CommandTimeoutError.
|
|
147
149
|
*/
|
|
148
150
|
async waitForJob<R = unknown>(this: Ctx, id: string, ttlMs = 30_000): Promise<R> {
|
|
151
|
+
// The server validates 0 <= timeout <= 600000: clamp instead of erroring.
|
|
152
|
+
ttlMs = Math.min(Math.max(ttlMs, 0), 600_000);
|
|
149
153
|
const response = await this.call<WaitJobResponse<R>>(
|
|
150
154
|
{ cmd: 'WaitJob', id, timeout: ttlMs },
|
|
151
155
|
ttlMs + 5000
|
package/src/worker-base.ts
CHANGED
|
@@ -7,9 +7,14 @@ import { randomBytes } from 'node:crypto';
|
|
|
7
7
|
import { EventEmitter } from 'node:events';
|
|
8
8
|
import { hostname } from 'node:os';
|
|
9
9
|
import { Connection } from './connection.js';
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
import {
|
|
11
|
+
MAX_POLL_TIMEOUT_MS,
|
|
12
|
+
sleep,
|
|
13
|
+
type WorkerEventMap,
|
|
14
|
+
type WorkerOptions,
|
|
15
|
+
} from './worker-types.js';
|
|
16
|
+
|
|
17
|
+
export class WorkerBase<T = unknown, R = unknown> extends EventEmitter {
|
|
13
18
|
readonly queue: string;
|
|
14
19
|
readonly concurrency: number;
|
|
15
20
|
readonly batchSize: number;
|
|
@@ -41,7 +46,11 @@ export class WorkerBase extends EventEmitter {
|
|
|
41
46
|
if ((opts.concurrency ?? 4) < 1) throw new Error('concurrency must be >= 1');
|
|
42
47
|
this.queue = queue;
|
|
43
48
|
this.concurrency = opts.concurrency ?? 4;
|
|
44
|
-
|
|
49
|
+
// The server rejects PULLB count > 1000 (handlers/core.ts) — an unclamped
|
|
50
|
+
// batchSize would wedge the pull loop in a permanent error cycle. The
|
|
51
|
+
// finite-guard also catches NaN, which would otherwise pass both bounds.
|
|
52
|
+
const rawBatch = opts.batchSize ?? 10;
|
|
53
|
+
this.batchSize = Number.isFinite(rawBatch) ? Math.min(Math.max(1, rawBatch), 1000) : 10;
|
|
45
54
|
this.pollTimeoutMs = Math.min(opts.pollTimeoutMs ?? 5000, MAX_POLL_TIMEOUT_MS);
|
|
46
55
|
this.lockTtlMs = opts.lockTtlMs ?? 30_000;
|
|
47
56
|
this.heartbeatIntervalS = opts.heartbeatIntervalS ?? 10;
|
|
@@ -88,18 +97,40 @@ export class WorkerBase extends EventEmitter {
|
|
|
88
97
|
* 'ready' is replayed to listeners attached after it fired: with autorun the
|
|
89
98
|
* loop starts inside the constructor, so a plain once-only event could be
|
|
90
99
|
* missed by `new Worker(...).on('ready', ...)` patterns.
|
|
100
|
+
*
|
|
101
|
+
* The overloads give the known worker events typed parameters (see
|
|
102
|
+
* WorkerEventMap); unknown event names keep the generic signature.
|
|
91
103
|
*/
|
|
92
|
-
override on
|
|
93
|
-
|
|
94
|
-
|
|
104
|
+
override on<E extends keyof WorkerEventMap<T, R>>(
|
|
105
|
+
event: E,
|
|
106
|
+
listener: WorkerEventMap<T, R>[E]
|
|
107
|
+
): this;
|
|
108
|
+
override on(event: string | symbol, listener: (...args: unknown[]) => void): this;
|
|
109
|
+
override on(event: string | symbol, listener: (...args: never[]) => void): this {
|
|
110
|
+
if (event === 'ready' && this.readyFired) (listener as () => void)();
|
|
111
|
+
return super.on(event, listener as (...args: unknown[]) => void);
|
|
95
112
|
}
|
|
96
113
|
|
|
97
|
-
override once
|
|
114
|
+
override once<E extends keyof WorkerEventMap<T, R>>(
|
|
115
|
+
event: E,
|
|
116
|
+
listener: WorkerEventMap<T, R>[E]
|
|
117
|
+
): this;
|
|
118
|
+
override once(event: string | symbol, listener: (...args: unknown[]) => void): this;
|
|
119
|
+
override once(event: string | symbol, listener: (...args: never[]) => void): this {
|
|
98
120
|
if (event === 'ready' && this.readyFired) {
|
|
99
|
-
listener();
|
|
121
|
+
(listener as () => void)();
|
|
100
122
|
return this;
|
|
101
123
|
}
|
|
102
|
-
return super.once(event, listener);
|
|
124
|
+
return super.once(event, listener as (...args: unknown[]) => void);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
override off<E extends keyof WorkerEventMap<T, R>>(
|
|
128
|
+
event: E,
|
|
129
|
+
listener: WorkerEventMap<T, R>[E]
|
|
130
|
+
): this;
|
|
131
|
+
override off(event: string | symbol, listener: (...args: unknown[]) => void): this;
|
|
132
|
+
override off(event: string | symbol, listener: (...args: never[]) => void): this {
|
|
133
|
+
return super.off(event, listener as (...args: unknown[]) => void);
|
|
103
134
|
}
|
|
104
135
|
|
|
105
136
|
/**
|
|
@@ -151,11 +182,16 @@ export class WorkerBase extends EventEmitter {
|
|
|
151
182
|
this.emit('closed');
|
|
152
183
|
}
|
|
153
184
|
|
|
154
|
-
|
|
185
|
+
/** Dispatch a command, routing failures to 'error'. Returns whether the
|
|
186
|
+
* command reached the server — callers gate success-only side effects
|
|
187
|
+
* ('completed'/'failed' emits, counters) on it. */
|
|
188
|
+
protected async safeCall(command: Record<string, unknown> & { cmd: string }): Promise<boolean> {
|
|
155
189
|
try {
|
|
156
190
|
await this.connection.call(command);
|
|
191
|
+
return true;
|
|
157
192
|
} catch (err) {
|
|
158
|
-
this.emit('error', err);
|
|
193
|
+
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
194
|
+
return false;
|
|
159
195
|
}
|
|
160
196
|
}
|
|
161
197
|
|
package/src/worker-types.ts
CHANGED
|
@@ -6,6 +6,32 @@ import type { Observability } from './observability.js';
|
|
|
6
6
|
|
|
7
7
|
export type Processor<T = unknown, R = unknown> = (job: Job<T>) => R | Promise<R>;
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Typed Worker event map: listeners registered via `on`/`once`/`off` for these
|
|
11
|
+
* names get typed job/result/error parameters in strict mode. Unknown event
|
|
12
|
+
* names fall back to a generic `(...args: unknown[])` overload.
|
|
13
|
+
*/
|
|
14
|
+
export interface WorkerEventMap<T = unknown, R = unknown> {
|
|
15
|
+
/** Worker registered and pull loop started (replayed to late listeners). */
|
|
16
|
+
ready: () => void;
|
|
17
|
+
/** A job was pulled and handed to the processor. */
|
|
18
|
+
active: (job: Job<T>) => void;
|
|
19
|
+
/** Processor resolved AND the ACK reached the server. */
|
|
20
|
+
completed: (job: Job<T>, result: R) => void;
|
|
21
|
+
/** Processor threw AND the FAIL reached the server. */
|
|
22
|
+
failed: (job: Job<T>, error: Error) => void;
|
|
23
|
+
/** job.updateProgress() was called from the processor. */
|
|
24
|
+
progress: (job: Job<T>, progress: number) => void;
|
|
25
|
+
/** Connection/command error (pull loop, ACK/FAIL, heartbeat, ...). */
|
|
26
|
+
error: (error: Error) => void;
|
|
27
|
+
/** The queue went from busy to empty (no active jobs, nothing pulled). */
|
|
28
|
+
drained: () => void;
|
|
29
|
+
/** Cooperative cancel was requested for a locally active job. */
|
|
30
|
+
cancelled: (info: { jobId: string; reason: string }) => void;
|
|
31
|
+
/** close() finished. */
|
|
32
|
+
closed: () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
9
35
|
export interface AckBatchOptions {
|
|
10
36
|
/** Batch ACKs into ACKB round-trips (default false; opt-in for throughput). */
|
|
11
37
|
enabled?: boolean;
|
|
@@ -27,13 +53,13 @@ export interface WorkerOptions extends Observability {
|
|
|
27
53
|
ackBatch?: AckBatchOptions;
|
|
28
54
|
/** Max jobs processed in parallel (default 4). */
|
|
29
55
|
concurrency?: number;
|
|
30
|
-
/** Max jobs fetched per PULLB (default 10, capped by free slots). */
|
|
56
|
+
/** Max jobs fetched per PULLB (default 10, capped by free slots and the server max 1000). */
|
|
31
57
|
batchSize?: number;
|
|
32
58
|
/** Server-side long-poll timeout in ms (default 5000, max 30000). */
|
|
33
59
|
pollTimeoutMs?: number;
|
|
34
60
|
/** Job lock TTL in ms (default 30000). */
|
|
35
61
|
lockTtlMs?: number;
|
|
36
|
-
/** Worker + job heartbeat interval in seconds (default 10). */
|
|
62
|
+
/** Worker + job heartbeat interval in seconds (default 10, 0 = disabled). */
|
|
37
63
|
heartbeatIntervalS?: number;
|
|
38
64
|
/** Start the loop at construction (default true, mirrors the TS client). */
|
|
39
65
|
autorun?: boolean;
|
package/src/worker.ts
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
type WorkerOptions,
|
|
22
22
|
} from './worker-types.js';
|
|
23
23
|
|
|
24
|
-
export class Worker<T = unknown, R = unknown> extends WorkerBase {
|
|
24
|
+
export class Worker<T = unknown, R = unknown> extends WorkerBase<T, R> {
|
|
25
25
|
private readonly processor: Processor<T, R>;
|
|
26
26
|
private readonly ackBatcher: AckBatcher | null;
|
|
27
27
|
|
|
@@ -44,8 +44,8 @@ export class Worker<T = unknown, R = unknown> extends WorkerBase {
|
|
|
44
44
|
run(): void {
|
|
45
45
|
if (this.running || this.closedFlag) return;
|
|
46
46
|
this.running = true;
|
|
47
|
-
this.loopPromise = this.loop().catch((err) => {
|
|
48
|
-
this.emit('error', err);
|
|
47
|
+
this.loopPromise = this.loop().catch((err: unknown) => {
|
|
48
|
+
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
49
49
|
});
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -68,7 +68,7 @@ export class Worker<T = unknown, R = unknown> extends WorkerBase {
|
|
|
68
68
|
await this.pollOnce();
|
|
69
69
|
backoffIdx = 0;
|
|
70
70
|
} catch (err) {
|
|
71
|
-
this.emit('error', err);
|
|
71
|
+
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
72
72
|
if (err instanceof ConnectionClosedError || err instanceof CommandTimeoutError) {
|
|
73
73
|
const delay = RECONNECT_BACKOFF_MS[Math.min(backoffIdx, RECONNECT_BACKOFF_MS.length - 1)];
|
|
74
74
|
backoffIdx += 1;
|
|
@@ -143,7 +143,7 @@ export class Worker<T = unknown, R = unknown> extends WorkerBase {
|
|
|
143
143
|
onSettled: (err) => {
|
|
144
144
|
this.finishJob(job.id);
|
|
145
145
|
if (err) {
|
|
146
|
-
this.emit('error', err);
|
|
146
|
+
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
147
147
|
} else {
|
|
148
148
|
this.processed += 1;
|
|
149
149
|
this.emit('completed', job, result);
|
|
@@ -152,21 +152,24 @@ export class Worker<T = unknown, R = unknown> extends WorkerBase {
|
|
|
152
152
|
});
|
|
153
153
|
return;
|
|
154
154
|
}
|
|
155
|
-
this.
|
|
156
|
-
await this.safeCall(
|
|
155
|
+
const acked = await this.safeCall(
|
|
157
156
|
compact({ cmd: 'ACK', id: job.id, token, result: result ?? undefined }) as { cmd: string }
|
|
158
157
|
);
|
|
159
158
|
// Free the slot BEFORE emitting: a throwing 'completed' listener must
|
|
160
159
|
// not leak the active slot (same rationale as the batched path).
|
|
161
160
|
this.finishJob(job.id);
|
|
162
|
-
|
|
161
|
+
// Mirror the batched path: a failed ACK already emitted 'error' — do not
|
|
162
|
+
// also claim completion (no 'completed', no processed++).
|
|
163
|
+
if (acked) {
|
|
164
|
+
this.processed += 1;
|
|
165
|
+
this.emit('completed', job, result);
|
|
166
|
+
}
|
|
163
167
|
} catch (err) {
|
|
164
|
-
this.failedCount += 1;
|
|
165
168
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
166
169
|
// Keep the FIRST lines: in a JS stack the message + throw site lead, so
|
|
167
170
|
// slice(0,N) preserves them (slice(-N) would drop them on long stacks).
|
|
168
171
|
const stack = (error.stack ?? error.message).split('\n').slice(0, MAX_STACK_LINES);
|
|
169
|
-
await this.safeCall(
|
|
172
|
+
const failed = await this.safeCall(
|
|
170
173
|
compact({
|
|
171
174
|
cmd: 'FAIL',
|
|
172
175
|
id: job.id,
|
|
@@ -177,7 +180,12 @@ export class Worker<T = unknown, R = unknown> extends WorkerBase {
|
|
|
177
180
|
}) as { cmd: string }
|
|
178
181
|
);
|
|
179
182
|
this.finishJob(job.id);
|
|
180
|
-
|
|
183
|
+
// Same asymmetry guard as the ACK path: if the FAIL never reached the
|
|
184
|
+
// server, only 'error' fires (the lock expiry will retry the job).
|
|
185
|
+
if (failed) {
|
|
186
|
+
this.failedCount += 1;
|
|
187
|
+
this.emit('failed', job, error);
|
|
188
|
+
}
|
|
181
189
|
}
|
|
182
190
|
}
|
|
183
191
|
|
|
@@ -189,6 +197,10 @@ export class Worker<T = unknown, R = unknown> extends WorkerBase {
|
|
|
189
197
|
// --------------------------------------------------------------- heartbeat
|
|
190
198
|
|
|
191
199
|
private startHeartbeat(): void {
|
|
200
|
+
// 0, negative or non-finite (NaN coerces to interval 0) disables
|
|
201
|
+
// heartbeats — setInterval(fn, 0) would fire every macrotask and flood
|
|
202
|
+
// the server with Heartbeat commands.
|
|
203
|
+
if (!(Number.isFinite(this.heartbeatIntervalS) && this.heartbeatIntervalS > 0)) return;
|
|
192
204
|
this.heartbeatTimer = setInterval(() => {
|
|
193
205
|
void (async () => {
|
|
194
206
|
await this.safeCall({
|