@oneunit/redis 0.0.0-stage → 1.0.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/ARCHITECTURE.md +422 -0
- package/CHANGELOG.md +186 -0
- package/CONTRIBUTING.md +353 -0
- package/LICENSE +21 -0
- package/README.md +760 -2
- package/dist/client/check.d.ts +19 -0
- package/dist/client/check.d.ts.map +1 -0
- package/dist/client/check.js +44 -0
- package/dist/client/check.js.map +1 -0
- package/dist/client/client.d.ts +10 -0
- package/dist/client/client.d.ts.map +1 -0
- package/dist/client/client.js +25 -0
- package/dist/client/client.js.map +1 -0
- package/dist/client/events.d.ts +5 -0
- package/dist/client/events.d.ts.map +1 -0
- package/dist/client/events.js +66 -0
- package/dist/client/events.js.map +1 -0
- package/dist/client/index.d.ts +6 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +5 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/shutdown.d.ts +4 -0
- package/dist/client/shutdown.d.ts.map +1 -0
- package/dist/client/shutdown.js +78 -0
- package/dist/client/shutdown.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +23 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +120 -0
- package/dist/logger.js.map +1 -0
- package/dist/pipeline/builder.d.ts +111 -0
- package/dist/pipeline/builder.d.ts.map +1 -0
- package/dist/pipeline/builder.js +197 -0
- package/dist/pipeline/builder.js.map +1 -0
- package/dist/pipeline/index.d.ts +3 -0
- package/dist/pipeline/index.d.ts.map +1 -0
- package/dist/pipeline/index.js +2 -0
- package/dist/pipeline/index.js.map +1 -0
- package/dist/queue/events.d.ts +13 -0
- package/dist/queue/events.d.ts.map +1 -0
- package/dist/queue/events.js +109 -0
- package/dist/queue/events.js.map +1 -0
- package/dist/queue/index.d.ts +7 -0
- package/dist/queue/index.d.ts.map +1 -0
- package/dist/queue/index.js +4 -0
- package/dist/queue/index.js.map +1 -0
- package/dist/queue/queue.d.ts +13 -0
- package/dist/queue/queue.d.ts.map +1 -0
- package/dist/queue/queue.js +37 -0
- package/dist/queue/queue.js.map +1 -0
- package/dist/queue/worker.d.ts +15 -0
- package/dist/queue/worker.d.ts.map +1 -0
- package/dist/queue/worker.js +20 -0
- package/dist/queue/worker.js.map +1 -0
- package/examples/README.md +86 -0
- package/examples/_setup.js +143 -0
- package/examples/cache.js +111 -0
- package/examples/pipeline.js +161 -0
- package/examples/pubsub.js +101 -0
- package/examples/queue-worker.js +189 -0
- package/examples/session.js +145 -0
- package/examples/standalone.js +58 -0
- package/package.json +100 -4
- package/src/client/check.ts +69 -0
- package/src/client/client.ts +45 -0
- package/src/client/events.ts +101 -0
- package/src/client/index.ts +5 -0
- package/src/client/shutdown.ts +97 -0
- package/src/index.ts +4 -0
- package/src/logger.ts +159 -0
- package/src/pipeline/builder.ts +307 -0
- package/src/pipeline/index.ts +7 -0
- package/src/queue/events.ts +158 -0
- package/src/queue/index.ts +6 -0
- package/src/queue/queue.ts +60 -0
- package/src/queue/worker.ts +44 -0
package/src/logger.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
const LOGGER_KEYS = ["error", "warn", "info", "debug"] as const;
|
|
2
|
+
|
|
3
|
+
export interface Logger {
|
|
4
|
+
error(message: unknown, extra?: unknown): void;
|
|
5
|
+
warn(message: unknown, extra?: unknown): void;
|
|
6
|
+
info(message: unknown, extra?: unknown): void;
|
|
7
|
+
debug(message: unknown, extra?: unknown): void;
|
|
8
|
+
child?(bindings?: Record<string, unknown>): Logger;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const silentLogger: Logger = Object.freeze({
|
|
12
|
+
error() {},
|
|
13
|
+
warn() {},
|
|
14
|
+
info() {},
|
|
15
|
+
debug() {},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const consoleLogger: Logger = Object.freeze({
|
|
19
|
+
error(message: unknown, extra?: unknown) {
|
|
20
|
+
write(console.error, message, extra);
|
|
21
|
+
},
|
|
22
|
+
warn(message: unknown, extra?: unknown) {
|
|
23
|
+
write(console.warn, message, extra);
|
|
24
|
+
},
|
|
25
|
+
info(message: unknown, extra?: unknown) {
|
|
26
|
+
write(console.info, message, extra);
|
|
27
|
+
},
|
|
28
|
+
debug(message: unknown, extra?: unknown) {
|
|
29
|
+
write(console.debug, message, extra);
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Does this logger take `(bindings, message)` instead of `(message, extra)`?
|
|
35
|
+
*
|
|
36
|
+
* pino and its drop-in forks accept `log.info(bindings, message)`, where the
|
|
37
|
+
* first argument is merged into the record and the second is the message.
|
|
38
|
+
* Those signatures are indistinguishable at the call site, so a caller has to
|
|
39
|
+
* be detected rather than chosen.
|
|
40
|
+
*
|
|
41
|
+
* `child()` was the first thing tried here and it is not a usable signal: this
|
|
42
|
+
* package's own `Logger` interface declares `child?()`, so every conforming
|
|
43
|
+
* logger is allowed to have one. Swapping arguments for those loggers silently
|
|
44
|
+
* moved the message into the bindings slot of every record.
|
|
45
|
+
*
|
|
46
|
+
* A pino instance exposes both `bindings()` and the `levels` map. `child()`
|
|
47
|
+
* loggers inherit `bindings()` from the same prototype, so a caller that hands
|
|
48
|
+
* us `logger.child({ service: "redis" })` is still recognised. Custom loggers
|
|
49
|
+
* have neither.
|
|
50
|
+
*/
|
|
51
|
+
function isBindingsFirst(logger: Logger): boolean {
|
|
52
|
+
const candidate = logger as unknown as Record<string, unknown>;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
typeof candidate.bindings === "function" &&
|
|
56
|
+
typeof candidate.levels === "object" &&
|
|
57
|
+
candidate.levels !== null
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function isLogger(value: unknown): value is Logger {
|
|
62
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const hasLogFn = LOGGER_KEYS.some(
|
|
67
|
+
(key) => typeof (value as Record<string, unknown>)[key] === "function",
|
|
68
|
+
);
|
|
69
|
+
if (!hasLogFn) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Complete a caller-supplied logger, or keep "no logger" as no logging.
|
|
78
|
+
*
|
|
79
|
+
* `createLogger` is the public entry point and defaults to the console, which
|
|
80
|
+
* is right when someone asks for a logger. Internal call sites are different:
|
|
81
|
+
* every `createClient(url)` in the wild passes nothing and expects silence, so
|
|
82
|
+
* they need a logger that is either complete or absent. Without this, a logger
|
|
83
|
+
* that implements only some levels throws `logger?.info is not a function` from
|
|
84
|
+
* a connection event, where it is least likely to be caught.
|
|
85
|
+
*/
|
|
86
|
+
export function normalizeLogger(logger?: Logger | null): Logger | undefined {
|
|
87
|
+
if (!logger) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return createLogger(logger);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function createLogger(input?: Logger | null): Logger {
|
|
95
|
+
if (!input) {
|
|
96
|
+
return consoleLogger;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (input === silentLogger || input === consoleLogger) {
|
|
100
|
+
return input;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
error(message: unknown, extra?: unknown) {
|
|
105
|
+
invoke(input, "error", message, extra);
|
|
106
|
+
},
|
|
107
|
+
warn(message: unknown, extra?: unknown) {
|
|
108
|
+
invoke(input, "warn", message, extra);
|
|
109
|
+
},
|
|
110
|
+
info(message: unknown, extra?: unknown) {
|
|
111
|
+
invoke(input, "info", message, extra);
|
|
112
|
+
},
|
|
113
|
+
debug(message: unknown, extra?: unknown) {
|
|
114
|
+
invoke(input, "debug", message, extra);
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function write(
|
|
120
|
+
fn: (...args: unknown[]) => void,
|
|
121
|
+
message: unknown,
|
|
122
|
+
extra?: unknown,
|
|
123
|
+
): void {
|
|
124
|
+
if (extra === undefined) {
|
|
125
|
+
fn(message);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
fn(message, extra);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function invoke(
|
|
132
|
+
logger: Logger,
|
|
133
|
+
level: "error" | "warn" | "info" | "debug",
|
|
134
|
+
message: unknown,
|
|
135
|
+
extra?: unknown,
|
|
136
|
+
): void {
|
|
137
|
+
const fn =
|
|
138
|
+
typeof logger[level] === "function"
|
|
139
|
+
? logger[level]
|
|
140
|
+
: typeof logger.info === "function"
|
|
141
|
+
? logger.info
|
|
142
|
+
: undefined;
|
|
143
|
+
|
|
144
|
+
if (typeof fn !== "function") {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (extra === undefined) {
|
|
149
|
+
fn.call(logger, message);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (isBindingsFirst(logger)) {
|
|
154
|
+
fn.call(logger, extra, message);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
fn.call(logger, message, extra);
|
|
159
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { type Redis as RedisClient, type ChainableCommander } from "ioredis";
|
|
2
|
+
import { normalizeLogger, type Logger } from "../logger.js";
|
|
3
|
+
import { redactError } from "../client/events.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One command in a pipeline, plus a label for reporting failures.
|
|
7
|
+
*
|
|
8
|
+
* The label is what makes a failure identifiable. A pipeline result is a
|
|
9
|
+
* positional array, so a bare `results[7]` tells an operator nothing about which
|
|
10
|
+
* of several hundred commands went wrong; ioredis attaches the command itself to
|
|
11
|
+
* the error, but only for the commands it can describe, and the label is what
|
|
12
|
+
* this package logs.
|
|
13
|
+
*/
|
|
14
|
+
export interface PipelineStep {
|
|
15
|
+
label: string;
|
|
16
|
+
run: (pipeline: ChainableCommander) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PipelineOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Milliseconds to wait for EXEC before giving up.
|
|
22
|
+
*
|
|
23
|
+
* ioredis queues commands while reconnecting and never flushes them until the
|
|
24
|
+
* connection is back, so EXEC against an unreachable server does not reject —
|
|
25
|
+
* it never settles. Without a bound a request handler awaiting a pipeline
|
|
26
|
+
* hangs for the whole outage and takes the process's request capacity with it.
|
|
27
|
+
*/
|
|
28
|
+
timeout?: number;
|
|
29
|
+
logger?: Logger;
|
|
30
|
+
/**
|
|
31
|
+
* Reject if any single command in the batch failed.
|
|
32
|
+
*
|
|
33
|
+
* Off by default. EXEC resolves even when individual commands fail: each
|
|
34
|
+
* failure arrives as a `[error, null]` tuple in the results, and a pipeline
|
|
35
|
+
* that silently drops one of its writes looks exactly like a pipeline that
|
|
36
|
+
* succeeded. Turn this on where a partial batch is not acceptable.
|
|
37
|
+
*/
|
|
38
|
+
throwOnError?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Result of one command. Mirrors ioredis's own tuple, with the error redacted. */
|
|
42
|
+
export interface PipelineStepResult {
|
|
43
|
+
label: string;
|
|
44
|
+
value: unknown;
|
|
45
|
+
error?: Error;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PipelineResult {
|
|
49
|
+
results: PipelineStepResult[];
|
|
50
|
+
/** Wall-clock duration of EXEC, in milliseconds. */
|
|
51
|
+
durationMs: number;
|
|
52
|
+
/** How many commands failed. Zero when all succeeded. */
|
|
53
|
+
failed: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
57
|
+
|
|
58
|
+
/** Whether a value is promise-like, i.e. something a caller forgot to await. */
|
|
59
|
+
function isThenable(value: unknown): boolean {
|
|
60
|
+
return (
|
|
61
|
+
typeof value === "object" &&
|
|
62
|
+
value !== null &&
|
|
63
|
+
typeof (value as { then?: unknown }).then === "function"
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class PipelineTimeoutError extends Error {
|
|
68
|
+
readonly steps: number;
|
|
69
|
+
|
|
70
|
+
constructor(timeout: number, steps: number) {
|
|
71
|
+
super(
|
|
72
|
+
`Redis pipeline of ${steps} command${steps === 1 ? "" : "s"} did not complete within ${timeout}ms`,
|
|
73
|
+
);
|
|
74
|
+
this.name = "PipelineTimeoutError";
|
|
75
|
+
this.steps = steps;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Thrown when `throwOnError` is set and a command failed.
|
|
81
|
+
*
|
|
82
|
+
* Carries the per-step results so the caller can see which command broke
|
|
83
|
+
* without re-running the batch. The `message` lists labels only — ioredis
|
|
84
|
+
* attaches command arguments to its errors, and a pipeline that batched an
|
|
85
|
+
* `AUTH` would otherwise put the password in an exception message.
|
|
86
|
+
*/
|
|
87
|
+
export class PipelineCommandError extends Error {
|
|
88
|
+
readonly results: PipelineStepResult[];
|
|
89
|
+
|
|
90
|
+
constructor(results: PipelineStepResult[]) {
|
|
91
|
+
const failed = results.filter((result) => result.error);
|
|
92
|
+
super(
|
|
93
|
+
`Redis pipeline: ${failed.length} of ${results.length} commands failed (${failed
|
|
94
|
+
.map((result) => result.label)
|
|
95
|
+
.join(", ")})`,
|
|
96
|
+
);
|
|
97
|
+
this.name = "PipelineCommandError";
|
|
98
|
+
this.results = results;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Thrown when a step does not queue exactly one command.
|
|
104
|
+
*
|
|
105
|
+
* `PipelineStep.run` is typed as returning `void`, and TypeScript allows any
|
|
106
|
+
* value to be returned from a `void` signature — so a step that queues two
|
|
107
|
+
* commands, or an `async` step whose command is only queued after `runPipeline`
|
|
108
|
+
* has already called `exec`, compiles without complaint. Both shift the
|
|
109
|
+
* positional pairing between `steps` and the result tuples, which is the one
|
|
110
|
+
* thing the label exists to prevent, so the batch is rejected rather than
|
|
111
|
+
* returned with a value against the wrong label.
|
|
112
|
+
*/
|
|
113
|
+
export class PipelineStepError extends Error {
|
|
114
|
+
readonly label: string;
|
|
115
|
+
readonly queued: number;
|
|
116
|
+
|
|
117
|
+
constructor(label: string, queued: number) {
|
|
118
|
+
super(
|
|
119
|
+
`Redis pipeline step "${label}" queued ${queued} command${
|
|
120
|
+
queued === 1 ? "" : "s"
|
|
121
|
+
}, expected exactly 1`,
|
|
122
|
+
);
|
|
123
|
+
this.name = "PipelineStepError";
|
|
124
|
+
this.label = label;
|
|
125
|
+
this.queued = queued;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Run a batch of commands in one round trip.
|
|
131
|
+
*
|
|
132
|
+
* Wraps `client.pipeline()` rather than reimplementing it. The value is in the
|
|
133
|
+
* four sharp edges this closes, all of which are silent in ioredis itself:
|
|
134
|
+
*
|
|
135
|
+
* 1. **A failed command does not fail the pipeline.** EXEC resolves with a
|
|
136
|
+
* `[error, null]` tuple for the command that failed. Code that reads
|
|
137
|
+
* `results.map(([, value]) => value)` gets `null` and carries on as if the
|
|
138
|
+
* write landed. Every result here carries an explicit `error` field.
|
|
139
|
+
* 2. **EXEC can hang forever.** ioredis parks queued commands while
|
|
140
|
+
* reconnecting. `timeout` bounds that.
|
|
141
|
+
* 3. **Command errors carry their arguments.** Redacted via `redactError`
|
|
142
|
+
* before they reach a logger or an exception — on both the per-command
|
|
143
|
+
* results and a rejected `exec()`.
|
|
144
|
+
* 4. **Results are positional.** Each step must queue exactly one command, or
|
|
145
|
+
* every later label is paired with the wrong value. A step that does not
|
|
146
|
+
* raises `PipelineStepError`.
|
|
147
|
+
*/
|
|
148
|
+
export async function runPipeline(
|
|
149
|
+
client: RedisClient,
|
|
150
|
+
steps: PipelineStep[],
|
|
151
|
+
options: PipelineOptions = {},
|
|
152
|
+
): Promise<PipelineResult> {
|
|
153
|
+
const logger = normalizeLogger(options.logger);
|
|
154
|
+
const requested = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
155
|
+
// setTimeout coerces a negative or NaN budget to 1ms and prints a warning for
|
|
156
|
+
// each; treat any invalid value as "no bound configured" instead.
|
|
157
|
+
const timeout =
|
|
158
|
+
Number.isFinite(requested) && requested > 0
|
|
159
|
+
? requested
|
|
160
|
+
: DEFAULT_TIMEOUT_MS;
|
|
161
|
+
|
|
162
|
+
if (steps.length === 0) {
|
|
163
|
+
// No round trip is needed, and building a pipeline just to exec an empty
|
|
164
|
+
// one is a wasted allocation.
|
|
165
|
+
return { results: [], durationMs: 0, failed: 0 };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const pipeline = client.pipeline();
|
|
169
|
+
|
|
170
|
+
for (const step of steps) {
|
|
171
|
+
// Results are paired with steps by position, so the queue has to grow by
|
|
172
|
+
// exactly one per step. `length` is ioredis's own queue length, and it is
|
|
173
|
+
// read before and after rather than counted here so the check is against
|
|
174
|
+
// what was actually queued, not against what `run` appears to have done.
|
|
175
|
+
const before = pipeline.length;
|
|
176
|
+
|
|
177
|
+
let returned: unknown;
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
returned = step.run(pipeline);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
// A throwing `run` means the command was never queued, so the batch is
|
|
183
|
+
// already malformed. Fail the whole pipeline: silently dropping the
|
|
184
|
+
// command would shift every later result by one and return the wrong
|
|
185
|
+
// value against the wrong label.
|
|
186
|
+
logger?.error(`Redis pipeline step failed to queue: ${step.label}`, {
|
|
187
|
+
err: redactError(error),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const queued = pipeline.length - before;
|
|
194
|
+
|
|
195
|
+
if (isThenable(returned)) {
|
|
196
|
+
// An `async` step is invisible to the count below: it queues nothing
|
|
197
|
+
// synchronously, so `exec()` runs first and the command it was going to
|
|
198
|
+
// queue lands in a pipeline that has already been sent. TypeScript
|
|
199
|
+
// permits returning a value from a `void`-typed signature, so this is
|
|
200
|
+
// not a compile error either.
|
|
201
|
+
//
|
|
202
|
+
// The step's promise is abandoned here, so its eventual rejection is
|
|
203
|
+
// nobody's to handle — which on Node 20 is a process-level crash that
|
|
204
|
+
// would mask the error naming the actual fault. Swallow it; the throw
|
|
205
|
+
// below is the real diagnosis.
|
|
206
|
+
void Promise.resolve(returned).catch(() => undefined);
|
|
207
|
+
|
|
208
|
+
logger?.error(
|
|
209
|
+
`Redis pipeline step returned a promise instead of queuing: ${step.label}`,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
throw new PipelineStepError(step.label, queued);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (queued !== 1) {
|
|
216
|
+
logger?.error(
|
|
217
|
+
`Redis pipeline step queued ${queued} commands: ${step.label}`,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
throw new PipelineStepError(step.label, queued);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const started = performance.now();
|
|
225
|
+
|
|
226
|
+
let timer: NodeJS.Timeout | undefined;
|
|
227
|
+
const expiry = new Promise<"timeout">((resolve) => {
|
|
228
|
+
timer = setTimeout(() => resolve("timeout"), timeout);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
let raw: Array<[Error | null, unknown]>;
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const outcome = await Promise.race([pipeline.exec(), expiry]);
|
|
235
|
+
|
|
236
|
+
if (outcome === "timeout") {
|
|
237
|
+
throw new PipelineTimeoutError(timeout, steps.length);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
raw = outcome as Array<[Error | null, unknown]>;
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (error instanceof PipelineTimeoutError) {
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// A rejected `exec()` is a connection-level failure, and it bypasses the
|
|
247
|
+
// per-tuple redaction below entirely. The module documents that errors are
|
|
248
|
+
// redacted, so the rejection goes through the same path rather than
|
|
249
|
+
// reaching the caller — and from there a logger — as ioredis built it.
|
|
250
|
+
throw redactError(error);
|
|
251
|
+
} finally {
|
|
252
|
+
// A pending timeout keeps the event loop alive. Always clear it, including
|
|
253
|
+
// on the timeout path where it has already fired.
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const results: PipelineStepResult[] = raw.map(([error, value], index) => {
|
|
258
|
+
const step = steps[index];
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
label: step?.label ?? `#${index}`,
|
|
262
|
+
value,
|
|
263
|
+
// Redacted here rather than at each call site so a caller that logs a
|
|
264
|
+
// whole result array cannot leak credentials by accident.
|
|
265
|
+
...(error ? { error: redactError(error) as Error } : {}),
|
|
266
|
+
};
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const failed = results.filter((result) => result.error).length;
|
|
270
|
+
const durationMs = Math.round(performance.now() - started);
|
|
271
|
+
|
|
272
|
+
if (failed > 0) {
|
|
273
|
+
logger?.warn(
|
|
274
|
+
`Redis pipeline: ${failed} of ${results.length} commands failed`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
logger?.debug?.(
|
|
279
|
+
`Redis pipeline completed ${results.length} command${
|
|
280
|
+
results.length === 1 ? "" : "s"
|
|
281
|
+
} in ${durationMs}ms`,
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
if (failed > 0 && options.throwOnError) {
|
|
285
|
+
throw new PipelineCommandError(results);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { results, durationMs, failed };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Values of the successful results, in order.
|
|
293
|
+
*
|
|
294
|
+
* Throws if any command failed, because the alternative is handing back a
|
|
295
|
+
* sparse array whose length matches the batch but whose contents silently
|
|
296
|
+
* include a failed write. Use `results` directly when a partial batch is
|
|
297
|
+
* expected and worth handling.
|
|
298
|
+
*/
|
|
299
|
+
export function pipelineValues(results: PipelineStepResult[]): unknown[] {
|
|
300
|
+
const failed = results.find((result) => result.error);
|
|
301
|
+
|
|
302
|
+
if (failed) {
|
|
303
|
+
throw new PipelineCommandError(results);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return results.map((result) => result.value);
|
|
307
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConnectionClosedError,
|
|
3
|
+
QueueEvents,
|
|
4
|
+
type QueueEventsOptions,
|
|
5
|
+
} from "bullmq";
|
|
6
|
+
import { Queue } from "bullmq";
|
|
7
|
+
import type { Logger } from "../logger.js";
|
|
8
|
+
import type { Redis } from "ioredis";
|
|
9
|
+
import { normalizeLogger } from "../logger.js";
|
|
10
|
+
import { redactError } from "../client/events.js";
|
|
11
|
+
|
|
12
|
+
export interface QueueEventsConfig {
|
|
13
|
+
queue: Queue;
|
|
14
|
+
logger?: Logger;
|
|
15
|
+
prefix?: string;
|
|
16
|
+
connection?: QueueEventsOptions["connection"];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* QueueEvents that can be closed after a failed startup.
|
|
21
|
+
*
|
|
22
|
+
* BullMQ's own `close()` awaits `this.client` before disconnecting, and that
|
|
23
|
+
* getter resolves to the connection's `initializing` promise. When the
|
|
24
|
+
* connection never became ready that promise has already rejected with
|
|
25
|
+
* "Connection is closed.", so `close()` throws before it reaches
|
|
26
|
+
* `connection.close()` and the duplicated ioredis client is left running its
|
|
27
|
+
* reconnect loop. Nothing else in the process can stop it, because the caller
|
|
28
|
+
* has no reference to the duplicate, so the process never exits.
|
|
29
|
+
*
|
|
30
|
+
* `Queue` does not have this problem: `RedisConnection.close()` handles
|
|
31
|
+
* `status === "initializing"` itself.
|
|
32
|
+
*
|
|
33
|
+
* Disconnecting the duplicate first is safe in both directions. When the
|
|
34
|
+
* connection is healthy, `disconnect()` ends it and the subsequent
|
|
35
|
+
* `connection.close()` sees a client already at `end` and skips its own quit.
|
|
36
|
+
*/
|
|
37
|
+
class ManagedQueueEvents extends QueueEvents {
|
|
38
|
+
override async close(): Promise<void> {
|
|
39
|
+
const connection = this.connection as unknown as {
|
|
40
|
+
_client?: Redis;
|
|
41
|
+
close(force?: boolean): Promise<void>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// A duplicate that is already gone does not need disconnecting, and
|
|
45
|
+
// `disconnect()` is a no-op at `end`.
|
|
46
|
+
connection._client?.disconnect();
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
await super.close();
|
|
50
|
+
} catch (error) {
|
|
51
|
+
// `super.close()` propagates the rejection from the failed startup
|
|
52
|
+
// instead of reporting that the connection is now closed. The connection
|
|
53
|
+
// itself does know how to close from `initializing`, so drive it directly
|
|
54
|
+
// and only rethrow errors that are not about the connection being gone.
|
|
55
|
+
await connection.close(true).catch(() => undefined);
|
|
56
|
+
|
|
57
|
+
if (!isConnectionGone(error)) {
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* True for the "this connection is already gone" family of errors.
|
|
66
|
+
*
|
|
67
|
+
* `ConnectionClosedError` is checked first and structurally, because BullMQ
|
|
68
|
+
* introduced it for exactly this reason — its own comment on the class says it
|
|
69
|
+
* exists so `isNotConnectionError` can "do a structural `instanceof` check
|
|
70
|
+
* rather than fragile message-substring matching". Matching the message cannot
|
|
71
|
+
* work here: only some of BullMQ's construction sites pass ioredis's
|
|
72
|
+
* `CONNECTION_CLOSED_ERROR_MSG`, and the others pass their own wording or no
|
|
73
|
+
* message at all, in which case the class default (`"Connection is closed"`,
|
|
74
|
+
* with no trailing period) applies. An exact string comparison therefore
|
|
75
|
+
* rethrows precisely the failures this function exists to absorb, and the
|
|
76
|
+
* caller gets an exception from teardown instead of a clean close.
|
|
77
|
+
*
|
|
78
|
+
* The string clauses stay as a fallback: they still cover a `bullmq` error that
|
|
79
|
+
* predates the class, and an error forwarded from another adapter. `instanceof`
|
|
80
|
+
* is identity-based, so a consumer with two copies of `bullmq` in one tree
|
|
81
|
+
* would miss the class check — the fallbacks catch the ioredis wording, and
|
|
82
|
+
* missing them is the safe direction to fail.
|
|
83
|
+
*/
|
|
84
|
+
function isConnectionGone(error: unknown): boolean {
|
|
85
|
+
if (error instanceof ConnectionClosedError) {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!(error instanceof Error)) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
error.message === "Connection is closed." ||
|
|
95
|
+
error.message.includes("ECONNREFUSED") ||
|
|
96
|
+
(error as NodeJS.ErrnoException).code === "ECONNREFUSED"
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function attachQueueEvents(config: QueueEventsConfig): QueueEvents {
|
|
101
|
+
const { queue, connection } = config;
|
|
102
|
+
|
|
103
|
+
// BullMQ swallows a throwing event listener and re-emits the failure as an
|
|
104
|
+
// "error" event, which then throws again and lands on console.error. A logger
|
|
105
|
+
// missing `info` would trigger that on every completed job.
|
|
106
|
+
const logger = normalizeLogger(config.logger);
|
|
107
|
+
|
|
108
|
+
// QueueEvents subscribes to a key derived from the prefix. Defaulting to a
|
|
109
|
+
// literal "queue" here silently dropped every event for a queue created
|
|
110
|
+
// with a custom prefix, so inherit the queue's own prefix instead.
|
|
111
|
+
const prefix = config.prefix ?? queue.opts.prefix ?? "queue";
|
|
112
|
+
|
|
113
|
+
const queueEvents = new ManagedQueueEvents(queue.name, {
|
|
114
|
+
prefix,
|
|
115
|
+
connection: connection ?? queue.opts.connection,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
queueEvents.on(
|
|
119
|
+
"completed",
|
|
120
|
+
(
|
|
121
|
+
args: { jobId: string; returnvalue: string; prev?: string },
|
|
122
|
+
_id: string,
|
|
123
|
+
) => {
|
|
124
|
+
logger?.info(`Job ${args.jobId} completed`);
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
queueEvents.on(
|
|
129
|
+
"failed",
|
|
130
|
+
(
|
|
131
|
+
args: { jobId: string; failedReason: string; prev?: string },
|
|
132
|
+
_id: string,
|
|
133
|
+
) => {
|
|
134
|
+
logger?.error(`Job ${args.jobId} failed`, args.failedReason);
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
queueEvents.on(
|
|
139
|
+
"progress",
|
|
140
|
+
(args: { jobId: string; data: unknown }, _id: string) => {
|
|
141
|
+
logger?.info(`Job ${args.jobId} progress`, args.data);
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
queueEvents.on("error", (error: Error) => {
|
|
146
|
+
// QueueEvents duplicates the caller's client, so it authenticates with the
|
|
147
|
+
// same password and BullMQ re-emits any AUTH failure here. ioredis attaches
|
|
148
|
+
// the failing command to that error, and for AUTH its args are the password
|
|
149
|
+
// in plaintext — logging it as-is would write the credential to the app's
|
|
150
|
+
// log on every reconnect attempt against a misconfigured server.
|
|
151
|
+
logger?.error(`Queue "${queue.name}" events error`, redactError(error));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return queueEvents;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export type { QueueEventsOptions } from "bullmq";
|
|
158
|
+
export { QueueEvents } from "bullmq";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Queue, type QueueOptions, type JobsOptions } from "bullmq";
|
|
2
|
+
import { type Redis as RedisClient } from "ioredis";
|
|
3
|
+
|
|
4
|
+
export interface QueueConfig {
|
|
5
|
+
name: string;
|
|
6
|
+
connection: RedisClient;
|
|
7
|
+
prefix?: string;
|
|
8
|
+
defaultJobOptions?: JobsOptions;
|
|
9
|
+
settings?: QueueOptions["settings"];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createQueue(config: QueueConfig): Queue {
|
|
13
|
+
const {
|
|
14
|
+
name,
|
|
15
|
+
connection,
|
|
16
|
+
prefix = "queue",
|
|
17
|
+
defaultJobOptions,
|
|
18
|
+
settings,
|
|
19
|
+
...options
|
|
20
|
+
} = config;
|
|
21
|
+
|
|
22
|
+
const defaults: JobsOptions = {
|
|
23
|
+
removeOnComplete: 100,
|
|
24
|
+
removeOnFail: 1000,
|
|
25
|
+
attempts: 3,
|
|
26
|
+
backoff: {
|
|
27
|
+
type: "exponential",
|
|
28
|
+
delay: 1000,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Merged key by key rather than by spreading. A spread writes `undefined` for
|
|
33
|
+
// every key the caller left unset, which erases the default instead of falling
|
|
34
|
+
// through to it — and that is how a config built by spreading another object
|
|
35
|
+
// (`{ ...base, attempts: maybeUndefined }`) quietly loses the package default.
|
|
36
|
+
// `null` is a deliberate value rather than a missing one, so it passes
|
|
37
|
+
// through: `removeOnComplete: null` is how BullMQ is told to keep a job.
|
|
38
|
+
const merged: Record<string, unknown> = { ...defaults };
|
|
39
|
+
|
|
40
|
+
for (const [key, value] of Object.entries(defaultJobOptions ?? {})) {
|
|
41
|
+
if (value !== undefined) {
|
|
42
|
+
merged[key] = value;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const queueOptions: QueueOptions = {
|
|
47
|
+
prefix,
|
|
48
|
+
connection,
|
|
49
|
+
defaultJobOptions: merged as JobsOptions,
|
|
50
|
+
...(settings === undefined ? {} : { settings }),
|
|
51
|
+
...options,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const queue = new Queue(name, queueOptions);
|
|
55
|
+
|
|
56
|
+
return queue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type { QueueOptions, JobsOptions } from "bullmq";
|
|
60
|
+
export { Queue } from "bullmq";
|