@unikvs/redis.node 0.0.0 → 0.0.14

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/src/redis.ts ADDED
@@ -0,0 +1,544 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import { type IStorage, KeyNotFoundError } from "@unikvs/core";
4
+ import {
5
+ type ClusterNode,
6
+ type RedisOptions,
7
+ type ClusterOptions,
8
+ Redis as Ioredis,
9
+ } from "ioredis";
10
+
11
+ import {
12
+ CloseTimeoutError,
13
+ ConnectTimeoutError,
14
+ ClusterNotSupportedError,
15
+ InvalidCloseTimeoutError,
16
+ } from "./errors.js";
17
+
18
+ /**
19
+ * [API Reference](https://tai-kun.github.io/unikvs/packages/redis-node#redisclusterspec)
20
+ */
21
+ export type RedisClusterSpec = {
22
+ /**
23
+ * [API Reference](https://tai-kun.github.io/unikvs/packages/redis-node#redisclusterspec-nodes)
24
+ */
25
+ readonly nodes: readonly ClusterNode[];
26
+
27
+ /**
28
+ * [API Reference](https://tai-kun.github.io/unikvs/packages/redis-node#redisclusterspec-options)
29
+ */
30
+ readonly options?: ClusterOptions | undefined;
31
+ };
32
+
33
+ /**
34
+ * [API Reference](https://tai-kun.github.io/unikvs/packages/redis-node#redisstorageoptions)
35
+ */
36
+ export type RedisStorageOptions = Omit<RedisOptions, "keyPrefix" | "lazyConnect"> & {
37
+ /**
38
+ * [API Reference](https://tai-kun.github.io/unikvs/packages/redis-node#redisstorageoptions-keyprefix)
39
+ */
40
+ readonly keyPrefix?: string | undefined;
41
+
42
+ /**
43
+ * [API Reference](https://tai-kun.github.io/unikvs/packages/redis-node#redisstorageoptions-cluster)
44
+ */
45
+ readonly cluster?: RedisClusterSpec | undefined;
46
+
47
+ /**
48
+ * [API Reference](https://tai-kun.github.io/unikvs/packages/redis-node#redisstorageoptions-closetimeout)
49
+ */
50
+ readonly closeTimeout?: number | undefined;
51
+ };
52
+
53
+ /**
54
+ * `SCAN` 1 回あたりにヒントとして渡すキー数です。
55
+ *
56
+ * 削除の往復回数と 1 回の `DEL` に渡す引数数を抑えるために使用します。
57
+ */
58
+ const SCAN_COUNT = 1000;
59
+
60
+ /**
61
+ * 接続確立の既定タイムアウト (ミリ秒) です。
62
+ */
63
+ const DEFAULT_CONNECT_TIMEOUT_MS = 5000;
64
+
65
+ /**
66
+ * 切断処理の既定タイムアウト (ミリ秒) です。
67
+ */
68
+ const DEFAULT_CLOSE_TIMEOUT_MS = 5000;
69
+
70
+ /**
71
+ * ストレージの動作に必要な接続を保持する型定義です。
72
+ */
73
+ type Connection = {
74
+ /**
75
+ * 操作に使うクライアントです。
76
+ */
77
+ readonly client: Ioredis;
78
+ };
79
+
80
+ /**
81
+ * [API Reference](https://tai-kun.github.io/unikvs/packages/redis-node#redis)
82
+ */
83
+ export default class Redis implements IStorage {
84
+ /**
85
+ * [API Reference](https://tai-kun.github.io/unikvs/packages/redis-node#redis)
86
+ */
87
+ public readonly name: string;
88
+
89
+ /**
90
+ * Redis への接続を保持します。
91
+ *
92
+ * ストレージがオープンされるまで null です。
93
+ */
94
+ #con: Connection | null;
95
+
96
+ /**
97
+ * 接続先の URL です。
98
+ *
99
+ * 未指定の場合はクライアントが環境変数 (`REDIS_URL`、`VALKEY_URL`) から解決します。
100
+ */
101
+ readonly #url: string | undefined;
102
+
103
+ /**
104
+ * `closeTimeout` は本パッケージ固有の拡張です。
105
+ *
106
+ * `this.#options` には含めず、別フィールドで保管します。
107
+ * `this.#options` は `new Ioredis` にそのまま spread できる透過バッグです。
108
+ */
109
+ readonly #options: Omit<RedisStorageOptions, "keyPrefix" | "cluster" | "closeTimeout">;
110
+
111
+ /**
112
+ * `close` 時の切断待ち時間 (ミリ秒) です。
113
+ *
114
+ * `ioredis` の `disconnectTimeout` (ソケット切断待機) とは別物です。
115
+ */
116
+ readonly #closeTimeout: number;
117
+
118
+ /**
119
+ * すべてのキーの先頭に付与するプレフィックスです。
120
+ */
121
+ readonly #keyPrefix: string;
122
+
123
+ /**
124
+ * [API Reference](https://tai-kun.github.io/unikvs/packages/redis-node#redis)
125
+ */
126
+ public constructor(url?: string, options: RedisStorageOptions = {}) {
127
+ const {
128
+ cluster,
129
+ keyPrefix = "unikvs:",
130
+ closeTimeout = DEFAULT_CLOSE_TIMEOUT_MS,
131
+ ...redisOptions
132
+ } = options;
133
+
134
+ // v1 は standalone のみに対応します。
135
+ // `cluster` を指定したときは、`closeTimeout` 検証より先に拒否します。
136
+ if (cluster !== undefined) {
137
+ throw new ClusterNotSupportedError();
138
+ }
139
+
140
+ // `undefined` は既定値 5000ms になるため対象外とします。
141
+ // 明示値が有限の正数でない場合のみ拒否します。
142
+ if (typeof closeTimeout !== "number" || !Number.isFinite(closeTimeout) || closeTimeout <= 0) {
143
+ throw new InvalidCloseTimeoutError({ actual: closeTimeout });
144
+ }
145
+
146
+ this.name = "Redis";
147
+ this.#con = null;
148
+ this.#url = url;
149
+ this.#options = redisOptions;
150
+ this.#closeTimeout = closeTimeout;
151
+ this.#keyPrefix = keyPrefix;
152
+ }
153
+
154
+ /**
155
+ * ストレージがオープン済みかどうかです。
156
+ *
157
+ * @returns オープン済みなら真です。
158
+ */
159
+ public get isOpen(): boolean {
160
+ return this.#con !== null;
161
+ }
162
+
163
+ /**
164
+ * ストレージをオープンします。
165
+ *
166
+ * `ioredis` クライアントを作成して接続します。
167
+ *
168
+ * @param _c オープンコンテキストです。
169
+ *
170
+ * @returns オープンが完了したら解決します。
171
+ */
172
+ public async open(_c: IStorage.OpenContext): Promise<void> {
173
+ // 並行 `open` 同士および `open` 接続待ち中の `close` の同時呼び出しは禁止 (事前条件) です。
174
+ // 呼び出し側で直列化してください。
175
+ // 並行 `close` 同士は一方が `TypeError` reject する形で直列化されます。
176
+ if (this.#con) {
177
+ await this.close(_c as unknown as IStorage.CloseContext);
178
+ }
179
+
180
+ // 空文字の `url`・環境変数は未指定として扱います (本パッケージ仕様)。
181
+ // `url` は `redis://` / `rediss://` 形式での指定を要求します。
182
+ // 実行時の形式検証は行いません (bare 形式は非サポート・動作未保証)。
183
+ const explicitUrl = this.#url === undefined || this.#url === "" ? undefined : this.#url;
184
+ const redisUrl =
185
+ process.env["REDIS_URL"] === undefined || process.env["REDIS_URL"] === ""
186
+ ? undefined
187
+ : process.env["REDIS_URL"];
188
+ const valkeyUrl =
189
+ process.env["VALKEY_URL"] === undefined || process.env["VALKEY_URL"] === ""
190
+ ? undefined
191
+ : process.env["VALKEY_URL"];
192
+ const url = explicitUrl ?? redisUrl ?? valkeyUrl;
193
+ const connectTimeout = this.#options.connectTimeout ?? DEFAULT_CONNECT_TIMEOUT_MS;
194
+
195
+ // `ioredis` v6 のコンストラクタ型は `replyMapping` に明示 `undefined` を許しません。
196
+ // `exactOptionalPropertyTypes` 下では spread のまま渡せないため型表明を付けます。
197
+ // 実行時の値は変わらず、`replyMapping` を含む透過は保たれます。
198
+ type ConnectOptions = RedisOptions & { replyMapping?: "legacy" };
199
+
200
+ const options = {
201
+ ...this.#options,
202
+ lazyConnect: true,
203
+ connectTimeout,
204
+ } as ConnectOptions;
205
+ const raw = url === undefined ? new Ioredis(options) : new Ioredis(url, options);
206
+
207
+ // `ioredis` は接続失敗・再試行時に `error` イベントを emit します。
208
+ // unhandled `error` によるクラッシュを抑止するため no-op リスナを付けます。
209
+ // 可観測性は v1 では提供しません。
210
+ raw.on("error", () => {});
211
+
212
+ let timer: ReturnType<typeof setTimeout> | undefined;
213
+
214
+ try {
215
+ await Promise.race([
216
+ raw.connect(),
217
+ new Promise<never>((_, reject) => {
218
+ timer = setTimeout(() => {
219
+ reject(new ConnectTimeoutError({ timeoutMs: connectTimeout }));
220
+ }, connectTimeout);
221
+ }),
222
+ ]);
223
+ } catch (ex) {
224
+ raw.disconnect();
225
+ throw ex;
226
+ } finally {
227
+ if (timer !== undefined) {
228
+ clearTimeout(timer);
229
+ }
230
+ }
231
+
232
+ this.#con = { client: raw };
233
+ }
234
+
235
+ /**
236
+ * ストレージをクローズします。
237
+ *
238
+ * `quit` で丁寧に閉じ、失敗時は `disconnect` で掃除します。
239
+ *
240
+ * @param _c クローズコンテキストです。
241
+ *
242
+ * @returns クローズが完了したら解決します。
243
+ */
244
+ public async close(_c: IStorage.CloseContext): Promise<void> {
245
+ // 未 `open`・二重 `close` は `TypeError` で reject されます。
246
+ // 必ず `await` し、結果を浮かせないでください。
247
+ const { client } = this.#getConnection();
248
+
249
+ // 先行 null 化により、並行 `close` 同士は二重 `quit` になりません。
250
+ this.#con = null;
251
+
252
+ const closeTimeout = this.#closeTimeout;
253
+ let timer: ReturnType<typeof setTimeout> | undefined;
254
+
255
+ try {
256
+ await Promise.race([
257
+ client.quit(),
258
+ new Promise<never>((_, reject) => {
259
+ timer = setTimeout(() => {
260
+ reject(new CloseTimeoutError({ timeoutMs: closeTimeout }));
261
+ }, closeTimeout);
262
+ }),
263
+ ]);
264
+ } catch {
265
+ // `quit()` の失敗全般 (タイムアウト含む) は `disconnect()` で掃除して正常復帰します。
266
+ client.disconnect();
267
+ } finally {
268
+ if (timer !== undefined) {
269
+ clearTimeout(timer);
270
+ }
271
+ }
272
+ }
273
+
274
+ /**
275
+ * バイト列を書き込みます。
276
+ *
277
+ * 指定したキーで値を上書きします。
278
+ *
279
+ * @param c 書き込みコンテキストです。
280
+ * @param data 保存するバイト列です。
281
+ *
282
+ * @returns 書き込みが完了したら解決します。
283
+ */
284
+ public async write(c: IStorage.WriteContext, data: Uint8Array<ArrayBuffer>): Promise<void> {
285
+ const { client } = this.#getConnection();
286
+
287
+ c.signal.throwIfAborted();
288
+
289
+ await client.set(this.#toKey(c.key), Buffer.from(data));
290
+ }
291
+
292
+ /**
293
+ * バイト列を読み取ります。
294
+ *
295
+ * 指定したキーの値を取得します。
296
+ *
297
+ * @param c 読み取りコンテキストです。
298
+ *
299
+ * @returns 読み取ったバイト列です。
300
+ */
301
+ public async read(c: IStorage.ReadContext): Promise<Uint8Array<ArrayBuffer>> {
302
+ const { client } = this.#getConnection();
303
+
304
+ c.signal.throwIfAborted();
305
+
306
+ const data = await client.getBuffer(this.#toKey(c.key));
307
+
308
+ if (data === null) {
309
+ throw new KeyNotFoundError({ key: c.key });
310
+ }
311
+
312
+ return new Uint8Array(data);
313
+ }
314
+
315
+ /**
316
+ * キーの存在を確認します。
317
+ *
318
+ * @param c 存在確認コンテキストです。
319
+ *
320
+ * @returns 存在すれば真です。
321
+ */
322
+ public async exists(c: IStorage.ExistsContext): Promise<boolean> {
323
+ const { client } = this.#getConnection();
324
+
325
+ c.signal.throwIfAborted();
326
+
327
+ return (await client.exists(this.#toKey(c.key))) > 0;
328
+ }
329
+
330
+ /**
331
+ * キーを削除します。
332
+ *
333
+ * 存在しないキーも成功扱いにします。
334
+ *
335
+ * @param c 削除コンテキストです。
336
+ *
337
+ * @returns 削除が完了したら解決します。
338
+ */
339
+ public async delete(c: IStorage.DeleteContext): Promise<void> {
340
+ const { client } = this.#getConnection();
341
+
342
+ c.signal.throwIfAborted();
343
+
344
+ await client.del(this.#toKey(c.key));
345
+ }
346
+
347
+ /**
348
+ * 接頭辞に一致するキーをすべて削除します。
349
+ *
350
+ * `SCAN` で列挙しながら削除します。
351
+ *
352
+ * @param c クリアコンテキストです。
353
+ *
354
+ * @returns 削除が完了したら解決します。
355
+ */
356
+ public async clear(c: IStorage.ClearContext): Promise<void> {
357
+ const { client } = this.#getConnection();
358
+
359
+ c.signal.throwIfAborted();
360
+
361
+ // prefix が空文字の場合は MATCH "*" となり、選択中のデータベースの全キーが対象になります。
362
+ const pattern = `${this.#keyPrefix}*`;
363
+ await this.#clearPattern(client, pattern, c.signal);
364
+ }
365
+
366
+ /**
367
+ * 書き込み用ストリームを取得します。
368
+ *
369
+ * 一時キーに追記し、`close` 時に最終キーへ置き換えます。
370
+ *
371
+ * @param c 書き込みストリーム取得コンテキストです。
372
+ *
373
+ * @returns 書き込み用ストリームです。
374
+ */
375
+ public getWritable(c: IStorage.GetWritableContext): WritableStream<Uint8Array<ArrayBuffer>> {
376
+ const { client } = this.#getConnection();
377
+
378
+ c.signal.throwIfAborted();
379
+
380
+ const dest = this.#toKey(c.key);
381
+ // 一意なサフィックスにより、同一キーへの並行書き込みでも一時キーが衝突しません。
382
+ const tmp = `${dest}.${randomUUID()}.tmp`;
383
+
384
+ // 中断と失敗のどちらからでも後始末が走るため、完了処理は一度だけ行います。
385
+ let finished = false;
386
+ const removeAbortListener = (): void => {
387
+ c.signal.removeEventListener("abort", onAbort);
388
+ };
389
+ const discardTemporary = async (): Promise<void> => {
390
+ try {
391
+ await client.del(tmp);
392
+ } catch {
393
+ // 後始末としての削除のため、失敗しても無視します。
394
+ }
395
+ };
396
+ const finish = async (): Promise<void> => {
397
+ if (finished) {
398
+ return;
399
+ }
400
+
401
+ finished = true;
402
+ removeAbortListener();
403
+ await discardTemporary();
404
+ };
405
+ const onAbort = (): void => {
406
+ void finish();
407
+ };
408
+ c.signal.addEventListener("abort", onAbort, { once: true });
409
+
410
+ return new WritableStream<Uint8Array<ArrayBuffer>>({
411
+ async abort() {
412
+ await finish();
413
+ },
414
+ async close() {
415
+ c.signal.throwIfAborted();
416
+ try {
417
+ // 全チャンクの追記が完了した時点で初めて最終キーへ置き換えます (swap-on-close)。
418
+ await client.rename(tmp, dest);
419
+ } catch (ex) {
420
+ await finish();
421
+ throw ex;
422
+ }
423
+ // rename 後は一時キーが存在しないため、完了済みとして中断時の削除を行いません。
424
+ finished = true;
425
+ removeAbortListener();
426
+ },
427
+ async start() {
428
+ try {
429
+ c.signal.throwIfAborted();
430
+ // 0 バイトの書き込みでも close 時の RENAME が成功するよう、空の文字列で一時キーを作ります。
431
+ await client.set(tmp, "");
432
+ } catch (ex) {
433
+ await finish();
434
+ throw ex;
435
+ }
436
+ },
437
+ async write(chunk) {
438
+ c.signal.throwIfAborted();
439
+ try {
440
+ await client.append(tmp, Buffer.from(chunk));
441
+ } catch (ex) {
442
+ await finish();
443
+ throw ex;
444
+ }
445
+ },
446
+ });
447
+ }
448
+
449
+ /**
450
+ * 読み取り用ストリームを取得します。
451
+ *
452
+ * 取得した値を単一チャンクとして送出します。
453
+ *
454
+ * @param c 読み取りストリーム取得コンテキストです。
455
+ *
456
+ * @returns 読み取り用ストリームです。
457
+ */
458
+ public getReadable(c: IStorage.GetReadableContext): ReadableStream<Uint8Array<ArrayBuffer>> {
459
+ const { client } = this.#getConnection();
460
+
461
+ c.signal.throwIfAborted();
462
+
463
+ const dest = this.#toKey(c.key);
464
+ let sent = false;
465
+
466
+ // Redis の GET は値を一括で返すため、取得したバイト列を単一のチャンクとして送出します。
467
+ return new ReadableStream<Uint8Array<ArrayBuffer>>({
468
+ async pull(controller) {
469
+ if (sent) {
470
+ controller.close();
471
+
472
+ return;
473
+ }
474
+
475
+ sent = true;
476
+ c.signal.throwIfAborted();
477
+
478
+ const data = await client.getBuffer(dest);
479
+
480
+ if (data === null) {
481
+ throw new KeyNotFoundError({ key: c.key });
482
+ }
483
+
484
+ controller.enqueue(new Uint8Array(data));
485
+ controller.close();
486
+ },
487
+ });
488
+ }
489
+
490
+ /**
491
+ * 開いている接続を返します。
492
+ *
493
+ * 未オープン時は例外を投げます。
494
+ *
495
+ * @returns オープン済みの接続です。
496
+ */
497
+ #getConnection(): Connection {
498
+ const con = this.#con;
499
+
500
+ if (con === null) {
501
+ throw new TypeError("Redis is not open. Call open() first.");
502
+ }
503
+
504
+ return con;
505
+ }
506
+
507
+ /**
508
+ * unikvs のキーにプレフィックスを付与し、Redis のキーへ変換します。
509
+ *
510
+ * @param key 変換する論理キーです。
511
+ *
512
+ * @returns Redis 上の実キーです。
513
+ */
514
+ #toKey(key: string): string {
515
+ return `${this.#keyPrefix}${key}`;
516
+ }
517
+
518
+ /**
519
+ * 接頭辞に一致するキーを走査して削除します。
520
+ *
521
+ * `SCAN` で頁送りしながら `DEL` します。
522
+ *
523
+ * @param client 操作に使うクライアントです。
524
+ * @param pattern 削除対象の一致パターンです。
525
+ * @param signal 中断を知らせるシグナルです。
526
+ *
527
+ * @returns 削除が完了したら解決します。
528
+ */
529
+ async #clearPattern(client: Ioredis, pattern: string, signal: AbortSignal): Promise<void> {
530
+ let cursor = "0";
531
+
532
+ do {
533
+ signal.throwIfAborted();
534
+
535
+ const [nextCursor, keys] = await client.scan(cursor, "MATCH", pattern, "COUNT", SCAN_COUNT);
536
+
537
+ if (keys.length > 0) {
538
+ await client.del(...keys);
539
+ }
540
+
541
+ cursor = nextCursor;
542
+ } while (cursor !== "0");
543
+ }
544
+ }