@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.
Files changed (40) hide show
  1. package/README.md +200 -1
  2. package/dist/constants/index.d.ts +1 -0
  3. package/dist/constants/index.d.ts.map +1 -1
  4. package/dist/constants/index.js +1 -0
  5. package/dist/constants/index.js.map +1 -1
  6. package/dist/enums/index.d.ts +102 -0
  7. package/dist/enums/index.d.ts.map +1 -1
  8. package/dist/enums/index.js +116 -0
  9. package/dist/enums/index.js.map +1 -1
  10. package/dist/index.d.ts +3 -3
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +2 -2
  13. package/dist/index.js.map +1 -1
  14. package/dist/types/index.d.ts +411 -1
  15. package/dist/types/index.d.ts.map +1 -1
  16. package/dist/utilities/distributed-adapters.d.ts +97 -0
  17. package/dist/utilities/distributed-adapters.d.ts.map +1 -0
  18. package/dist/utilities/distributed-adapters.js +896 -0
  19. package/dist/utilities/distributed-adapters.js.map +1 -0
  20. package/dist/utilities/distributed-buffer.d.ts +16 -0
  21. package/dist/utilities/distributed-buffer.d.ts.map +1 -0
  22. package/dist/utilities/distributed-buffer.js +142 -0
  23. package/dist/utilities/distributed-buffer.js.map +1 -0
  24. package/dist/utilities/distributed-coordinator.d.ts +246 -0
  25. package/dist/utilities/distributed-coordinator.d.ts.map +1 -0
  26. package/dist/utilities/distributed-coordinator.js +680 -0
  27. package/dist/utilities/distributed-coordinator.js.map +1 -0
  28. package/dist/utilities/distributed-infrastructure.d.ts +57 -0
  29. package/dist/utilities/distributed-infrastructure.d.ts.map +1 -0
  30. package/dist/utilities/distributed-infrastructure.js +117 -0
  31. package/dist/utilities/distributed-infrastructure.js.map +1 -0
  32. package/dist/utilities/distributed-scheduler.d.ts +55 -0
  33. package/dist/utilities/distributed-scheduler.d.ts.map +1 -0
  34. package/dist/utilities/distributed-scheduler.js +151 -0
  35. package/dist/utilities/distributed-scheduler.js.map +1 -0
  36. package/dist/utilities/index.d.ts +8 -0
  37. package/dist/utilities/index.d.ts.map +1 -1
  38. package/dist/utilities/index.js +5 -0
  39. package/dist/utilities/index.js.map +1 -1
  40. 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