@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/src/counter.ts ADDED
@@ -0,0 +1,63 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { Redis } from 'ioredis';
4
+ import type { ICounter, CounterIncrOptions } from '@objectstack/spec/contracts';
5
+
6
+ export interface RedisCounterOptions {
7
+ client: Redis;
8
+ keyPrefix?: string;
9
+ }
10
+
11
+ /**
12
+ * Redis-backed monotonic counter using INCRBY. Each counter key lives at
13
+ * `{prefix}ctr:{key}` so the same Redis instance can host multiple
14
+ * tenants without collision.
15
+ *
16
+ * Values are stored as ASCII decimals (Redis convention). We surface
17
+ * them as `bigint` to match the contract — INCRBY accepts up to a
18
+ * signed 64-bit range, well beyond Number.MAX_SAFE_INTEGER.
19
+ */
20
+ export class RedisCounter implements ICounter {
21
+ private readonly client: Redis;
22
+ private readonly keyPrefix: string;
23
+ private closed = false;
24
+
25
+ constructor(opts: RedisCounterOptions) {
26
+ this.client = opts.client;
27
+ this.keyPrefix = opts.keyPrefix ?? 'os:';
28
+ }
29
+
30
+ async incr(key: string, opts: CounterIncrOptions = {}): Promise<bigint> {
31
+ if (this.closed) throw new Error('RedisCounter is closed');
32
+ const by = opts.by ?? 1;
33
+ const next = await this.client.incrby(this.ctrKey(key), by);
34
+ return BigInt(next);
35
+ }
36
+
37
+ async peek(key: string): Promise<bigint> {
38
+ const raw = await this.client.get(this.ctrKey(key));
39
+ if (raw === null) return 0n;
40
+ try {
41
+ return BigInt(raw);
42
+ } catch {
43
+ return 0n;
44
+ }
45
+ }
46
+
47
+ async reset(key: string, value: bigint = 0n): Promise<void> {
48
+ if (this.closed) throw new Error('RedisCounter is closed');
49
+ if (value === 0n) {
50
+ await this.client.del(this.ctrKey(key));
51
+ return;
52
+ }
53
+ await this.client.set(this.ctrKey(key), value.toString());
54
+ }
55
+
56
+ async close(): Promise<void> {
57
+ this.closed = true;
58
+ }
59
+
60
+ private ctrKey(key: string): string {
61
+ return `${this.keyPrefix}ctr:${key}`;
62
+ }
63
+ }
package/src/index.ts ADDED
@@ -0,0 +1,111 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * @objectstack/service-cluster-redis
5
+ *
6
+ * Redis driver for `@objectstack/service-cluster`. Import this package
7
+ * once at process start to register the `'redis'` driver:
8
+ *
9
+ * ```ts
10
+ * import '@objectstack/service-cluster-redis';
11
+ * import { defineCluster } from '@objectstack/service-cluster';
12
+ *
13
+ * const cluster = defineCluster({
14
+ * driver: 'redis',
15
+ * url: 'redis://localhost:6379',
16
+ * nodeId: 'web-1',
17
+ * });
18
+ * ```
19
+ *
20
+ * The driver also exports raw constructors so callers who already own
21
+ * an ioredis client can compose primitives by hand.
22
+ */
23
+
24
+ import type { Redis } from 'ioredis';
25
+ import {
26
+ ComposedClusterService,
27
+ registerClusterDriver,
28
+ type DriverFactoryConfig,
29
+ } from '@objectstack/service-cluster';
30
+ import { createRedisClient } from './client.js';
31
+ import { RedisPubSub } from './pubsub.js';
32
+ import { RedisLock } from './lock.js';
33
+ import { RedisKV } from './kv.js';
34
+ import { RedisCounter } from './counter.js';
35
+
36
+ // Re-exports for advanced composition.
37
+ export { createRedisClient, duplicateForPubSub, type CreateRedisOptions } from './client.js';
38
+ export { RedisPubSub, type RedisPubSubOptions } from './pubsub.js';
39
+ export { RedisLock, type RedisLockOptions } from './lock.js';
40
+ export { RedisKV, VersionMismatchError, type RedisKVOptions } from './kv.js';
41
+ export { RedisCounter, type RedisCounterOptions } from './counter.js';
42
+
43
+ /**
44
+ * Driver-specific options accepted under `driverOptions` in
45
+ * `ClusterCapabilityConfig`. All are optional — supply a `client` to
46
+ * share a Redis pool with other services (e.g. `service-cache`).
47
+ */
48
+ export interface RedisDriverOptions {
49
+ /** Pre-built ioredis client. When provided, `url` is ignored. */
50
+ client?: Redis;
51
+ /** Key prefix applied to every Redis key (default: 'os:'). */
52
+ keyPrefix?: string;
53
+ /** Default lock TTL in ms. Overridden by `LockAcquireOptions.ttlMs`. */
54
+ lockTtlMs?: number;
55
+ /** Pub/sub subscriber error sink. */
56
+ onError?: (err: unknown, channel: string) => void;
57
+ }
58
+
59
+ /**
60
+ * Factory consumed by `defineCluster({ driver: 'redis' })`. Builds an
61
+ * `IClusterService` backed by Redis. Owns the underlying ioredis client
62
+ * iff it created it (i.e. when caller didn't pass `driverOptions.client`).
63
+ */
64
+ function redisDriverFactory(config: DriverFactoryConfig) {
65
+ const driverOpts = (config.driverOptions ?? {}) as RedisDriverOptions;
66
+ const ownsClient = !driverOpts.client;
67
+ const client =
68
+ driverOpts.client ??
69
+ createRedisClient({ url: config.url });
70
+
71
+ const keyPrefix = driverOpts.keyPrefix ?? 'os:';
72
+ const ttlMs = config.lockTtlMs ?? driverOpts.lockTtlMs;
73
+
74
+ const pubsub = new RedisPubSub({
75
+ client,
76
+ nodeId: config.nodeId,
77
+ keyPrefix,
78
+ onError: driverOpts.onError,
79
+ });
80
+ const lock = new RedisLock({
81
+ client,
82
+ keyPrefix,
83
+ defaultTtlMs: ttlMs,
84
+ nodeId: config.nodeId,
85
+ });
86
+ const kv = new RedisKV({ client, keyPrefix });
87
+ const counter = new RedisCounter({ client, keyPrefix });
88
+
89
+ const facade = new ComposedClusterService(
90
+ config.nodeId,
91
+ 'redis',
92
+ pubsub,
93
+ lock,
94
+ kv,
95
+ counter,
96
+ );
97
+
98
+ // Wrap close() so we also tear down the publisher client if we own it.
99
+ const originalClose = facade.close.bind(facade);
100
+ facade.close = async () => {
101
+ await originalClose();
102
+ if (ownsClient) {
103
+ try { await client.quit(); } catch { /* swallow */ }
104
+ }
105
+ };
106
+
107
+ return facade;
108
+ }
109
+
110
+ // Module-load registration — importing this package is enough.
111
+ registerClusterDriver('redis', redisDriverFactory);
package/src/kv.ts ADDED
@@ -0,0 +1,187 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { Redis } from 'ioredis';
4
+ import type { IKV, KVEntry, KVSetOptions } from '@objectstack/spec/contracts';
5
+
6
+ /**
7
+ * Stored payload shape: `{v: <value>, ver: <bigint as string>}`.
8
+ * Versions are stored as strings because Redis values are bytes and
9
+ * bigints can exceed JSON-safe-integer range.
10
+ */
11
+ interface StoredKV {
12
+ v: unknown;
13
+ ver: string;
14
+ }
15
+
16
+ export class VersionMismatchError extends Error {
17
+ constructor(
18
+ public readonly key: string,
19
+ public readonly expected: bigint,
20
+ public readonly actual: bigint,
21
+ ) {
22
+ super(
23
+ `KV version mismatch on "${key}": expected v${expected}, found v${actual}`,
24
+ );
25
+ this.name = 'VersionMismatchError';
26
+ }
27
+ }
28
+
29
+ export interface RedisKVOptions {
30
+ client: Redis;
31
+ keyPrefix?: string;
32
+ }
33
+
34
+ /**
35
+ * Redis-backed coordination KV with optimistic concurrency via WATCH/MULTI.
36
+ *
37
+ * Each `set()` performs:
38
+ * 1. WATCH key
39
+ * 2. GET key → read current version
40
+ * 3. compare to ifVersion (if provided)
41
+ * 4. MULTI / SET / [PEXPIRE] / EXEC
42
+ *
43
+ * If a competing writer modifies the key between WATCH and EXEC the
44
+ * transaction aborts and we throw `VersionMismatchError`, matching
45
+ * memory driver semantics.
46
+ *
47
+ * **Not** a cache — uses small JSON envelopes and per-key versioning.
48
+ * Use service-cache for high-throughput caching.
49
+ */
50
+ export class RedisKV implements IKV {
51
+ private readonly client: Redis;
52
+ private readonly keyPrefix: string;
53
+ private closed = false;
54
+
55
+ constructor(opts: RedisKVOptions) {
56
+ this.client = opts.client;
57
+ this.keyPrefix = opts.keyPrefix ?? 'os:';
58
+ }
59
+
60
+ async get<T = unknown>(key: string): Promise<KVEntry<T> | undefined> {
61
+ const raw = await this.client.get(this.kvKey(key));
62
+ if (raw === null) return undefined;
63
+ const parsed = this.parse(raw);
64
+ if (!parsed) return undefined;
65
+ const pttl = await this.client.pttl(this.kvKey(key));
66
+ const expiresAt = pttl > 0 ? Date.now() + pttl : undefined;
67
+ return {
68
+ key,
69
+ value: parsed.v as T,
70
+ version: BigInt(parsed.ver),
71
+ expiresAt,
72
+ };
73
+ }
74
+
75
+ async set<T = unknown>(
76
+ key: string,
77
+ value: T,
78
+ opts: KVSetOptions = {},
79
+ ): Promise<KVEntry<T>> {
80
+ if (this.closed) throw new Error('RedisKV is closed');
81
+ const physical = this.kvKey(key);
82
+ const ttlMs = opts.ttl && opts.ttl > 0 ? opts.ttl * 1000 : undefined;
83
+
84
+ // Optimistic-concurrency loop — Redis WATCH aborts the MULTI on
85
+ // any intervening write.
86
+ // eslint-disable-next-line no-constant-condition
87
+ while (true) {
88
+ await this.client.watch(physical);
89
+ const raw = await this.client.get(physical);
90
+ const existing = raw ? this.parse(raw) : undefined;
91
+ const existingVersion = existing ? BigInt(existing.ver) : 0n;
92
+
93
+ if (opts.ifVersion !== undefined && opts.ifVersion !== existingVersion) {
94
+ await this.client.unwatch();
95
+ throw new VersionMismatchError(key, opts.ifVersion, existingVersion);
96
+ }
97
+
98
+ const newVersion = existingVersion + 1n;
99
+ const payload: StoredKV = { v: value, ver: newVersion.toString() };
100
+ const encoded = JSON.stringify(payload);
101
+
102
+ const multi = this.client.multi();
103
+ if (ttlMs) {
104
+ multi.set(physical, encoded, 'PX', ttlMs);
105
+ } else {
106
+ multi.set(physical, encoded);
107
+ }
108
+ const result = await multi.exec();
109
+ // exec() returns null when WATCH detected a concurrent change.
110
+ if (result === null) continue;
111
+
112
+ return {
113
+ key,
114
+ value,
115
+ version: newVersion,
116
+ expiresAt: ttlMs ? Date.now() + ttlMs : undefined,
117
+ };
118
+ }
119
+ }
120
+
121
+ async delete(key: string, opts: { ifVersion?: bigint } = {}): Promise<boolean> {
122
+ if (this.closed) throw new Error('RedisKV is closed');
123
+ const physical = this.kvKey(key);
124
+
125
+ if (opts.ifVersion === undefined) {
126
+ const removed = await this.client.del(physical);
127
+ return removed > 0;
128
+ }
129
+
130
+ // eslint-disable-next-line no-constant-condition
131
+ while (true) {
132
+ await this.client.watch(physical);
133
+ const raw = await this.client.get(physical);
134
+ if (!raw) {
135
+ await this.client.unwatch();
136
+ return false;
137
+ }
138
+ const parsed = this.parse(raw);
139
+ if (!parsed) {
140
+ await this.client.unwatch();
141
+ return false;
142
+ }
143
+ const currentVersion = BigInt(parsed.ver);
144
+ if (opts.ifVersion !== currentVersion) {
145
+ await this.client.unwatch();
146
+ throw new VersionMismatchError(key, opts.ifVersion, currentVersion);
147
+ }
148
+ const multi = this.client.multi();
149
+ multi.del(physical);
150
+ const result = await multi.exec();
151
+ if (result === null) continue;
152
+ return (result[0]?.[1] as number) > 0;
153
+ }
154
+ }
155
+
156
+ async cas<T = unknown>(
157
+ key: string,
158
+ expectedVersion: bigint,
159
+ next: T,
160
+ opts: Omit<KVSetOptions, 'ifVersion'> = {},
161
+ ): Promise<KVEntry<T> | undefined> {
162
+ try {
163
+ return await this.set(key, next, { ...opts, ifVersion: expectedVersion });
164
+ } catch (err) {
165
+ if (err instanceof VersionMismatchError) return undefined;
166
+ throw err;
167
+ }
168
+ }
169
+
170
+ async close(): Promise<void> {
171
+ this.closed = true;
172
+ }
173
+
174
+ private kvKey(key: string): string {
175
+ return `${this.keyPrefix}kv:${key}`;
176
+ }
177
+
178
+ private parse(raw: string): StoredKV | undefined {
179
+ try {
180
+ const obj = JSON.parse(raw) as StoredKV;
181
+ if (typeof obj.ver !== 'string') return undefined;
182
+ return obj;
183
+ } catch {
184
+ return undefined;
185
+ }
186
+ }
187
+ }
package/src/lock.ts ADDED
@@ -0,0 +1,205 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { Redis } from 'ioredis';
4
+ import type {
5
+ ILock,
6
+ LockAcquireOptions,
7
+ LockHandle,
8
+ } from '@objectstack/spec/contracts';
9
+
10
+ const DEFAULT_TTL_MS = 15_000;
11
+ const POLL_INTERVAL_MS = 50;
12
+
13
+ /**
14
+ * Lua script for safe release: only DEL when the value matches our
15
+ * fencing token. Prevents a delayed release from kicking out the
16
+ * legitimate next holder.
17
+ */
18
+ const RELEASE_SCRIPT = `
19
+ if redis.call("GET", KEYS[1]) == ARGV[1] then
20
+ return redis.call("DEL", KEYS[1])
21
+ else
22
+ return 0
23
+ end
24
+ `;
25
+
26
+ /**
27
+ * Lua script for renew: PEXPIRE only when we still hold the lock.
28
+ * Returns 1 on success, 0 if the lock was lost.
29
+ */
30
+ const RENEW_SCRIPT = `
31
+ if redis.call("GET", KEYS[1]) == ARGV[1] then
32
+ return redis.call("PEXPIRE", KEYS[1], ARGV[2])
33
+ else
34
+ return 0
35
+ end
36
+ `;
37
+
38
+ export interface RedisLockOptions {
39
+ client: Redis;
40
+ /** Counter client for fencing-token allocation. Same Redis is fine. */
41
+ counterClient?: Redis;
42
+ keyPrefix?: string;
43
+ defaultTtlMs?: number;
44
+ /** Stable node id baked into lock values for debugging. */
45
+ nodeId?: string;
46
+ }
47
+
48
+ /**
49
+ * Redis-backed distributed lock with TTL fencing.
50
+ *
51
+ * Algorithm (single-instance Redis, NOT Redlock — adequate for
52
+ * typical ObjectStack deployments with one Redis primary):
53
+ *
54
+ * acquire = SET key {nodeId}:{token} NX PX ttl
55
+ * release = Lua: GET == expected ? DEL : 0
56
+ * renew = Lua: GET == expected ? PEXPIRE : 0
57
+ *
58
+ * Fencing tokens come from a Redis counter (INCR on `{prefix}fence:{key}`)
59
+ * so that two clients in a split-brain see strictly increasing tokens
60
+ * downstream.
61
+ *
62
+ * For multi-master Redis (Sentinel failover, etc.) consider switching to
63
+ * a Redlock variant — not implemented yet.
64
+ */
65
+ export class RedisLock implements ILock {
66
+ private readonly client: Redis;
67
+ private readonly counterClient: Redis;
68
+ private readonly keyPrefix: string;
69
+ private readonly defaultTtlMs: number;
70
+ private readonly nodeId: string;
71
+ private closed = false;
72
+
73
+ constructor(opts: RedisLockOptions) {
74
+ this.client = opts.client;
75
+ this.counterClient = opts.counterClient ?? opts.client;
76
+ this.keyPrefix = opts.keyPrefix ?? 'os:';
77
+ this.defaultTtlMs = opts.defaultTtlMs ?? DEFAULT_TTL_MS;
78
+ this.nodeId = opts.nodeId ?? 'node';
79
+ }
80
+
81
+ async acquire(key: string, opts: LockAcquireOptions = {}): Promise<LockHandle | null> {
82
+ if (this.closed) throw new Error('RedisLock is closed');
83
+ const ttlMs = opts.ttlMs ?? this.defaultTtlMs;
84
+ const waitMs = opts.waitMs ?? 0;
85
+ const deadline = Date.now() + waitMs;
86
+ const lockKey = this.lockKey(key);
87
+
88
+ // Allocate a fresh fencing token up-front.
89
+ const fencingToken = BigInt(
90
+ await this.counterClient.incr(this.fenceKey(key)),
91
+ );
92
+ const value = `${this.nodeId}:${fencingToken}`;
93
+
94
+ // First attempt — fast path.
95
+ if (await this.trySet(lockKey, value, ttlMs)) {
96
+ return this.makeHandle(key, lockKey, value, fencingToken, ttlMs);
97
+ }
98
+ if (waitMs <= 0) return null;
99
+
100
+ // Polling loop: simple, correct, good enough for typical
101
+ // contention. Future: pub/sub-driven wakeup on release.
102
+ while (Date.now() < deadline) {
103
+ await sleep(Math.min(POLL_INTERVAL_MS, Math.max(1, deadline - Date.now())));
104
+ if (await this.trySet(lockKey, value, ttlMs)) {
105
+ return this.makeHandle(key, lockKey, value, fencingToken, ttlMs);
106
+ }
107
+ }
108
+ return null;
109
+ }
110
+
111
+ async withLock<T>(
112
+ key: string,
113
+ fn: (h: LockHandle) => Promise<T>,
114
+ opts?: LockAcquireOptions,
115
+ ): Promise<T | null> {
116
+ const handle = await this.acquire(key, opts);
117
+ if (!handle) return null;
118
+ try {
119
+ return await fn(handle);
120
+ } finally {
121
+ await handle.release();
122
+ }
123
+ }
124
+
125
+ async close(): Promise<void> {
126
+ this.closed = true;
127
+ }
128
+
129
+ private async trySet(lockKey: string, value: string, ttlMs: number): Promise<boolean> {
130
+ // SET key value NX PX ttlMs — atomic acquire.
131
+ const res = await this.client.set(lockKey, value, 'PX', ttlMs, 'NX');
132
+ return res === 'OK';
133
+ }
134
+
135
+ private makeHandle(
136
+ logicalKey: string,
137
+ lockKey: string,
138
+ value: string,
139
+ fencingToken: bigint,
140
+ ttlMs: number,
141
+ ): LockHandle {
142
+ const self = this;
143
+ let released = false;
144
+ let currentTtl = ttlMs;
145
+ // Local expiry timer — mirrors memory driver so `isHeld()` flips
146
+ // on TTL without a Redis roundtrip. Not authoritative (clocks may
147
+ // drift), but matches contract test expectations.
148
+ let timer: NodeJS.Timeout | undefined = setTimeout(() => {
149
+ released = true;
150
+ }, ttlMs);
151
+
152
+ return {
153
+ key: logicalKey,
154
+ fencingToken,
155
+ isHeld(): boolean {
156
+ return !released;
157
+ },
158
+ async renew(extendMs?: number): Promise<void> {
159
+ if (released) {
160
+ throw new Error(`Lock "${logicalKey}" already released`);
161
+ }
162
+ const next = extendMs ?? currentTtl;
163
+ const result = await self.client.eval(
164
+ RENEW_SCRIPT,
165
+ 1,
166
+ lockKey,
167
+ value,
168
+ String(next),
169
+ );
170
+ if (result !== 1) {
171
+ released = true;
172
+ if (timer) { clearTimeout(timer); timer = undefined; }
173
+ throw new Error(
174
+ `Lock "${logicalKey}" no longer held (fence=${fencingToken})`,
175
+ );
176
+ }
177
+ currentTtl = next;
178
+ if (timer) clearTimeout(timer);
179
+ timer = setTimeout(() => { released = true; }, next);
180
+ },
181
+ async release(): Promise<void> {
182
+ if (released) return;
183
+ released = true;
184
+ if (timer) { clearTimeout(timer); timer = undefined; }
185
+ try {
186
+ await self.client.eval(RELEASE_SCRIPT, 1, lockKey, value);
187
+ } catch {
188
+ /* swallow — release is best-effort */
189
+ }
190
+ },
191
+ };
192
+ }
193
+
194
+ private lockKey(key: string): string {
195
+ return `${this.keyPrefix}lock:${key}`;
196
+ }
197
+
198
+ private fenceKey(key: string): string {
199
+ return `${this.keyPrefix}fence:${key}`;
200
+ }
201
+ }
202
+
203
+ function sleep(ms: number): Promise<void> {
204
+ return new Promise((r) => setTimeout(r, ms));
205
+ }