-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathcluster-service.ts
More file actions
262 lines (235 loc) · 8.86 KB
/
Copy pathcluster-service.ts
File metadata and controls
262 lines (235 loc) · 8.86 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
/**
* Cluster Service Contracts
*
* Runtime interfaces for the four cluster primitives defined in
* `content/docs/kernel/cluster.mdx` §3:
*
* - IPubSub — fan-out messaging across nodes
* - ILock — distributed mutual exclusion with TTL fencing
* - IKV — small ephemeral coordination state
* - ICounter — monotonic sequence generation
*
* Concrete drivers (memory / postgres / redis / nats / custom) live in
* `@objectstack/service-cluster` and implement these interfaces.
*
* Per ObjectStack convention:
* - Configuration & metadata go through Zod (see `kernel/cluster.zod.ts`).
* - Runtime contracts are plain TypeScript interfaces (this file).
*
* These interfaces are the abstraction boundary that lets plugins
* remain driver-agnostic — a plugin author writes against `IPubSub`,
* never against `redis.publish` or `pg.query('NOTIFY ...')`.
*/
// ---------------------------------------------------------------------------
// Shared
// ---------------------------------------------------------------------------
/** Disposer returned from subscription / watch APIs. Calling it is idempotent. */
export type Unsubscribe = () => void;
/** Common metadata attached to cluster primitives for observability. */
export interface ClusterCallContext {
/** Tenant or org channel-prefix; injected by the kernel, may be undefined for system primitives. */
tenant?: string;
/** Free-form trace id for correlation with logs. */
traceId?: string;
}
// ---------------------------------------------------------------------------
// IPubSub
// ---------------------------------------------------------------------------
export interface PubSubMessage<T = unknown> {
/** Logical channel name (already tenant-prefixed by the driver). */
channel: string;
/** Decoded payload. */
payload: T;
/** Wall-clock publish time, ms since epoch. Best-effort. */
publishedAt: number;
/** Origin node id, or undefined for local publishers. */
fromNode?: string;
}
export type PubSubHandler<T = unknown> = (
msg: PubSubMessage<T>,
) => void | Promise<void>;
export interface PublishOptions {
/** Partition key — used by partitioned drivers to preserve ordering. */
partitionKey?: string;
/** Optional context for logging / metrics. */
ctx?: ClusterCallContext;
}
export interface SubscribeOptions {
/** Optional context for logging / metrics. */
ctx?: ClusterCallContext;
}
/**
* Fan-out messaging primitive. At-least-once delivery; handlers MUST be
* idempotent. The memory driver delivers synchronously within a process;
* remote drivers (redis pub/sub, postgres LISTEN/NOTIFY, nats) deliver
* across nodes.
*/
export interface IPubSub {
publish<T = unknown>(
channel: string,
payload: T,
opts?: PublishOptions,
): Promise<void>;
subscribe<T = unknown>(
channel: string,
handler: PubSubHandler<T>,
opts?: SubscribeOptions,
): Unsubscribe;
/** Release any underlying resources (sockets, listeners). Idempotent. */
close(): Promise<void>;
}
// ---------------------------------------------------------------------------
// ILock
// ---------------------------------------------------------------------------
export interface LockAcquireOptions {
/** Lease duration in ms. Driver default applies if omitted. */
ttlMs?: number;
/**
* Wait up to this many ms for the lock to become available.
* `0` (default) = fail-fast, never queue.
*/
waitMs?: number;
/** Optional context for logging / metrics. */
ctx?: ClusterCallContext;
}
/**
* Opaque handle held by the lock owner. Carrying a fencing token lets
* downstream resources reject zombie writes from an expired holder.
*/
export interface LockHandle {
/** Logical lock key. */
readonly key: string;
/**
* Monotonically-increasing fencing token, unique per (key, acquisition).
* Use this when calling into resources that may have outlived the
* lock-holder process — pass it along so the resource can reject
* out-of-order writes.
*/
readonly fencingToken: bigint;
/** Extend the lease by another `ttlMs`. Throws if the lock was lost. */
renew(ttlMs?: number): Promise<void>;
/** Release the lock. Safe to call multiple times. */
release(): Promise<void>;
/** Returns true while this handle still owns the lock. */
isHeld(): boolean;
}
/**
* Distributed mutual exclusion. The memory driver is a per-process Mutex;
* remote drivers use Redis SETNX+TTL, Postgres advisory locks, or
* equivalents.
*
* **Always** acquire with a TTL — the lock MUST self-release if the holder
* crashes. Use `renew()` for long-running operations.
*/
export interface ILock {
/**
* Acquire `key`. Returns a `LockHandle` on success, or `null` if the
* lock is held and `waitMs` elapsed without it becoming free.
*/
acquire(key: string, opts?: LockAcquireOptions): Promise<LockHandle | null>;
/**
* Run `fn` while holding `key`. Releases the lock automatically; on
* timeout returns `null` and never invokes `fn`.
*/
withLock<T>(
key: string,
fn: (handle: LockHandle) => Promise<T>,
opts?: LockAcquireOptions,
): Promise<T | null>;
/** Release driver resources. */
close(): Promise<void>;
}
// ---------------------------------------------------------------------------
// IKV
// ---------------------------------------------------------------------------
export interface KVSetOptions {
/** Time-to-live in seconds. Omit for "no expiry". */
ttl?: number;
/**
* If set, the write succeeds only when the existing value's version
* matches `ifVersion`. Use `0n` to require the key not exist.
*/
ifVersion?: bigint;
ctx?: ClusterCallContext;
}
export interface KVEntry<T = unknown> {
key: string;
value: T;
/** Monotonic per-key version, incremented on every successful write. */
version: bigint;
/** Expiry in ms since epoch, or undefined for no TTL. */
expiresAt?: number;
}
/**
* Small ephemeral coordination KV. **NOT** a cache and **NOT** a database;
* intended for cluster bookkeeping like leader handles, heartbeat state,
* feature-flag flips. Values should be small (KB range).
*
* Supports optimistic concurrency via `ifVersion`.
*/
export interface IKV {
get<T = unknown>(key: string): Promise<KVEntry<T> | undefined>;
set<T = unknown>(key: string, value: T, opts?: KVSetOptions): Promise<KVEntry<T>>;
delete(key: string, opts?: { ifVersion?: bigint }): Promise<boolean>;
/**
* Compare-and-set convenience. Returns the new entry on success, or
* undefined if the precondition failed.
*/
cas<T = unknown>(
key: string,
expectedVersion: bigint,
next: T,
opts?: Omit<KVSetOptions, 'ifVersion'>,
): Promise<KVEntry<T> | undefined>;
close(): Promise<void>;
}
// ---------------------------------------------------------------------------
// ICounter
// ---------------------------------------------------------------------------
export interface CounterIncrOptions {
/** Delta to add. Default 1. May be negative. */
by?: number;
ctx?: ClusterCallContext;
}
/**
* Monotonic counter. The memory driver is a process-local integer; remote
* drivers use Redis INCRBY, Postgres sequences, etc.
*
* Use cases: id allocation, idempotency keys, fencing tokens (which is how
* `ILock` typically gets its `fencingToken`).
*/
export interface ICounter {
/** Increment and return the new value. */
incr(key: string, opts?: CounterIncrOptions): Promise<bigint>;
/** Read without mutating; returns 0n if the key has never been incremented. */
peek(key: string): Promise<bigint>;
/** Reset to a given value. Use sparingly — breaks monotonicity guarantees. */
reset(key: string, value?: bigint): Promise<void>;
close(): Promise<void>;
}
// ---------------------------------------------------------------------------
// IClusterService (facade)
// ---------------------------------------------------------------------------
/**
* The unified cluster facade exposed by the kernel to plugins.
*
* Plugins access primitives via `ctx.cluster.pubsub` etc., never by
* constructing a driver directly. The kernel resolves the registry from
* `ClusterCapabilityConfig` on the active stack.
*/
export interface IClusterService {
/** Stable identifier of this node within the cluster. */
readonly nodeId: string;
/** Driver name in use ('memory' | 'redis' | 'postgres' | 'nats' | 'custom'). */
readonly driver: string;
readonly pubsub: IPubSub;
readonly lock: ILock;
readonly kv: IKV;
readonly counter: ICounter;
/**
* Tear down every primitive in reverse order of construction.
* Safe to call multiple times.
*/
close(): Promise<void>;
}