@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.
Files changed (79) hide show
  1. package/ARCHITECTURE.md +422 -0
  2. package/CHANGELOG.md +186 -0
  3. package/CONTRIBUTING.md +353 -0
  4. package/LICENSE +21 -0
  5. package/README.md +760 -2
  6. package/dist/client/check.d.ts +19 -0
  7. package/dist/client/check.d.ts.map +1 -0
  8. package/dist/client/check.js +44 -0
  9. package/dist/client/check.js.map +1 -0
  10. package/dist/client/client.d.ts +10 -0
  11. package/dist/client/client.d.ts.map +1 -0
  12. package/dist/client/client.js +25 -0
  13. package/dist/client/client.js.map +1 -0
  14. package/dist/client/events.d.ts +5 -0
  15. package/dist/client/events.d.ts.map +1 -0
  16. package/dist/client/events.js +66 -0
  17. package/dist/client/events.js.map +1 -0
  18. package/dist/client/index.d.ts +6 -0
  19. package/dist/client/index.d.ts.map +1 -0
  20. package/dist/client/index.js +5 -0
  21. package/dist/client/index.js.map +1 -0
  22. package/dist/client/shutdown.d.ts +4 -0
  23. package/dist/client/shutdown.d.ts.map +1 -0
  24. package/dist/client/shutdown.js +78 -0
  25. package/dist/client/shutdown.js.map +1 -0
  26. package/dist/index.d.ts +5 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +5 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/logger.d.ts +23 -0
  31. package/dist/logger.d.ts.map +1 -0
  32. package/dist/logger.js +120 -0
  33. package/dist/logger.js.map +1 -0
  34. package/dist/pipeline/builder.d.ts +111 -0
  35. package/dist/pipeline/builder.d.ts.map +1 -0
  36. package/dist/pipeline/builder.js +197 -0
  37. package/dist/pipeline/builder.js.map +1 -0
  38. package/dist/pipeline/index.d.ts +3 -0
  39. package/dist/pipeline/index.d.ts.map +1 -0
  40. package/dist/pipeline/index.js +2 -0
  41. package/dist/pipeline/index.js.map +1 -0
  42. package/dist/queue/events.d.ts +13 -0
  43. package/dist/queue/events.d.ts.map +1 -0
  44. package/dist/queue/events.js +109 -0
  45. package/dist/queue/events.js.map +1 -0
  46. package/dist/queue/index.d.ts +7 -0
  47. package/dist/queue/index.d.ts.map +1 -0
  48. package/dist/queue/index.js +4 -0
  49. package/dist/queue/index.js.map +1 -0
  50. package/dist/queue/queue.d.ts +13 -0
  51. package/dist/queue/queue.d.ts.map +1 -0
  52. package/dist/queue/queue.js +37 -0
  53. package/dist/queue/queue.js.map +1 -0
  54. package/dist/queue/worker.d.ts +15 -0
  55. package/dist/queue/worker.d.ts.map +1 -0
  56. package/dist/queue/worker.js +20 -0
  57. package/dist/queue/worker.js.map +1 -0
  58. package/examples/README.md +86 -0
  59. package/examples/_setup.js +143 -0
  60. package/examples/cache.js +111 -0
  61. package/examples/pipeline.js +161 -0
  62. package/examples/pubsub.js +101 -0
  63. package/examples/queue-worker.js +189 -0
  64. package/examples/session.js +145 -0
  65. package/examples/standalone.js +58 -0
  66. package/package.json +100 -4
  67. package/src/client/check.ts +69 -0
  68. package/src/client/client.ts +45 -0
  69. package/src/client/events.ts +101 -0
  70. package/src/client/index.ts +5 -0
  71. package/src/client/shutdown.ts +97 -0
  72. package/src/index.ts +4 -0
  73. package/src/logger.ts +159 -0
  74. package/src/pipeline/builder.ts +307 -0
  75. package/src/pipeline/index.ts +7 -0
  76. package/src/queue/events.ts +158 -0
  77. package/src/queue/index.ts +6 -0
  78. package/src/queue/queue.ts +60 -0
  79. package/src/queue/worker.ts +44 -0
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Session store on Redis.
3
+ *
4
+ * npm run example:session
5
+ *
6
+ * Environment:
7
+ * REDIS_URL connection URL (default redis://localhost:6379)
8
+ * REDIS_SILENT set to "true" to suppress connection-event logging
9
+ *
10
+ * Sessions belong in Redis rather than in process memory for two reasons: they
11
+ * survive a restart, and they are visible to every instance behind a load
12
+ * balancer. The TTL is what bounds their lifetime, so it is set on write and
13
+ * refreshed on activity.
14
+ */
15
+
16
+ import { randomUUID } from "node:crypto";
17
+ import { createClient, health, shutdown } from "@oneunit/redis";
18
+ import { REDIS_URL, exampleLogger, onFailure, release, run } from "./_setup.js";
19
+
20
+ const SESSION_TTL_SECONDS = 3600;
21
+ const KEY_PREFIX = "example:session:";
22
+
23
+ await run("session", async () => {
24
+ const client = createClient({ url: REDIS_URL }, exampleLogger);
25
+ onFailure(() => shutdown(client, exampleLogger));
26
+
27
+ const healthResult = await health(client);
28
+ if (healthResult.status === "down") {
29
+ // Always shut down, including on this early exit: ioredis retries in the
30
+ // background, so a client left open keeps the event loop alive and the
31
+ // script never exits.
32
+ console.log("Redis is not reachable, stopping here.");
33
+ console.log("Health:", healthResult);
34
+ await release();
35
+ return;
36
+ }
37
+
38
+ const key = (sessionId) => `${KEY_PREFIX}${sessionId}`;
39
+
40
+ const sessions = {
41
+ async create(userId, data = {}) {
42
+ const session = {
43
+ id: randomUUID(),
44
+ userId,
45
+ createdAt: Date.now(),
46
+ ...data,
47
+ };
48
+
49
+ await client.setex(
50
+ key(session.id),
51
+ SESSION_TTL_SECONDS,
52
+ JSON.stringify(session),
53
+ );
54
+
55
+ return session;
56
+ },
57
+
58
+ async get(sessionId) {
59
+ // Returns null rather than throwing for a missing session, so callers
60
+ // treat "no session" and "expired session" the same way.
61
+ const raw = await client.get(key(sessionId));
62
+ return raw === null ? null : JSON.parse(raw);
63
+ },
64
+
65
+ async update(sessionId, patch) {
66
+ const existing = await sessions.get(sessionId);
67
+ if (!existing) {
68
+ return null;
69
+ }
70
+
71
+ const updated = { ...existing, ...patch, updatedAt: Date.now() };
72
+
73
+ // Rewriting with SETEX resets the TTL to the full window. Refresh on
74
+ // a sliding window only; an absolute-expiry session would use PERSIST
75
+ // here instead so the original deadline stands.
76
+ await client.setex(
77
+ key(sessionId),
78
+ SESSION_TTL_SECONDS,
79
+ JSON.stringify(updated),
80
+ );
81
+
82
+ return updated;
83
+ },
84
+
85
+ async extend(sessionId, ttlSeconds = SESSION_TTL_SECONDS) {
86
+ // EXPIRE on a missing key returns 0, not 1, which is the cheapest way
87
+ // to tell whether the session was still alive.
88
+ return client.expire(key(sessionId), ttlSeconds);
89
+ },
90
+
91
+ async touch(sessionId) {
92
+ // Refresh the TTL without reading or rewriting the payload.
93
+ return sessions.extend(sessionId);
94
+ },
95
+
96
+ async destroy(sessionId) {
97
+ return client.del(key(sessionId));
98
+ },
99
+ };
100
+
101
+ console.log("Create:");
102
+ const session = await sessions.create("user-123", {
103
+ role: "admin",
104
+ permissions: ["read", "write"],
105
+ });
106
+ console.log(" ", session);
107
+ console.log(" TTL:", await client.ttl(key(session.id)), "seconds");
108
+
109
+ console.log("\nGet:");
110
+ console.log(" ", await sessions.get(session.id));
111
+
112
+ console.log("\nGet a session that does not exist:");
113
+ console.log(" ", await sessions.get("not-a-real-session"));
114
+
115
+ console.log("\nUpdate:");
116
+ const updated = await sessions.update(session.id, {
117
+ lastActivity: Date.now(),
118
+ });
119
+ console.log(
120
+ " role:",
121
+ updated.role,
122
+ "| updatedAt set:",
123
+ updated.updatedAt > updated.createdAt,
124
+ );
125
+
126
+ console.log("\nExtend the TTL to 2 hours:");
127
+ const extended = await sessions.extend(session.id, 7200);
128
+ console.log(
129
+ " EXPIRE returned",
130
+ extended,
131
+ "| TTL now:",
132
+ await client.ttl(key(session.id)),
133
+ );
134
+
135
+ console.log("\nTouch (refresh TTL without rewriting the payload):");
136
+ await sessions.touch(session.id);
137
+ console.log(" TTL:", await client.ttl(key(session.id)));
138
+
139
+ console.log("\nDestroy:");
140
+ console.log(" DEL removed", await sessions.destroy(session.id), "key(s)");
141
+ console.log(" get after destroy:", await sessions.get(session.id));
142
+
143
+ await release();
144
+ console.log("\nDisconnected cleanly.");
145
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Standalone client: connect, check health, read and write, disconnect.
3
+ *
4
+ * npm run example:standalone
5
+ *
6
+ * Environment:
7
+ * REDIS_URL connection URL (default redis://localhost:6379)
8
+ * REDIS_SILENT set to "true" to suppress connection-event logging
9
+ *
10
+ * `createClient` returns a plain ioredis instance, so every ioredis command is
11
+ * available on it. The defaults are chosen so one client works for both plain
12
+ * commands and BullMQ: `url` falls back to REDIS_URL, `lazyConnect` avoids
13
+ * opening a socket at construction, and `maxRetriesPerRequest: null` is what
14
+ * BullMQ requires.
15
+ */
16
+
17
+ import { createClient, health, shutdown } from "@oneunit/redis";
18
+ import { REDIS_URL, exampleLogger, onFailure, release, run } from "./_setup.js";
19
+
20
+ await run("standalone", async () => {
21
+ const client = createClient({ url: REDIS_URL }, exampleLogger);
22
+ onFailure(() => shutdown(client, exampleLogger));
23
+
24
+ // `lazyConnect` is on, so nothing is connected until the first command.
25
+ // `health` issues PING and reports how long it took.
26
+ const healthResult = await health(client);
27
+
28
+ if (healthResult.status === "down") {
29
+ // Always shut the client down, including on this early exit. ioredis
30
+ // retries in the background, so a client left open keeps the event loop
31
+ // alive and the script never exits.
32
+ console.log("Redis is not reachable, stopping here.");
33
+ console.log("Set REDIS_URL if your server is not on localhost:6379.");
34
+ console.log("Health:", healthResult);
35
+ await release();
36
+ return;
37
+ }
38
+
39
+ console.log("Health:", healthResult);
40
+
41
+ await client.set("greeting", "hello from @oneunit/redis");
42
+ console.log("GET greeting:", await client.get("greeting"));
43
+
44
+ // INCR is atomic in Redis, so concurrent callers cannot interleave.
45
+ await client.set("visits", 0);
46
+ const visits = await client.incrby("visits", 5);
47
+ console.log("INCRBY visits 5 ->", visits);
48
+
49
+ // EXPIRE sets a TTL in seconds. A key with no TTL lives forever, which is
50
+ // the usual cause of a Redis instance quietly filling up.
51
+ await client.expire("greeting", 60);
52
+ console.log("TTL greeting:", await client.ttl("greeting"), "seconds");
53
+
54
+ await client.del("greeting", "visits");
55
+
56
+ await release();
57
+ console.log("\nDisconnected cleanly.");
58
+ });
package/package.json CHANGED
@@ -1,6 +1,102 @@
1
1
  {
2
2
  "name": "@oneunit/redis",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Redis client and BullMQ job queues for Node.js. Works standalone or with any logger that has error, warn, info, and debug.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./client": {
16
+ "types": "./dist/client/index.d.ts",
17
+ "import": "./dist/client/index.js",
18
+ "default": "./dist/client/index.js"
19
+ },
20
+ "./queue": {
21
+ "types": "./dist/queue/index.d.ts",
22
+ "import": "./dist/queue/index.js",
23
+ "default": "./dist/queue/index.js"
24
+ },
25
+ "./pipeline": {
26
+ "types": "./dist/pipeline/index.d.ts",
27
+ "import": "./dist/pipeline/index.js",
28
+ "default": "./dist/pipeline/index.js"
29
+ },
30
+ "./package.json": "./package.json"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "src",
35
+ "examples",
36
+ "LICENSE",
37
+ "README.md",
38
+ "ARCHITECTURE.md",
39
+ "CONTRIBUTING.md",
40
+ "CHANGELOG.md"
41
+ ],
42
+ "scripts": {
43
+ "clean": "node -e \"const fs=require('node:fs');for(const p of ['dist','tsconfig.tsbuildinfo'])fs.rmSync(p,{recursive:true,force:true})\"",
44
+ "build": "npm run clean && tsc -p tsconfig.json",
45
+ "dev": "tsc -w -p tsconfig.json",
46
+ "typecheck": "tsc -p tsconfig.json --noEmit",
47
+ "test": "tsx --test test/*.test.ts",
48
+ "test:watch": "tsx --test --watch test/*.test.ts",
49
+ "lint": "eslint src test",
50
+ "example:standalone": "npm run build && node examples/standalone.js",
51
+ "example:cache": "npm run build && node examples/cache.js",
52
+ "example:session": "npm run build && node examples/session.js",
53
+ "example:pipeline": "npm run build && node examples/pipeline.js",
54
+ "example:pubsub": "npm run build && node examples/pubsub.js",
55
+ "example:queue-worker": "npm run build && node examples/queue-worker.js",
56
+ "prepack": "npm run build",
57
+ "prepublishOnly": "npm run build && npm test",
58
+ "pack:check": "npm pack --dry-run",
59
+ "verify": "npm run build && npm run typecheck && npm run lint && npm test && npm run pack:check"
60
+ },
61
+ "keywords": [
62
+ "redis",
63
+ "ioredis",
64
+ "bullmq",
65
+ "queue",
66
+ "worker",
67
+ "cache",
68
+ "session",
69
+ "nodejs",
70
+ "microservice",
71
+ "oneunit"
72
+ ],
73
+ "author": "mayank",
74
+ "license": "MIT",
75
+ "sideEffects": false,
76
+ "engines": {
77
+ "node": ">=20"
78
+ },
79
+ "publishConfig": {
80
+ "access": "public",
81
+ "registry": "https://registry.npmjs.org/"
82
+ },
83
+ "repository": {
84
+ "type": "git",
85
+ "url": "git+https://github.com/mayank040902/oneunit.git",
86
+ "directory": "packages/redis"
87
+ },
88
+ "bugs": {
89
+ "url": "https://github.com/mayank040902/oneunit/issues"
90
+ },
91
+ "homepage": "https://github.com/mayank040902/oneunit/tree/master/packages/redis#readme",
92
+ "dependencies": {
93
+ "bullmq": "^5.80.9",
94
+ "ioredis": "^5.11.1"
95
+ },
96
+ "devDependencies": {
97
+ "@types/node": "^22.13.10",
98
+ "eslint": "^9.22.0",
99
+ "tsx": "^4.20.5",
100
+ "typescript": "^5.8.2"
101
+ }
102
+ }
@@ -0,0 +1,69 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import { type Redis as RedisClient } from "ioredis";
3
+
4
+ export interface HealthResult {
5
+ status: "up" | "down";
6
+ latency: {
7
+ value: number;
8
+ unit: "ms";
9
+ };
10
+ error?: string;
11
+ }
12
+
13
+ export interface HealthOptions {
14
+ /**
15
+ * Milliseconds to wait for PING before reporting `down`. ioredis queues
16
+ * commands while reconnecting, so without a bound a PING against an
17
+ * unreachable server never settles and health checks hang forever.
18
+ */
19
+ timeout?: number;
20
+ }
21
+
22
+ const DEFAULT_TIMEOUT_MS = 1000;
23
+
24
+ export async function health(
25
+ client: RedisClient,
26
+ options: HealthOptions = {},
27
+ ): Promise<HealthResult> {
28
+ const requested = options.timeout ?? DEFAULT_TIMEOUT_MS;
29
+ // setTimeout coerces negatives to 1 and NaN to 1, and Node prints a
30
+ // TimeoutNegativeWarning/TimeoutNaNWarning for each. Treat any invalid
31
+ // budget as "no bound configured" rather than leaking warnings per call.
32
+ const timeout =
33
+ Number.isFinite(requested) && requested > 0
34
+ ? requested
35
+ : DEFAULT_TIMEOUT_MS;
36
+ const start = performance.now();
37
+
38
+ const elapsed = (): number => Math.round(performance.now() - start);
39
+
40
+ let timer: NodeJS.Timeout | undefined;
41
+ const expiry = new Promise<never>((_resolve, reject) => {
42
+ timer = setTimeout(() => {
43
+ reject(new Error(`Health check timed out after ${timeout}ms`));
44
+ }, timeout);
45
+ });
46
+
47
+ try {
48
+ await Promise.race([client.ping(), expiry]);
49
+
50
+ return {
51
+ status: "up",
52
+ latency: {
53
+ value: elapsed(),
54
+ unit: "ms",
55
+ },
56
+ };
57
+ } catch (error) {
58
+ return {
59
+ status: "down",
60
+ latency: {
61
+ value: elapsed(),
62
+ unit: "ms",
63
+ },
64
+ error: error instanceof Error ? error.message : String(error),
65
+ };
66
+ } finally {
67
+ clearTimeout(timer);
68
+ }
69
+ }
@@ -0,0 +1,45 @@
1
+ import { Redis } from "ioredis";
2
+ import { attachEvents } from "./events.js";
3
+ import { normalizeLogger, type Logger } from "../logger.js";
4
+ import type { RedisOptions } from "ioredis";
5
+
6
+ export interface RedisClientOptions extends RedisOptions {
7
+ url?: string;
8
+ lazyConnect?: boolean;
9
+ }
10
+
11
+ export function createClient(
12
+ options: RedisClientOptions | string = {},
13
+ logger?: Logger,
14
+ ): Redis {
15
+ // ioredis's own constructor accepts `new Redis("redis://host:port")`, so
16
+ // that is the form people reach for first. Destructuring the string as an
17
+ // options object produces no `url`, which leaves the client pointing at
18
+ // localhost:6379: a silent connection to the wrong server, with no error
19
+ // anywhere to notice it by.
20
+ const resolved: RedisClientOptions =
21
+ typeof options === "string" ? { url: options } : options;
22
+
23
+ const {
24
+ url = process.env.REDIS_URL,
25
+ lazyConnect = true,
26
+ // BullMQ throws unless this is null on the connections it drives, and
27
+ // `createQueue`/`createWorker` take this same client instance.
28
+ maxRetriesPerRequest = null,
29
+ ...restOptions
30
+ } = resolved;
31
+
32
+ // ioredis only reads `url` from the first positional argument; an options
33
+ // object carrying `url` is silently ignored and connects to localhost.
34
+ const client = url
35
+ ? new Redis(url, { ...restOptions, lazyConnect, maxRetriesPerRequest })
36
+ : new Redis({ ...restOptions, lazyConnect, maxRetriesPerRequest });
37
+
38
+ // A caller-supplied logger may implement only some of the levels. Completing
39
+ // it here keeps `attachEvents` from throwing mid-connection-event.
40
+ attachEvents(client, normalizeLogger(logger));
41
+
42
+ return client;
43
+ }
44
+
45
+ export type { Logger } from "../logger.js";
@@ -0,0 +1,101 @@
1
+ import { normalizeLogger, type Logger } from "../logger.js";
2
+ import { type Redis as RedisClient } from "ioredis";
3
+
4
+ /** Command names whose arguments must never be logged. */
5
+ const SECRET_COMMANDS = new Set(["auth", "hello"]);
6
+
7
+ /**
8
+ * Strip credentials before an error reaches a logger.
9
+ *
10
+ * ioredis attaches the failing command to its errors, and for `AUTH` that
11
+ * command's args are the username and the password in plaintext:
12
+ *
13
+ * { command: { name: "auth", args: ["default", "hunter2-real-secret"] } }
14
+ *
15
+ * Logging the error as-is writes the Redis password to whatever the app logs
16
+ * to, on every failed authentication. The message itself is safe; only the
17
+ * attached command is not, so only that part is replaced.
18
+ */
19
+ /** An ioredis error, which carries the failing command alongside the message. */
20
+ type CommandError = Error & {
21
+ command?: { name?: unknown; args?: unknown } | null;
22
+ };
23
+
24
+ export function redactError(error: unknown): unknown {
25
+ if (!(error instanceof Error)) {
26
+ return error;
27
+ }
28
+
29
+ const command = (error as CommandError).command;
30
+
31
+ if (
32
+ typeof command === "object" &&
33
+ command !== null &&
34
+ SECRET_COMMANDS.has(String(command.name).toLowerCase())
35
+ ) {
36
+ // Copy rather than mutate: ioredis may still be using this error, and the
37
+ // caller has no reason to lose the original for their own handling.
38
+ //
39
+ // `message` is deliberately kept. Redis error text is what tells an
40
+ // operator *why* authentication failed, and it never echoes the password
41
+ // back. Only the attached command's args carry it.
42
+ const safe = new Error(error.message) as CommandError;
43
+ safe.name = error.name;
44
+ safe.stack = error.stack;
45
+ safe.command = { ...command, args: "[redacted]" };
46
+
47
+ return safe;
48
+ }
49
+
50
+ return error;
51
+ }
52
+
53
+ /**
54
+ * Marks a client this function has already wired up.
55
+ *
56
+ * `attachEvents` is exported and `createClient` calls it. A caller reaching for
57
+ * the exported helper on a client from `createClient` would otherwise get a
58
+ * second full set of listeners, logging every connection event twice. BullMQ
59
+ * adds listeners to the same client too, so crossing the default limit of 10
60
+ * and tripping `MaxListenersExceededWarning` should not be something a
61
+ * consumer causes by accident.
62
+ *
63
+ * A global symbol is used so two copies of this package in one dependency tree
64
+ * still recognise a client the other already wired.
65
+ */
66
+ const WIRED = Symbol.for("oneunit.redis.eventsAttached");
67
+
68
+ export function attachEvents(client: RedisClient, loggerInput?: Logger): void {
69
+ if ((client as unknown as Record<symbol, boolean>)[WIRED]) {
70
+ return;
71
+ }
72
+
73
+ Object.defineProperty(client, WIRED, {
74
+ value: true,
75
+ enumerable: false,
76
+ });
77
+
78
+ const logger = normalizeLogger(loggerInput);
79
+
80
+ client.on("connect", () => {
81
+ logger?.info("redis connect");
82
+ });
83
+
84
+ client.on("ready", () => {
85
+ logger?.info("redis ready");
86
+ });
87
+
88
+ client.on("reconnecting", (delay: number) => {
89
+ logger?.warn(
90
+ delay ? `redis reconnecting in ${delay}ms` : "redis reconnecting",
91
+ );
92
+ });
93
+
94
+ client.on("error", (err: Error) => {
95
+ logger?.error("redis error", { err: redactError(err) });
96
+ });
97
+
98
+ client.on("close", () => {
99
+ logger?.info("redis close");
100
+ });
101
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./client.js";
2
+ export type { RedisClientOptions, Logger } from "./client.js";
3
+ export { attachEvents, redactError } from "./events.js";
4
+ export { health, type HealthResult, type HealthOptions } from "./check.js";
5
+ export { shutdown } from "./shutdown.js";
@@ -0,0 +1,97 @@
1
+ import { normalizeLogger, type Logger } from "../logger.js";
2
+ import { redactError } from "./events.js";
3
+ import { type Redis as RedisClient } from "ioredis";
4
+
5
+ /**
6
+ * How long to wait for QUIT before forcing the connection closed.
7
+ *
8
+ * QUIT is a queued command: ioredis only sends it on a live connection, so a
9
+ * client stuck in `reconnecting` parks it in the offline queue and the promise
10
+ * never settles. Without a bound, `shutdown` hangs for the lifetime of the
11
+ * outage, which is exactly when a process most needs to be able to exit.
12
+ */
13
+ const DEFAULT_TIMEOUT_MS = 5000;
14
+
15
+ const isClosed = (client: RedisClient): boolean =>
16
+ client.status === "end" || FORCED.has(client);
17
+
18
+ /**
19
+ * Clients this function already tore down by force.
20
+ *
21
+ * `disconnect()` only reaches ioredis's `closeHandler` from a live connection.
22
+ * On a client sitting in `reconnecting` the connector has nothing to close, so
23
+ * the status never becomes `end` even though `disconnect()` has cleared the
24
+ * retry timer and the connection can never come back. Shutdown is registered on
25
+ * both SIGINT and SIGTERM in most apps, so the second call would otherwise wait
26
+ * out the full QUIT deadline again for a connection that is already gone.
27
+ *
28
+ * A `WeakSet` keeps this bookkeeping off the client object, so it does not
29
+ * show up in `Object.keys`, in a serialised snapshot, or in BullMQ's own
30
+ * inspection of the connection.
31
+ */
32
+ const FORCED = new WeakSet<object>();
33
+
34
+ export async function shutdown(
35
+ client: RedisClient | null | undefined,
36
+ loggerInput?: Logger,
37
+ ): Promise<void> {
38
+ if (!client) {
39
+ return;
40
+ }
41
+
42
+ // Completing the logger here matters more than anywhere else: shutdown runs
43
+ // during teardown, so `logger?.info is not a function` would replace a clean
44
+ // disconnect with a rejection on the way out.
45
+ const logger = normalizeLogger(loggerInput);
46
+
47
+ // Once ioredis reaches "end" the connection is gone for good and QUIT
48
+ // rejects with "Connection is closed.". Shutdown is registered on both
49
+ // SIGINT and SIGTERM in most apps, so a second call has to be a no-op
50
+ // rather than an unhandled rejection during teardown.
51
+ if (isClosed(client)) {
52
+ return;
53
+ }
54
+
55
+ let timer: NodeJS.Timeout | undefined;
56
+ const expiry = new Promise<"timeout">((resolve) => {
57
+ timer = setTimeout(() => resolve("timeout"), DEFAULT_TIMEOUT_MS);
58
+ });
59
+
60
+ try {
61
+ const outcome = await Promise.race([
62
+ client.quit().then(() => "quit" as const),
63
+ expiry,
64
+ ]);
65
+
66
+ if (outcome === "timeout") {
67
+ // The server never acknowledged QUIT. Tear the connection down
68
+ // directly so the retry loop stops and the process can exit; the
69
+ // caller asked to disconnect, and we are, one way or another.
70
+ logger?.warn(
71
+ `Redis did not acknowledge QUIT within ${DEFAULT_TIMEOUT_MS}ms, forcing the connection closed`,
72
+ );
73
+ client.disconnect();
74
+ FORCED.add(client);
75
+
76
+ return;
77
+ }
78
+
79
+ logger?.info("Redis disconnected");
80
+ } catch (error) {
81
+ // A connection that died mid-shutdown is already disconnected from the
82
+ // caller's point of view, so treat it as success but still surface it.
83
+ if (isClosed(client)) {
84
+ logger?.warn(
85
+ "Redis connection closed before QUIT completed",
86
+ redactError(error),
87
+ );
88
+ return;
89
+ }
90
+
91
+ logger?.error("Failed to disconnect Redis", redactError(error));
92
+
93
+ throw error;
94
+ } finally {
95
+ clearTimeout(timer);
96
+ }
97
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from "./client/index.js";
2
+ export * from "./queue/index.js";
3
+ export * from "./logger.js";
4
+ export * from "./pipeline/index.js";