@objectstack/service-cluster-redis 5.1.1
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/.turbo/turbo-build.log +22 -0
- package/CHANGELOG.md +11 -0
- package/LICENSE +202 -0
- package/dist/index.cjs +460 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +248 -0
- package/dist/index.d.ts +248 -0
- package/dist/index.js +460 -0
- package/dist/index.js.map +1 -0
- package/package.json +45 -0
- package/src/client.ts +75 -0
- package/src/counter.ts +63 -0
- package/src/index.ts +111 -0
- package/src/kv.ts +187 -0
- package/src/lock.ts +205 -0
- package/src/pubsub.ts +174 -0
- package/src/redis.contract.test.ts +159 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +14 -0
package/src/pubsub.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import type { Redis } from 'ioredis';
|
|
4
|
+
import type {
|
|
5
|
+
IPubSub,
|
|
6
|
+
PubSubHandler,
|
|
7
|
+
PublishOptions,
|
|
8
|
+
SubscribeOptions,
|
|
9
|
+
Unsubscribe,
|
|
10
|
+
} from '@objectstack/spec/contracts';
|
|
11
|
+
import { duplicateForPubSub } from './client.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Wire-format envelope sent over Redis. Adds `fromNode` and
|
|
15
|
+
* `publishedAt` so subscribers see the same surface as the memory
|
|
16
|
+
* driver. The user payload is nested under `p` to avoid colliding with
|
|
17
|
+
* reserved keys.
|
|
18
|
+
*/
|
|
19
|
+
interface RedisPubSubEnvelope {
|
|
20
|
+
n?: string;
|
|
21
|
+
t: number;
|
|
22
|
+
p: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RedisPubSubOptions {
|
|
26
|
+
/** Already-connected client used for PUBLISH. */
|
|
27
|
+
client: Redis;
|
|
28
|
+
/** Optional node id surfaced as `fromNode` on every delivered message. */
|
|
29
|
+
nodeId?: string;
|
|
30
|
+
/** Key namespace prefix applied to every channel (default: 'os:'). */
|
|
31
|
+
keyPrefix?: string;
|
|
32
|
+
/** Error sink for subscriber handler exceptions. */
|
|
33
|
+
onError?: (err: unknown, channel: string) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Redis pub/sub implementation of {@link IPubSub}.
|
|
38
|
+
*
|
|
39
|
+
* Uses two ioredis clients under the hood:
|
|
40
|
+
* - `publisher` (caller-provided) — runs PUBLISH commands
|
|
41
|
+
* - `subscriber` (auto-duplicated) — held in subscribe mode, can't run
|
|
42
|
+
* regular commands per Redis protocol
|
|
43
|
+
*
|
|
44
|
+
* Delivery semantics match Redis pub/sub: at-most-once, fire-and-forget,
|
|
45
|
+
* no persistence. For at-least-once + replay use the planned `streams`
|
|
46
|
+
* adapter (separate driver).
|
|
47
|
+
*
|
|
48
|
+
* Channel names are prefixed with `keyPrefix` before being sent to
|
|
49
|
+
* Redis, so the same Redis instance can host multiple isolated
|
|
50
|
+
* ObjectStack deployments.
|
|
51
|
+
*/
|
|
52
|
+
export class RedisPubSub implements IPubSub {
|
|
53
|
+
private readonly publisher: Redis;
|
|
54
|
+
private readonly subscriber: Redis;
|
|
55
|
+
private readonly nodeId?: string;
|
|
56
|
+
private readonly keyPrefix: string;
|
|
57
|
+
private readonly onError: (err: unknown, channel: string) => void;
|
|
58
|
+
private readonly subs = new Map<string, Set<PubSubHandler<unknown>>>();
|
|
59
|
+
private closed = false;
|
|
60
|
+
|
|
61
|
+
constructor(opts: RedisPubSubOptions) {
|
|
62
|
+
this.publisher = opts.client;
|
|
63
|
+
this.subscriber = duplicateForPubSub(opts.client);
|
|
64
|
+
this.nodeId = opts.nodeId;
|
|
65
|
+
this.keyPrefix = opts.keyPrefix ?? 'os:';
|
|
66
|
+
this.onError =
|
|
67
|
+
opts.onError ??
|
|
68
|
+
((err, channel) => {
|
|
69
|
+
// eslint-disable-next-line no-console
|
|
70
|
+
console.error(`[RedisPubSub] handler error on "${channel}":`, err);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
this.subscriber.on('message', (raw: string, data: string) => {
|
|
74
|
+
this.dispatch(raw, data);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async publish<T = unknown>(
|
|
79
|
+
channel: string,
|
|
80
|
+
payload: T,
|
|
81
|
+
_opts?: PublishOptions,
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
if (this.closed) throw new Error('RedisPubSub is closed');
|
|
84
|
+
const envelope: RedisPubSubEnvelope = {
|
|
85
|
+
n: this.nodeId,
|
|
86
|
+
t: Date.now(),
|
|
87
|
+
p: payload,
|
|
88
|
+
};
|
|
89
|
+
await this.publisher.publish(this.prefixed(channel), JSON.stringify(envelope));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
subscribe<T = unknown>(
|
|
93
|
+
channel: string,
|
|
94
|
+
handler: PubSubHandler<T>,
|
|
95
|
+
_opts?: SubscribeOptions,
|
|
96
|
+
): Unsubscribe {
|
|
97
|
+
if (this.closed) throw new Error('RedisPubSub is closed');
|
|
98
|
+
const prefixed = this.prefixed(channel);
|
|
99
|
+
let bucket = this.subs.get(prefixed);
|
|
100
|
+
if (!bucket) {
|
|
101
|
+
bucket = new Set();
|
|
102
|
+
this.subs.set(prefixed, bucket);
|
|
103
|
+
// Fire-and-forget; if subscribe fails the next publish will
|
|
104
|
+
// simply not deliver — caller can resubscribe.
|
|
105
|
+
void this.subscriber.subscribe(prefixed).catch((err) => {
|
|
106
|
+
this.onError(err, channel);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const wrapped = handler as PubSubHandler<unknown>;
|
|
110
|
+
bucket.add(wrapped);
|
|
111
|
+
|
|
112
|
+
let disposed = false;
|
|
113
|
+
return () => {
|
|
114
|
+
if (disposed) return;
|
|
115
|
+
disposed = true;
|
|
116
|
+
const b = this.subs.get(prefixed);
|
|
117
|
+
if (!b) return;
|
|
118
|
+
b.delete(wrapped);
|
|
119
|
+
if (b.size === 0) {
|
|
120
|
+
this.subs.delete(prefixed);
|
|
121
|
+
void this.subscriber.unsubscribe(prefixed).catch(() => { /* swallow */ });
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async close(): Promise<void> {
|
|
127
|
+
if (this.closed) return;
|
|
128
|
+
this.closed = true;
|
|
129
|
+
this.subs.clear();
|
|
130
|
+
try { await this.subscriber.quit(); } catch { /* swallow */ }
|
|
131
|
+
// We don't quit `publisher` — caller owns it.
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private prefixed(channel: string): string {
|
|
135
|
+
return `${this.keyPrefix}ps:${channel}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private dispatch(prefixedChannel: string, data: string): void {
|
|
139
|
+
const bucket = this.subs.get(prefixedChannel);
|
|
140
|
+
if (!bucket || bucket.size === 0) return;
|
|
141
|
+
|
|
142
|
+
let envelope: RedisPubSubEnvelope;
|
|
143
|
+
try {
|
|
144
|
+
envelope = JSON.parse(data) as RedisPubSubEnvelope;
|
|
145
|
+
} catch (err) {
|
|
146
|
+
this.onError(err, prefixedChannel);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Strip our keyPrefix so the handler sees the logical channel.
|
|
151
|
+
const logical = prefixedChannel.startsWith(`${this.keyPrefix}ps:`)
|
|
152
|
+
? prefixedChannel.slice(`${this.keyPrefix}ps:`.length)
|
|
153
|
+
: prefixedChannel;
|
|
154
|
+
|
|
155
|
+
const snapshot = Array.from(bucket);
|
|
156
|
+
for (const handler of snapshot) {
|
|
157
|
+
try {
|
|
158
|
+
const result = handler({
|
|
159
|
+
channel: logical,
|
|
160
|
+
payload: envelope.p,
|
|
161
|
+
publishedAt: envelope.t,
|
|
162
|
+
fromNode: envelope.n,
|
|
163
|
+
});
|
|
164
|
+
if (result && typeof (result as Promise<void>).then === 'function') {
|
|
165
|
+
(result as Promise<void>).catch((err) =>
|
|
166
|
+
this.onError(err, logical),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
} catch (err) {
|
|
170
|
+
this.onError(err, logical);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Driver contract tests for the Redis cluster driver, run against
|
|
5
|
+
* `ioredis-mock` so they execute without a real Redis instance in CI.
|
|
6
|
+
*
|
|
7
|
+
* The same suites can be invoked against a live Redis by setting
|
|
8
|
+
* `RUN_REAL_REDIS=1` and providing `REDIS_URL` — see the conditional
|
|
9
|
+
* `describe.skipIf` blocks at the bottom.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// @ts-expect-error — ioredis-mock has no published types
|
|
13
|
+
import RedisMock from 'ioredis-mock';
|
|
14
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
15
|
+
import {
|
|
16
|
+
runLockContract,
|
|
17
|
+
runKVContract,
|
|
18
|
+
runCounterContract,
|
|
19
|
+
} from '@objectstack/service-cluster/testing';
|
|
20
|
+
|
|
21
|
+
import { RedisPubSub } from './pubsub.js';
|
|
22
|
+
import { RedisLock } from './lock.js';
|
|
23
|
+
import { RedisKV } from './kv.js';
|
|
24
|
+
import { RedisCounter } from './counter.js';
|
|
25
|
+
|
|
26
|
+
// ioredis-mock shares state across instances by default, so each
|
|
27
|
+
// primitive gets a unique key-prefix per test to ensure isolation.
|
|
28
|
+
let suffix = 0;
|
|
29
|
+
const uniquePrefix = () => `t${++suffix}:`;
|
|
30
|
+
const makeClient = () => new RedisMock();
|
|
31
|
+
|
|
32
|
+
runLockContract('redis(mock)', async () =>
|
|
33
|
+
new RedisLock({ client: makeClient(), keyPrefix: uniquePrefix() }),
|
|
34
|
+
);
|
|
35
|
+
runKVContract('redis(mock)', async () =>
|
|
36
|
+
new RedisKV({ client: makeClient(), keyPrefix: uniquePrefix() }),
|
|
37
|
+
);
|
|
38
|
+
runCounterContract('redis(mock)', async () =>
|
|
39
|
+
new RedisCounter({ client: makeClient(), keyPrefix: uniquePrefix() }),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
// PubSub: Redis delivery is async (network roundtrip even for mock), so
|
|
43
|
+
// we can't reuse the synchronous contract suite. Cover the same surface
|
|
44
|
+
// here with explicit waits.
|
|
45
|
+
describe('IPubSub contract — redis(mock)', () => {
|
|
46
|
+
const flush = () => new Promise<void>((r) => setTimeout(r, 10));
|
|
47
|
+
|
|
48
|
+
it('delivers published messages to subscribers', async () => {
|
|
49
|
+
const bus = new RedisPubSub({ client: makeClient(), keyPrefix: uniquePrefix() });
|
|
50
|
+
const received: unknown[] = [];
|
|
51
|
+
bus.subscribe<{ n: number }>('ch', (msg) => { received.push(msg.payload); });
|
|
52
|
+
await flush(); // let SUBSCRIBE register
|
|
53
|
+
await bus.publish('ch', { n: 1 });
|
|
54
|
+
await bus.publish('ch', { n: 2 });
|
|
55
|
+
await flush();
|
|
56
|
+
expect(received).toEqual([{ n: 1 }, { n: 2 }]);
|
|
57
|
+
await bus.close();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('does not deliver to other channels', async () => {
|
|
61
|
+
const bus = new RedisPubSub({ client: makeClient(), keyPrefix: uniquePrefix() });
|
|
62
|
+
const h = vi.fn();
|
|
63
|
+
bus.subscribe('a', h);
|
|
64
|
+
await flush();
|
|
65
|
+
await bus.publish('b', { x: 1 });
|
|
66
|
+
await flush();
|
|
67
|
+
expect(h).not.toHaveBeenCalled();
|
|
68
|
+
await bus.close();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('supports multiple subscribers per channel', async () => {
|
|
72
|
+
const bus = new RedisPubSub({ client: makeClient(), keyPrefix: uniquePrefix() });
|
|
73
|
+
const a = vi.fn();
|
|
74
|
+
const b = vi.fn();
|
|
75
|
+
bus.subscribe('ch', a);
|
|
76
|
+
bus.subscribe('ch', b);
|
|
77
|
+
await flush();
|
|
78
|
+
await bus.publish('ch', 'hi');
|
|
79
|
+
await flush();
|
|
80
|
+
expect(a).toHaveBeenCalledTimes(1);
|
|
81
|
+
expect(b).toHaveBeenCalledTimes(1);
|
|
82
|
+
await bus.close();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('unsubscribe stops delivery and is idempotent', async () => {
|
|
86
|
+
const bus = new RedisPubSub({ client: makeClient(), keyPrefix: uniquePrefix() });
|
|
87
|
+
const h = vi.fn();
|
|
88
|
+
const off = bus.subscribe('ch', h);
|
|
89
|
+
await flush();
|
|
90
|
+
await bus.publish('ch', 1);
|
|
91
|
+
await flush();
|
|
92
|
+
off();
|
|
93
|
+
off();
|
|
94
|
+
await bus.publish('ch', 2);
|
|
95
|
+
await flush();
|
|
96
|
+
expect(h).toHaveBeenCalledTimes(1);
|
|
97
|
+
await bus.close();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('isolates handler errors from siblings', async () => {
|
|
101
|
+
const bus = new RedisPubSub({ client: makeClient(), keyPrefix: uniquePrefix() });
|
|
102
|
+
bus.subscribe('ch', () => { throw new Error('boom'); });
|
|
103
|
+
const ok = vi.fn();
|
|
104
|
+
bus.subscribe('ch', ok);
|
|
105
|
+
await flush();
|
|
106
|
+
await expect(bus.publish('ch', 1)).resolves.toBeUndefined();
|
|
107
|
+
await flush();
|
|
108
|
+
expect(ok).toHaveBeenCalledTimes(1);
|
|
109
|
+
await bus.close();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('close rejects further publishes', async () => {
|
|
113
|
+
const bus = new RedisPubSub({ client: makeClient(), keyPrefix: uniquePrefix() });
|
|
114
|
+
await bus.close();
|
|
115
|
+
await expect(bus.publish('ch', 1)).rejects.toThrow(/closed/);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('Redis driver — wiring', () => {
|
|
120
|
+
it('exports a registerable driver and defineCluster picks it up', async () => {
|
|
121
|
+
await import('./index.js');
|
|
122
|
+
const { defineCluster } = await import('@objectstack/service-cluster');
|
|
123
|
+
const client = makeClient();
|
|
124
|
+
const cluster = defineCluster({
|
|
125
|
+
driver: 'redis',
|
|
126
|
+
nodeId: 'mock-node',
|
|
127
|
+
driverOptions: { client, keyPrefix: uniquePrefix() },
|
|
128
|
+
});
|
|
129
|
+
expect(cluster.driver).toBe('redis');
|
|
130
|
+
expect(cluster.nodeId).toBe('mock-node');
|
|
131
|
+
|
|
132
|
+
expect(await cluster.counter.incr('seq')).toBe(1n);
|
|
133
|
+
expect(await cluster.counter.incr('seq')).toBe(2n);
|
|
134
|
+
|
|
135
|
+
await cluster.kv.set('k', { hello: 'world' });
|
|
136
|
+
const got = await cluster.kv.get<{ hello: string }>('k');
|
|
137
|
+
expect(got?.value).toEqual({ hello: 'world' });
|
|
138
|
+
|
|
139
|
+
const handle = await cluster.lock.acquire('foo');
|
|
140
|
+
expect(handle).not.toBeNull();
|
|
141
|
+
expect(handle!.fencingToken).toBeGreaterThan(0n);
|
|
142
|
+
await handle!.release();
|
|
143
|
+
|
|
144
|
+
await cluster.close();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('does NOT quit caller-owned client on close', async () => {
|
|
148
|
+
await import('./index.js');
|
|
149
|
+
const { defineCluster } = await import('@objectstack/service-cluster');
|
|
150
|
+
const client = makeClient();
|
|
151
|
+
const cluster = defineCluster({
|
|
152
|
+
driver: 'redis',
|
|
153
|
+
nodeId: 'mock-node-2',
|
|
154
|
+
driverOptions: { client, keyPrefix: uniquePrefix() },
|
|
155
|
+
});
|
|
156
|
+
await cluster.close();
|
|
157
|
+
await expect(client.set('post-close', '1')).resolves.toBe('OK');
|
|
158
|
+
});
|
|
159
|
+
});
|
package/tsconfig.json
ADDED
package/tsup.config.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { defineConfig } from 'tsup';
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
entry: ['src/index.ts'],
|
|
7
|
+
splitting: true,
|
|
8
|
+
sourcemap: true,
|
|
9
|
+
clean: true,
|
|
10
|
+
dts: true,
|
|
11
|
+
format: ['esm', 'cjs'],
|
|
12
|
+
target: 'es2020',
|
|
13
|
+
external: ['vitest', 'ioredis'],
|
|
14
|
+
});
|