@emmvish/stable-infra 1.0.2 → 2.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.
- package/README.md +200 -1
- package/dist/constants/index.d.ts +1 -0
- package/dist/constants/index.d.ts.map +1 -1
- package/dist/constants/index.js +1 -0
- package/dist/constants/index.js.map +1 -1
- package/dist/enums/index.d.ts +102 -0
- package/dist/enums/index.d.ts.map +1 -1
- package/dist/enums/index.js +116 -0
- package/dist/enums/index.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/types/index.d.ts +411 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utilities/distributed-adapters.d.ts +97 -0
- package/dist/utilities/distributed-adapters.d.ts.map +1 -0
- package/dist/utilities/distributed-adapters.js +896 -0
- package/dist/utilities/distributed-adapters.js.map +1 -0
- package/dist/utilities/distributed-buffer.d.ts +16 -0
- package/dist/utilities/distributed-buffer.d.ts.map +1 -0
- package/dist/utilities/distributed-buffer.js +142 -0
- package/dist/utilities/distributed-buffer.js.map +1 -0
- package/dist/utilities/distributed-coordinator.d.ts +246 -0
- package/dist/utilities/distributed-coordinator.d.ts.map +1 -0
- package/dist/utilities/distributed-coordinator.js +680 -0
- package/dist/utilities/distributed-coordinator.js.map +1 -0
- package/dist/utilities/distributed-infrastructure.d.ts +57 -0
- package/dist/utilities/distributed-infrastructure.d.ts.map +1 -0
- package/dist/utilities/distributed-infrastructure.js +117 -0
- package/dist/utilities/distributed-infrastructure.js.map +1 -0
- package/dist/utilities/distributed-scheduler.d.ts +55 -0
- package/dist/utilities/distributed-scheduler.d.ts.map +1 -0
- package/dist/utilities/distributed-scheduler.js +151 -0
- package/dist/utilities/distributed-scheduler.js.map +1 -0
- package/dist/utilities/index.d.ts +8 -0
- package/dist/utilities/index.d.ts.map +1 -1
- package/dist/utilities/index.js +5 -0
- package/dist/utilities/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,896 @@
|
|
|
1
|
+
import { DistributedLockStatus, DistributedLeaderStatus, DistributedTransactionStatus, DistributedTransactionOperationType, DistributedMessageDelivery, DistributedLockRenewalMode } from '../enums/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Generate a unique ID for this process/node
|
|
4
|
+
*/
|
|
5
|
+
const generateNodeId = () => {
|
|
6
|
+
const timestamp = Date.now().toString(36);
|
|
7
|
+
const random = Math.random().toString(36).substring(2, 10);
|
|
8
|
+
return `node-${timestamp}-${random}`;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Generate a unique message ID
|
|
12
|
+
*/
|
|
13
|
+
const generateMessageId = () => {
|
|
14
|
+
return `msg-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Generate a unique transaction ID
|
|
18
|
+
*/
|
|
19
|
+
const generateTransactionId = () => {
|
|
20
|
+
return `txn-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* In-Memory Distributed Adapter with Full Feature Support
|
|
24
|
+
*
|
|
25
|
+
* This adapter provides an in-memory implementation of the DistributedAdapter interface
|
|
26
|
+
* with support for:
|
|
27
|
+
* - Fencing tokens for locks
|
|
28
|
+
* - Auto lock renewal
|
|
29
|
+
* - Quorum-based leader election
|
|
30
|
+
* - Distributed transactions
|
|
31
|
+
* - Compare-and-swap operations
|
|
32
|
+
* - Guaranteed message delivery (at-least-once, exactly-once)
|
|
33
|
+
*
|
|
34
|
+
* NOTE: This adapter does NOT provide true distributed coordination across multiple
|
|
35
|
+
* processes or machines. For production distributed deployments, use a proper
|
|
36
|
+
* distributed backend adapter (Redis, PostgreSQL, etc.)
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```typescript
|
|
40
|
+
* const adapter = new InMemoryDistributedAdapter();
|
|
41
|
+
* const coordinator = new DistributedCoordinator({
|
|
42
|
+
* adapter,
|
|
43
|
+
* namespace: 'my-app'
|
|
44
|
+
* });
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export class InMemoryDistributedAdapter {
|
|
48
|
+
nodeId;
|
|
49
|
+
connected = false;
|
|
50
|
+
state = new Map();
|
|
51
|
+
locks = new Map();
|
|
52
|
+
fencingTokens = new Map();
|
|
53
|
+
counters = new Map();
|
|
54
|
+
leaders = new Map();
|
|
55
|
+
subscriptions = new Map();
|
|
56
|
+
pendingMessages = new Map();
|
|
57
|
+
processedMessageIds = new Map();
|
|
58
|
+
transactions = new Map();
|
|
59
|
+
renewalTimers = new Map();
|
|
60
|
+
cleanupTimer = null;
|
|
61
|
+
messageRetryTimer = null;
|
|
62
|
+
// Mutex for CAS operations to ensure atomicity
|
|
63
|
+
casMutex = new Map();
|
|
64
|
+
constructor(nodeId) {
|
|
65
|
+
this.nodeId = nodeId ?? generateNodeId();
|
|
66
|
+
}
|
|
67
|
+
async connect() {
|
|
68
|
+
this.connected = true;
|
|
69
|
+
// Start cleanup timer for expired entries
|
|
70
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), 1000);
|
|
71
|
+
// Start message retry timer for guaranteed delivery
|
|
72
|
+
this.messageRetryTimer = setInterval(() => this.retryPendingMessages(), 500);
|
|
73
|
+
}
|
|
74
|
+
async disconnect() {
|
|
75
|
+
this.connected = false;
|
|
76
|
+
if (this.cleanupTimer) {
|
|
77
|
+
clearInterval(this.cleanupTimer);
|
|
78
|
+
this.cleanupTimer = null;
|
|
79
|
+
}
|
|
80
|
+
if (this.messageRetryTimer) {
|
|
81
|
+
clearInterval(this.messageRetryTimer);
|
|
82
|
+
this.messageRetryTimer = null;
|
|
83
|
+
}
|
|
84
|
+
// Clear all renewal timers
|
|
85
|
+
for (const timer of this.renewalTimers.values()) {
|
|
86
|
+
clearInterval(timer);
|
|
87
|
+
}
|
|
88
|
+
this.renewalTimers.clear();
|
|
89
|
+
}
|
|
90
|
+
async isHealthy() {
|
|
91
|
+
return this.connected;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Helper to acquire a mutex for atomic operations on a key
|
|
95
|
+
*/
|
|
96
|
+
async withMutex(key, operation) {
|
|
97
|
+
// Wait for any existing operation on this key to complete
|
|
98
|
+
const existing = this.casMutex.get(key);
|
|
99
|
+
if (existing) {
|
|
100
|
+
await existing;
|
|
101
|
+
}
|
|
102
|
+
// Create a new promise for our operation
|
|
103
|
+
let resolve;
|
|
104
|
+
const mutex = new Promise(r => { resolve = r; });
|
|
105
|
+
this.casMutex.set(key, mutex);
|
|
106
|
+
try {
|
|
107
|
+
return await operation();
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
resolve();
|
|
111
|
+
this.casMutex.delete(key);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// ============================================================================
|
|
115
|
+
// Distributed Locking with Fencing Tokens
|
|
116
|
+
// ============================================================================
|
|
117
|
+
async acquireLock(options) {
|
|
118
|
+
const { resource, ttlMs = 30000, waitTimeoutMs = 0, retryIntervalMs = 100, renewalMode = DistributedLockRenewalMode.MANUAL, renewalIntervalMs, onRenewalFailure } = options;
|
|
119
|
+
const startTime = Date.now();
|
|
120
|
+
while (true) {
|
|
121
|
+
const existingLock = this.locks.get(resource);
|
|
122
|
+
const now = Date.now();
|
|
123
|
+
// Check if existing lock is expired
|
|
124
|
+
if (existingLock && existingLock.expiresAt > now) {
|
|
125
|
+
// Lock is held by someone else
|
|
126
|
+
if (waitTimeoutMs === 0 || (now - startTime) >= waitTimeoutMs) {
|
|
127
|
+
return { status: DistributedLockStatus.FAILED, error: 'Lock is held by another owner' };
|
|
128
|
+
}
|
|
129
|
+
// Wait and retry
|
|
130
|
+
await new Promise(resolve => setTimeout(resolve, retryIntervalMs));
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
// Increment fencing token
|
|
134
|
+
const currentFencingToken = this.fencingTokens.get(resource) ?? 0;
|
|
135
|
+
const newFencingToken = currentFencingToken + 1;
|
|
136
|
+
this.fencingTokens.set(resource, newFencingToken);
|
|
137
|
+
// Acquire the lock
|
|
138
|
+
const handle = {
|
|
139
|
+
lockId: `lock-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`,
|
|
140
|
+
resource,
|
|
141
|
+
acquiredAt: now,
|
|
142
|
+
expiresAt: now + ttlMs,
|
|
143
|
+
ownerId: this.nodeId,
|
|
144
|
+
fencingToken: newFencingToken
|
|
145
|
+
};
|
|
146
|
+
this.locks.set(resource, handle);
|
|
147
|
+
// Setup auto-renewal if enabled
|
|
148
|
+
if (renewalMode === DistributedLockRenewalMode.AUTO) {
|
|
149
|
+
const interval = renewalIntervalMs ?? Math.floor(ttlMs / 3);
|
|
150
|
+
const timer = setInterval(async () => {
|
|
151
|
+
const result = await this.extendLock(handle, ttlMs);
|
|
152
|
+
if (result.status !== DistributedLockStatus.ACQUIRED) {
|
|
153
|
+
clearInterval(timer);
|
|
154
|
+
this.renewalTimers.delete(handle.lockId);
|
|
155
|
+
if (onRenewalFailure) {
|
|
156
|
+
onRenewalFailure(handle, new Error('Failed to renew lock'));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}, interval);
|
|
160
|
+
handle.renewalTimerId = timer;
|
|
161
|
+
this.renewalTimers.set(handle.lockId, timer);
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
status: DistributedLockStatus.ACQUIRED,
|
|
165
|
+
handle,
|
|
166
|
+
fencingToken: newFencingToken
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async releaseLock(handle) {
|
|
171
|
+
const existingLock = this.locks.get(handle.resource);
|
|
172
|
+
if (!existingLock) {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
// Only release if we own the lock
|
|
176
|
+
if (existingLock.ownerId !== handle.ownerId || existingLock.lockId !== handle.lockId) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
// Stop renewal timer if active
|
|
180
|
+
if (handle.renewalTimerId) {
|
|
181
|
+
clearInterval(handle.renewalTimerId);
|
|
182
|
+
this.renewalTimers.delete(handle.lockId);
|
|
183
|
+
}
|
|
184
|
+
this.locks.delete(handle.resource);
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
async extendLock(handle, additionalMs) {
|
|
188
|
+
const existingLock = this.locks.get(handle.resource);
|
|
189
|
+
if (!existingLock || existingLock.ownerId !== handle.ownerId || existingLock.lockId !== handle.lockId) {
|
|
190
|
+
return { status: DistributedLockStatus.FAILED, error: 'Lock not found or not owned' };
|
|
191
|
+
}
|
|
192
|
+
// Check if lock has been fenced (a newer lock was acquired)
|
|
193
|
+
if (existingLock.fencingToken !== handle.fencingToken) {
|
|
194
|
+
return { status: DistributedLockStatus.FENCED, error: 'Lock has been fenced' };
|
|
195
|
+
}
|
|
196
|
+
const newHandle = {
|
|
197
|
+
...existingLock,
|
|
198
|
+
expiresAt: existingLock.expiresAt + additionalMs
|
|
199
|
+
};
|
|
200
|
+
this.locks.set(handle.resource, newHandle);
|
|
201
|
+
return { status: DistributedLockStatus.ACQUIRED, handle: newHandle };
|
|
202
|
+
}
|
|
203
|
+
async validateFencingToken(resource, token) {
|
|
204
|
+
const currentToken = this.fencingTokens.get(resource) ?? 0;
|
|
205
|
+
return token >= currentToken;
|
|
206
|
+
}
|
|
207
|
+
async getCurrentFencingToken(resource) {
|
|
208
|
+
return this.fencingTokens.get(resource) ?? 0;
|
|
209
|
+
}
|
|
210
|
+
// ============================================================================
|
|
211
|
+
// Distributed State with Versioning and Consistency
|
|
212
|
+
// ============================================================================
|
|
213
|
+
async getState(key, options) {
|
|
214
|
+
// In-memory adapter always provides linearizable reads
|
|
215
|
+
// Real implementations would handle different consistency levels
|
|
216
|
+
const entry = this.state.get(key);
|
|
217
|
+
if (!entry) {
|
|
218
|
+
return { success: true, value: undefined, version: 0 };
|
|
219
|
+
}
|
|
220
|
+
// Check expiration
|
|
221
|
+
if (entry.expiresAt && entry.expiresAt < Date.now()) {
|
|
222
|
+
this.state.delete(key);
|
|
223
|
+
return { success: true, value: undefined, version: 0 };
|
|
224
|
+
}
|
|
225
|
+
return { success: true, value: entry.value, version: entry.version };
|
|
226
|
+
}
|
|
227
|
+
async setState(key, value, options) {
|
|
228
|
+
const existing = this.state.get(key);
|
|
229
|
+
// Optimistic locking check
|
|
230
|
+
if (options?.version !== undefined && existing && existing.version !== options.version) {
|
|
231
|
+
return { success: false, error: 'Version mismatch', conflicted: true, version: existing.version };
|
|
232
|
+
}
|
|
233
|
+
// Fencing token validation
|
|
234
|
+
if (options?.fencingToken !== undefined) {
|
|
235
|
+
const isValid = await this.validateFencingToken(key, options.fencingToken);
|
|
236
|
+
if (!isValid) {
|
|
237
|
+
return { success: false, error: 'Invalid fencing token', conflicted: true };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const newVersion = (existing?.version ?? 0) + 1;
|
|
241
|
+
this.state.set(key, {
|
|
242
|
+
value,
|
|
243
|
+
version: newVersion,
|
|
244
|
+
expiresAt: options?.ttlMs ? Date.now() + options.ttlMs : undefined
|
|
245
|
+
});
|
|
246
|
+
return { success: true, value, version: newVersion };
|
|
247
|
+
}
|
|
248
|
+
async updateState(key, updater, options) {
|
|
249
|
+
const existing = this.state.get(key);
|
|
250
|
+
const currentValue = existing?.value;
|
|
251
|
+
const newValue = updater(currentValue);
|
|
252
|
+
return this.setState(key, newValue, {
|
|
253
|
+
...options,
|
|
254
|
+
version: existing?.version
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
async deleteState(key) {
|
|
258
|
+
return this.state.delete(key);
|
|
259
|
+
}
|
|
260
|
+
async compareAndSwap(options) {
|
|
261
|
+
const { key, expectedValue, expectedVersion, newValue, ttlMs } = options;
|
|
262
|
+
// Use mutex to ensure atomic check-and-swap
|
|
263
|
+
return this.withMutex(key, () => {
|
|
264
|
+
const existing = this.state.get(key);
|
|
265
|
+
// Check version condition
|
|
266
|
+
if (expectedVersion !== undefined) {
|
|
267
|
+
const currentVersion = existing?.version ?? 0;
|
|
268
|
+
if (currentVersion !== expectedVersion) {
|
|
269
|
+
return {
|
|
270
|
+
success: false,
|
|
271
|
+
swapped: false,
|
|
272
|
+
currentValue: existing?.value,
|
|
273
|
+
currentVersion
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// Check value condition
|
|
278
|
+
if (expectedValue !== undefined) {
|
|
279
|
+
if (JSON.stringify(existing?.value) !== JSON.stringify(expectedValue)) {
|
|
280
|
+
return {
|
|
281
|
+
success: false,
|
|
282
|
+
swapped: false,
|
|
283
|
+
currentValue: existing?.value,
|
|
284
|
+
currentVersion: existing?.version ?? 0
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// Perform the swap
|
|
289
|
+
const newVersion = (existing?.version ?? 0) + 1;
|
|
290
|
+
this.state.set(key, {
|
|
291
|
+
value: newValue,
|
|
292
|
+
version: newVersion,
|
|
293
|
+
expiresAt: ttlMs ? Date.now() + ttlMs : undefined
|
|
294
|
+
});
|
|
295
|
+
return {
|
|
296
|
+
success: true,
|
|
297
|
+
swapped: true,
|
|
298
|
+
currentValue: newValue,
|
|
299
|
+
currentVersion: newVersion,
|
|
300
|
+
version: newVersion
|
|
301
|
+
};
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
// ============================================================================
|
|
305
|
+
// Distributed Counters
|
|
306
|
+
// ============================================================================
|
|
307
|
+
async getCounter(key) {
|
|
308
|
+
return this.counters.get(key) ?? 0;
|
|
309
|
+
}
|
|
310
|
+
async incrementCounter(key, delta = 1) {
|
|
311
|
+
const current = this.counters.get(key) ?? 0;
|
|
312
|
+
const newValue = current + delta;
|
|
313
|
+
this.counters.set(key, newValue);
|
|
314
|
+
return newValue;
|
|
315
|
+
}
|
|
316
|
+
async decrementCounter(key, delta = 1) {
|
|
317
|
+
const current = this.counters.get(key) ?? 0;
|
|
318
|
+
const newValue = current - delta;
|
|
319
|
+
this.counters.set(key, newValue);
|
|
320
|
+
return newValue;
|
|
321
|
+
}
|
|
322
|
+
async resetCounter(key, value = 0) {
|
|
323
|
+
this.counters.set(key, value);
|
|
324
|
+
}
|
|
325
|
+
// ============================================================================
|
|
326
|
+
// Quorum-Based Leader Election
|
|
327
|
+
// ============================================================================
|
|
328
|
+
async campaignForLeader(options) {
|
|
329
|
+
const { electionKey, ttlMs = 30000, quorumSize = 0, onPartitionDetected, onPartitionResolved } = options;
|
|
330
|
+
const now = Date.now();
|
|
331
|
+
let existing = this.leaders.get(electionKey);
|
|
332
|
+
// Initialize election state if not exists
|
|
333
|
+
if (!existing) {
|
|
334
|
+
existing = {
|
|
335
|
+
leaderId: '',
|
|
336
|
+
term: 0,
|
|
337
|
+
expiresAt: 0,
|
|
338
|
+
nodes: new Set(),
|
|
339
|
+
quorumSize: quorumSize,
|
|
340
|
+
votes: new Map()
|
|
341
|
+
};
|
|
342
|
+
this.leaders.set(electionKey, existing);
|
|
343
|
+
}
|
|
344
|
+
// Register this node
|
|
345
|
+
existing.nodes.add(this.nodeId);
|
|
346
|
+
existing.quorumSize = quorumSize > 0 ? quorumSize : Math.floor(existing.nodes.size / 2) + 1;
|
|
347
|
+
// Check if current leader's lease has expired
|
|
348
|
+
const leaderExpired = existing.expiresAt < now;
|
|
349
|
+
const isCurrentLeader = existing.leaderId === this.nodeId;
|
|
350
|
+
if (!leaderExpired && !isCurrentLeader && existing.leaderId) {
|
|
351
|
+
// Another leader exists and is still valid
|
|
352
|
+
const quorumInfo = this.getQuorumInfo(existing);
|
|
353
|
+
// Check for partition
|
|
354
|
+
if (!quorumInfo.hasQuorum && onPartitionDetected) {
|
|
355
|
+
setTimeout(() => onPartitionDetected(), 0);
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
leaderId: existing.leaderId,
|
|
359
|
+
status: DistributedLeaderStatus.FOLLOWER,
|
|
360
|
+
term: existing.term,
|
|
361
|
+
lastHeartbeat: now,
|
|
362
|
+
nodeId: this.nodeId,
|
|
363
|
+
quorum: quorumInfo,
|
|
364
|
+
partitionDetected: !quorumInfo.hasQuorum
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
// Try to become the leader
|
|
368
|
+
const newTerm = existing.term + 1;
|
|
369
|
+
existing.votes.set(this.nodeId, newTerm);
|
|
370
|
+
// Check if we have enough votes (quorum)
|
|
371
|
+
const votesForTerm = Array.from(existing.votes.entries())
|
|
372
|
+
.filter(([_, term]) => term === newTerm).length;
|
|
373
|
+
const hasQuorum = existing.quorumSize === 0 || votesForTerm >= existing.quorumSize;
|
|
374
|
+
if (hasQuorum) {
|
|
375
|
+
// Become the leader
|
|
376
|
+
existing.leaderId = this.nodeId;
|
|
377
|
+
existing.term = newTerm;
|
|
378
|
+
existing.expiresAt = now + ttlMs;
|
|
379
|
+
// Trigger callback
|
|
380
|
+
if (options.onBecomeLeader) {
|
|
381
|
+
setTimeout(() => options.onBecomeLeader(), 0);
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
leaderId: this.nodeId,
|
|
385
|
+
status: DistributedLeaderStatus.LEADER,
|
|
386
|
+
term: newTerm,
|
|
387
|
+
lastHeartbeat: now,
|
|
388
|
+
nodeId: this.nodeId,
|
|
389
|
+
quorum: this.getQuorumInfo(existing),
|
|
390
|
+
partitionDetected: false
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
// Not enough votes, remain candidate
|
|
394
|
+
return {
|
|
395
|
+
leaderId: existing.leaderId || null,
|
|
396
|
+
status: DistributedLeaderStatus.CANDIDATE,
|
|
397
|
+
term: newTerm,
|
|
398
|
+
lastHeartbeat: now,
|
|
399
|
+
nodeId: this.nodeId,
|
|
400
|
+
quorum: this.getQuorumInfo(existing),
|
|
401
|
+
partitionDetected: !hasQuorum
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
getQuorumInfo(entry) {
|
|
405
|
+
const votesForCurrentTerm = Array.from(entry.votes.entries())
|
|
406
|
+
.filter(([_, term]) => term === entry.term)
|
|
407
|
+
.map(([nodeId]) => nodeId);
|
|
408
|
+
return {
|
|
409
|
+
totalNodes: entry.nodes.size,
|
|
410
|
+
votesReceived: votesForCurrentTerm.length,
|
|
411
|
+
required: entry.quorumSize,
|
|
412
|
+
quorumThreshold: entry.quorumSize,
|
|
413
|
+
hasQuorum: entry.quorumSize === 0 || votesForCurrentTerm.length >= entry.quorumSize,
|
|
414
|
+
acknowledgedNodes: votesForCurrentTerm
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
async resignLeadership(electionKey) {
|
|
418
|
+
const existing = this.leaders.get(electionKey);
|
|
419
|
+
if (existing && existing.leaderId === this.nodeId) {
|
|
420
|
+
existing.leaderId = '';
|
|
421
|
+
existing.expiresAt = 0;
|
|
422
|
+
existing.votes.clear();
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
async getLeaderStatus(electionKey) {
|
|
426
|
+
const existing = this.leaders.get(electionKey);
|
|
427
|
+
const now = Date.now();
|
|
428
|
+
if (!existing || existing.expiresAt < now) {
|
|
429
|
+
return {
|
|
430
|
+
leaderId: null,
|
|
431
|
+
status: DistributedLeaderStatus.CANDIDATE,
|
|
432
|
+
term: existing?.term ?? 0,
|
|
433
|
+
lastHeartbeat: 0,
|
|
434
|
+
nodeId: this.nodeId,
|
|
435
|
+
quorum: existing ? this.getQuorumInfo(existing) : undefined
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
return {
|
|
439
|
+
leaderId: existing.leaderId || null,
|
|
440
|
+
status: existing.leaderId === this.nodeId
|
|
441
|
+
? DistributedLeaderStatus.LEADER
|
|
442
|
+
: DistributedLeaderStatus.FOLLOWER,
|
|
443
|
+
term: existing.term,
|
|
444
|
+
lastHeartbeat: now,
|
|
445
|
+
nodeId: this.nodeId,
|
|
446
|
+
quorum: this.getQuorumInfo(existing)
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
async sendLeaderHeartbeat(electionKey) {
|
|
450
|
+
const existing = this.leaders.get(electionKey);
|
|
451
|
+
if (!existing || existing.leaderId !== this.nodeId) {
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
// Extend the lease
|
|
455
|
+
existing.expiresAt = Date.now() + 30000;
|
|
456
|
+
// Refresh vote
|
|
457
|
+
existing.votes.set(this.nodeId, existing.term);
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
async registerNode(electionKey) {
|
|
461
|
+
let existing = this.leaders.get(electionKey);
|
|
462
|
+
if (!existing) {
|
|
463
|
+
existing = {
|
|
464
|
+
leaderId: '',
|
|
465
|
+
term: 0,
|
|
466
|
+
expiresAt: 0,
|
|
467
|
+
nodes: new Set(),
|
|
468
|
+
quorumSize: 0,
|
|
469
|
+
votes: new Map()
|
|
470
|
+
};
|
|
471
|
+
this.leaders.set(electionKey, existing);
|
|
472
|
+
}
|
|
473
|
+
existing.nodes.add(this.nodeId);
|
|
474
|
+
}
|
|
475
|
+
async unregisterNode(electionKey) {
|
|
476
|
+
const existing = this.leaders.get(electionKey);
|
|
477
|
+
if (existing) {
|
|
478
|
+
existing.nodes.delete(this.nodeId);
|
|
479
|
+
existing.votes.delete(this.nodeId);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
async getKnownNodes(electionKey) {
|
|
483
|
+
const existing = this.leaders.get(electionKey);
|
|
484
|
+
return existing ? Array.from(existing.nodes) : [];
|
|
485
|
+
}
|
|
486
|
+
// ============================================================================
|
|
487
|
+
// Pub/Sub with Guaranteed Delivery
|
|
488
|
+
// ============================================================================
|
|
489
|
+
async publish(channel, payload, options) {
|
|
490
|
+
const channelSubs = this.subscriptions.get(channel);
|
|
491
|
+
if (!channelSubs || channelSubs.size === 0) {
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
const deliveryMode = options?.deliveryMode ?? DistributedMessageDelivery.AT_MOST_ONCE;
|
|
495
|
+
const messageId = generateMessageId();
|
|
496
|
+
const message = {
|
|
497
|
+
channel,
|
|
498
|
+
payload,
|
|
499
|
+
publisherId: this.nodeId,
|
|
500
|
+
timestamp: Date.now(),
|
|
501
|
+
messageId,
|
|
502
|
+
deliveryMode,
|
|
503
|
+
sequenceNumber: await this.incrementCounter(`pubsub:seq:${channel}`),
|
|
504
|
+
requiresAck: deliveryMode !== DistributedMessageDelivery.AT_MOST_ONCE
|
|
505
|
+
};
|
|
506
|
+
if (deliveryMode === DistributedMessageDelivery.AT_MOST_ONCE) {
|
|
507
|
+
// Fire and forget
|
|
508
|
+
for (const [_, handler] of channelSubs) {
|
|
509
|
+
Promise.resolve(handler(message)).catch(err => {
|
|
510
|
+
console.warn('stable-infra: Subscription handler error:', err);
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
else {
|
|
515
|
+
// Track message for redelivery
|
|
516
|
+
if (!this.pendingMessages.has(channel)) {
|
|
517
|
+
this.pendingMessages.set(channel, new Map());
|
|
518
|
+
}
|
|
519
|
+
const pending = {
|
|
520
|
+
message,
|
|
521
|
+
acks: new Set(),
|
|
522
|
+
subscribers: new Set(channelSubs.keys()),
|
|
523
|
+
retryCount: 0,
|
|
524
|
+
maxRetries: options?.maxRetries ?? 10
|
|
525
|
+
};
|
|
526
|
+
this.pendingMessages.get(channel).set(messageId, pending);
|
|
527
|
+
// Deliver to subscribers
|
|
528
|
+
for (const [subscriberId, handler] of channelSubs) {
|
|
529
|
+
this.deliverMessage(handler, message, pending, subscriberId);
|
|
530
|
+
}
|
|
531
|
+
// Wait for acks if requested
|
|
532
|
+
if (options?.waitForAck) {
|
|
533
|
+
const timeout = options.ackTimeoutMs ?? 5000;
|
|
534
|
+
const startTime = Date.now();
|
|
535
|
+
while (Date.now() - startTime < timeout) {
|
|
536
|
+
if (pending.acks.size === pending.subscribers.size) {
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
async deliverMessage(handler, message, pending, subscriberId) {
|
|
545
|
+
// For exactly-once, check if already processed
|
|
546
|
+
if (message.deliveryMode === DistributedMessageDelivery.EXACTLY_ONCE) {
|
|
547
|
+
const processed = this.processedMessageIds.get(subscriberId);
|
|
548
|
+
if (processed?.has(message.messageId)) {
|
|
549
|
+
pending.acks.add(subscriberId);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
try {
|
|
554
|
+
await handler(message);
|
|
555
|
+
// For at-least-once, don't auto-ack - subscriber must call acknowledge
|
|
556
|
+
if (message.deliveryMode === DistributedMessageDelivery.EXACTLY_ONCE) {
|
|
557
|
+
// Mark as processed to prevent redelivery
|
|
558
|
+
if (!this.processedMessageIds.has(subscriberId)) {
|
|
559
|
+
this.processedMessageIds.set(subscriberId, new Set());
|
|
560
|
+
}
|
|
561
|
+
this.processedMessageIds.get(subscriberId).add(message.messageId);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
catch (err) {
|
|
565
|
+
console.warn('stable-infra: Subscription handler error:', err);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
retryPendingMessages() {
|
|
569
|
+
for (const [channel, messages] of this.pendingMessages) {
|
|
570
|
+
const channelSubs = this.subscriptions.get(channel);
|
|
571
|
+
if (!channelSubs)
|
|
572
|
+
continue;
|
|
573
|
+
for (const [messageId, pending] of messages) {
|
|
574
|
+
// Find subscribers that haven't acked
|
|
575
|
+
const unacked = Array.from(pending.subscribers).filter(s => !pending.acks.has(s));
|
|
576
|
+
if (unacked.length === 0) {
|
|
577
|
+
// All acked, remove from pending
|
|
578
|
+
messages.delete(messageId);
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
if (pending.retryCount >= pending.maxRetries) {
|
|
582
|
+
// Max retries exceeded, remove from pending
|
|
583
|
+
messages.delete(messageId);
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
pending.retryCount++;
|
|
587
|
+
// Redeliver to unacked subscribers
|
|
588
|
+
for (const subscriberId of unacked) {
|
|
589
|
+
const handler = channelSubs.get(subscriberId);
|
|
590
|
+
if (handler) {
|
|
591
|
+
this.deliverMessage(handler, pending.message, pending, subscriberId);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
async subscribe(channel, handler, options) {
|
|
598
|
+
if (!this.subscriptions.has(channel)) {
|
|
599
|
+
this.subscriptions.set(channel, new Map());
|
|
600
|
+
}
|
|
601
|
+
const channelSubs = this.subscriptions.get(channel);
|
|
602
|
+
const subscriptionId = `sub-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
|
|
603
|
+
channelSubs.set(subscriptionId, handler);
|
|
604
|
+
return {
|
|
605
|
+
subscriptionId,
|
|
606
|
+
channel,
|
|
607
|
+
unsubscribe: async () => {
|
|
608
|
+
channelSubs.delete(subscriptionId);
|
|
609
|
+
if (channelSubs.size === 0) {
|
|
610
|
+
this.subscriptions.delete(channel);
|
|
611
|
+
}
|
|
612
|
+
// Remove any pending message tracking for this subscriber
|
|
613
|
+
const pending = this.pendingMessages.get(channel);
|
|
614
|
+
if (pending) {
|
|
615
|
+
for (const msg of pending.values()) {
|
|
616
|
+
msg.subscribers.delete(subscriptionId);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
},
|
|
620
|
+
acknowledge: async (messageId) => {
|
|
621
|
+
const pending = this.pendingMessages.get(channel);
|
|
622
|
+
if (pending) {
|
|
623
|
+
const msg = pending.get(messageId);
|
|
624
|
+
if (msg) {
|
|
625
|
+
msg.acks.add(subscriptionId);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
async acknowledgeMessage(channel, messageId) {
|
|
632
|
+
const pending = this.pendingMessages.get(channel);
|
|
633
|
+
if (pending) {
|
|
634
|
+
const msg = pending.get(messageId);
|
|
635
|
+
if (msg) {
|
|
636
|
+
// Mark all subscribers as acked (this is a simplified implementation)
|
|
637
|
+
msg.acks = new Set(msg.subscribers);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
async getUnacknowledgedMessages(channel, subscriberId) {
|
|
642
|
+
const pending = this.pendingMessages.get(channel);
|
|
643
|
+
if (!pending)
|
|
644
|
+
return [];
|
|
645
|
+
return Array.from(pending.values())
|
|
646
|
+
.filter(p => p.subscribers.has(subscriberId) && !p.acks.has(subscriberId))
|
|
647
|
+
.map(p => p.message);
|
|
648
|
+
}
|
|
649
|
+
// ============================================================================
|
|
650
|
+
// Distributed Transactions
|
|
651
|
+
// ============================================================================
|
|
652
|
+
async beginTransaction(options) {
|
|
653
|
+
const transactionId = generateTransactionId();
|
|
654
|
+
const transaction = {
|
|
655
|
+
transactionId,
|
|
656
|
+
status: DistributedTransactionStatus.PENDING,
|
|
657
|
+
operations: [],
|
|
658
|
+
createdAt: Date.now(),
|
|
659
|
+
timeoutMs: options?.timeoutMs ?? 30000,
|
|
660
|
+
initiatorNodeId: this.nodeId,
|
|
661
|
+
stagedOperations: new Map(),
|
|
662
|
+
lockedKeys: new Set()
|
|
663
|
+
};
|
|
664
|
+
this.transactions.set(transactionId, transaction);
|
|
665
|
+
return transaction;
|
|
666
|
+
}
|
|
667
|
+
async addTransactionOperation(transactionId, operation) {
|
|
668
|
+
const transaction = this.transactions.get(transactionId);
|
|
669
|
+
if (!transaction) {
|
|
670
|
+
throw new Error(`Transaction ${transactionId} not found`);
|
|
671
|
+
}
|
|
672
|
+
if (transaction.status !== DistributedTransactionStatus.PENDING) {
|
|
673
|
+
throw new Error(`Transaction ${transactionId} is not pending`);
|
|
674
|
+
}
|
|
675
|
+
transaction.operations.push(operation);
|
|
676
|
+
}
|
|
677
|
+
async prepareTransaction(transactionId) {
|
|
678
|
+
const transaction = this.transactions.get(transactionId);
|
|
679
|
+
if (!transaction) {
|
|
680
|
+
return { transactionId, status: DistributedTransactionStatus.FAILED, success: false, error: 'Transaction not found' };
|
|
681
|
+
}
|
|
682
|
+
if (transaction.status !== DistributedTransactionStatus.PENDING) {
|
|
683
|
+
return { transactionId, status: transaction.status, success: false, error: 'Transaction is not pending' };
|
|
684
|
+
}
|
|
685
|
+
// Check timeout
|
|
686
|
+
if (Date.now() - transaction.createdAt > transaction.timeoutMs) {
|
|
687
|
+
transaction.status = DistributedTransactionStatus.TIMEOUT;
|
|
688
|
+
return { transactionId, status: DistributedTransactionStatus.TIMEOUT, success: false, error: 'Transaction timed out' };
|
|
689
|
+
}
|
|
690
|
+
try {
|
|
691
|
+
// Acquire locks on all keys involved
|
|
692
|
+
for (const op of transaction.operations) {
|
|
693
|
+
const lockResult = await this.acquireLock({
|
|
694
|
+
resource: `txn:${op.key}`,
|
|
695
|
+
ttlMs: transaction.timeoutMs,
|
|
696
|
+
waitTimeoutMs: 1000
|
|
697
|
+
});
|
|
698
|
+
if (lockResult.status !== DistributedLockStatus.ACQUIRED) {
|
|
699
|
+
// Rollback any acquired locks
|
|
700
|
+
for (const key of transaction.lockedKeys) {
|
|
701
|
+
const handle = this.locks.get(`txn:${key}`);
|
|
702
|
+
if (handle) {
|
|
703
|
+
await this.releaseLock(handle);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
transaction.status = DistributedTransactionStatus.FAILED;
|
|
707
|
+
return { transactionId, status: DistributedTransactionStatus.FAILED, success: false, error: `Failed to lock key: ${op.key}` };
|
|
708
|
+
}
|
|
709
|
+
transaction.lockedKeys.add(op.key);
|
|
710
|
+
}
|
|
711
|
+
// Stage all operations (validate they can be performed)
|
|
712
|
+
for (const op of transaction.operations) {
|
|
713
|
+
const current = this.state.get(op.key);
|
|
714
|
+
const currentVersion = current?.version ?? 0;
|
|
715
|
+
if (op.type === DistributedTransactionOperationType.COMPARE_AND_SWAP) {
|
|
716
|
+
if (op.expectedVersion !== undefined && currentVersion !== op.expectedVersion) {
|
|
717
|
+
throw new Error(`Version mismatch for key ${op.key}`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
// Calculate the staged value
|
|
721
|
+
let stagedValue;
|
|
722
|
+
switch (op.type) {
|
|
723
|
+
case DistributedTransactionOperationType.SET:
|
|
724
|
+
stagedValue = op.value;
|
|
725
|
+
break;
|
|
726
|
+
case DistributedTransactionOperationType.DELETE:
|
|
727
|
+
stagedValue = undefined;
|
|
728
|
+
break;
|
|
729
|
+
case DistributedTransactionOperationType.INCREMENT:
|
|
730
|
+
stagedValue = (current?.value ?? 0) + (op.delta ?? 1);
|
|
731
|
+
break;
|
|
732
|
+
case DistributedTransactionOperationType.DECREMENT:
|
|
733
|
+
stagedValue = (current?.value ?? 0) - (op.delta ?? 1);
|
|
734
|
+
break;
|
|
735
|
+
case DistributedTransactionOperationType.COMPARE_AND_SWAP:
|
|
736
|
+
stagedValue = op.value;
|
|
737
|
+
break;
|
|
738
|
+
}
|
|
739
|
+
transaction.stagedOperations.set(op.key, { value: stagedValue, version: currentVersion + 1 });
|
|
740
|
+
}
|
|
741
|
+
transaction.status = DistributedTransactionStatus.PREPARED;
|
|
742
|
+
transaction.preparedAt = Date.now();
|
|
743
|
+
return { transactionId, status: DistributedTransactionStatus.PREPARED, success: true };
|
|
744
|
+
}
|
|
745
|
+
catch (error) {
|
|
746
|
+
// Release locks
|
|
747
|
+
for (const key of transaction.lockedKeys) {
|
|
748
|
+
const handle = this.locks.get(`txn:${key}`);
|
|
749
|
+
if (handle) {
|
|
750
|
+
await this.releaseLock(handle);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
transaction.status = DistributedTransactionStatus.FAILED;
|
|
754
|
+
return {
|
|
755
|
+
transactionId,
|
|
756
|
+
status: DistributedTransactionStatus.FAILED,
|
|
757
|
+
success: false,
|
|
758
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
async commitTransaction(transactionId) {
|
|
763
|
+
const transaction = this.transactions.get(transactionId);
|
|
764
|
+
if (!transaction) {
|
|
765
|
+
return { transactionId, status: DistributedTransactionStatus.FAILED, success: false, error: 'Transaction not found' };
|
|
766
|
+
}
|
|
767
|
+
if (transaction.status !== DistributedTransactionStatus.PREPARED) {
|
|
768
|
+
return { transactionId, status: transaction.status, success: false, error: 'Transaction is not prepared' };
|
|
769
|
+
}
|
|
770
|
+
const results = [];
|
|
771
|
+
try {
|
|
772
|
+
// Apply all staged operations atomically
|
|
773
|
+
for (const [key, staged] of transaction.stagedOperations) {
|
|
774
|
+
if (staged.value === undefined) {
|
|
775
|
+
this.state.delete(key);
|
|
776
|
+
results.push({ key, success: true });
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
this.state.set(key, { value: staged.value, version: staged.version });
|
|
780
|
+
results.push({ key, success: true, value: staged.value, version: staged.version });
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
// Release locks
|
|
784
|
+
for (const key of transaction.lockedKeys) {
|
|
785
|
+
const handle = this.locks.get(`txn:${key}`);
|
|
786
|
+
if (handle) {
|
|
787
|
+
await this.releaseLock(handle);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
transaction.status = DistributedTransactionStatus.COMMITTED;
|
|
791
|
+
transaction.completedAt = Date.now();
|
|
792
|
+
return { transactionId, status: DistributedTransactionStatus.COMMITTED, success: true, results };
|
|
793
|
+
}
|
|
794
|
+
catch (error) {
|
|
795
|
+
transaction.status = DistributedTransactionStatus.FAILED;
|
|
796
|
+
return {
|
|
797
|
+
transactionId,
|
|
798
|
+
status: DistributedTransactionStatus.FAILED,
|
|
799
|
+
success: false,
|
|
800
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
async rollbackTransaction(transactionId) {
|
|
805
|
+
const transaction = this.transactions.get(transactionId);
|
|
806
|
+
if (!transaction) {
|
|
807
|
+
return { transactionId, status: DistributedTransactionStatus.FAILED, success: false, error: 'Transaction not found' };
|
|
808
|
+
}
|
|
809
|
+
// Release all held locks
|
|
810
|
+
for (const key of transaction.lockedKeys) {
|
|
811
|
+
const handle = this.locks.get(`txn:${key}`);
|
|
812
|
+
if (handle) {
|
|
813
|
+
await this.releaseLock(handle);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
transaction.status = DistributedTransactionStatus.ROLLED_BACK;
|
|
817
|
+
transaction.completedAt = Date.now();
|
|
818
|
+
return { transactionId, status: DistributedTransactionStatus.ROLLED_BACK, success: true };
|
|
819
|
+
}
|
|
820
|
+
async executeTransaction(operations, options) {
|
|
821
|
+
// Begin
|
|
822
|
+
const transaction = await this.beginTransaction(options);
|
|
823
|
+
// Add operations
|
|
824
|
+
for (const op of operations) {
|
|
825
|
+
await this.addTransactionOperation(transaction.transactionId, op);
|
|
826
|
+
}
|
|
827
|
+
// Prepare
|
|
828
|
+
const prepareResult = await this.prepareTransaction(transaction.transactionId);
|
|
829
|
+
if (prepareResult.status !== DistributedTransactionStatus.PREPARED) {
|
|
830
|
+
return prepareResult;
|
|
831
|
+
}
|
|
832
|
+
// Commit
|
|
833
|
+
return this.commitTransaction(transaction.transactionId);
|
|
834
|
+
}
|
|
835
|
+
// ============================================================================
|
|
836
|
+
// Cleanup
|
|
837
|
+
// ============================================================================
|
|
838
|
+
cleanup() {
|
|
839
|
+
const now = Date.now();
|
|
840
|
+
// Cleanup expired locks
|
|
841
|
+
for (const [key, lock] of this.locks) {
|
|
842
|
+
if (lock.expiresAt < now) {
|
|
843
|
+
// Clear renewal timer if exists
|
|
844
|
+
if (lock.renewalTimerId) {
|
|
845
|
+
clearInterval(lock.renewalTimerId);
|
|
846
|
+
this.renewalTimers.delete(lock.lockId);
|
|
847
|
+
}
|
|
848
|
+
this.locks.delete(key);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
// Cleanup expired state
|
|
852
|
+
for (const [key, entry] of this.state) {
|
|
853
|
+
if (entry.expiresAt && entry.expiresAt < now) {
|
|
854
|
+
this.state.delete(key);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
// Cleanup expired leader leases
|
|
858
|
+
for (const [key, leader] of this.leaders) {
|
|
859
|
+
if (leader.expiresAt < now && leader.leaderId) {
|
|
860
|
+
leader.leaderId = '';
|
|
861
|
+
leader.votes.clear();
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
// Cleanup old transactions
|
|
865
|
+
for (const [id, txn] of this.transactions) {
|
|
866
|
+
if (txn.completedAt && now - txn.completedAt > 60000) {
|
|
867
|
+
this.transactions.delete(id);
|
|
868
|
+
}
|
|
869
|
+
else if (now - txn.createdAt > txn.timeoutMs * 2) {
|
|
870
|
+
// Force cleanup timed out transactions
|
|
871
|
+
for (const key of txn.lockedKeys) {
|
|
872
|
+
const handle = this.locks.get(`txn:${key}`);
|
|
873
|
+
if (handle) {
|
|
874
|
+
this.locks.delete(`txn:${key}`);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
this.transactions.delete(id);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
// Cleanup old processed message IDs (keep last 1000 per subscriber)
|
|
881
|
+
for (const [subscriberId, messageIds] of this.processedMessageIds) {
|
|
882
|
+
if (messageIds.size > 1000) {
|
|
883
|
+
const arr = Array.from(messageIds);
|
|
884
|
+
this.processedMessageIds.set(subscriberId, new Set(arr.slice(-1000)));
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Create an in-memory distributed adapter
|
|
891
|
+
* Primarily for development and testing purposes
|
|
892
|
+
*/
|
|
893
|
+
export const createInMemoryAdapter = (nodeId) => {
|
|
894
|
+
return new InMemoryDistributedAdapter(nodeId);
|
|
895
|
+
};
|
|
896
|
+
//# sourceMappingURL=distributed-adapters.js.map
|