@optimystic/db-p2p 0.9.3 → 0.11.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 +32 -6
- package/dist/src/cluster/cluster-repo.d.ts +23 -1
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +110 -8
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/cluster/i-transaction-state-store.d.ts +36 -0
- package/dist/src/cluster/i-transaction-state-store.d.ts.map +1 -0
- package/dist/src/cluster/i-transaction-state-store.js +2 -0
- package/dist/src/cluster/i-transaction-state-store.js.map +1 -0
- package/dist/src/cluster/memory-transaction-state-store.d.ts +19 -0
- package/dist/src/cluster/memory-transaction-state-store.d.ts.map +1 -0
- package/dist/src/cluster/memory-transaction-state-store.js +44 -0
- package/dist/src/cluster/memory-transaction-state-store.js.map +1 -0
- package/dist/src/cluster/persistent-transaction-state-store.d.ts +26 -0
- package/dist/src/cluster/persistent-transaction-state-store.d.ts.map +1 -0
- package/dist/src/cluster/persistent-transaction-state-store.js +79 -0
- package/dist/src/cluster/persistent-transaction-state-store.js.map +1 -0
- package/dist/src/cluster/spread-on-churn.d.ts +67 -0
- package/dist/src/cluster/spread-on-churn.d.ts.map +1 -0
- package/dist/src/cluster/spread-on-churn.js +192 -0
- package/dist/src/cluster/spread-on-churn.js.map +1 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +6 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/libp2p-node-base.d.ts +13 -0
- package/dist/src/libp2p-node-base.d.ts.map +1 -1
- package/dist/src/libp2p-node-base.js +17 -4
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/network/network-manager-service.d.ts +8 -0
- package/dist/src/network/network-manager-service.d.ts.map +1 -1
- package/dist/src/network/network-manager-service.js +21 -0
- package/dist/src/network/network-manager-service.js.map +1 -1
- package/dist/src/repo/cluster-coordinator.d.ts +12 -1
- package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
- package/dist/src/repo/cluster-coordinator.js +67 -2
- package/dist/src/repo/cluster-coordinator.js.map +1 -1
- package/dist/src/repo/coordinator-repo.d.ts +5 -2
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +8 -4
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/storage/i-kv-store.d.ts +9 -0
- package/dist/src/storage/i-kv-store.d.ts.map +1 -0
- package/dist/src/storage/i-kv-store.js +2 -0
- package/dist/src/storage/i-kv-store.js.map +1 -0
- package/dist/src/storage/memory-kv-store.d.ts +10 -0
- package/dist/src/storage/memory-kv-store.d.ts.map +1 -0
- package/dist/src/storage/memory-kv-store.js +23 -0
- package/dist/src/storage/memory-kv-store.js.map +1 -0
- package/package.json +2 -2
- package/src/cluster/cluster-repo.ts +112 -8
- package/src/cluster/i-transaction-state-store.ts +43 -0
- package/src/cluster/memory-transaction-state-store.ts +56 -0
- package/src/cluster/persistent-transaction-state-store.ts +92 -0
- package/src/cluster/spread-on-churn.ts +285 -0
- package/src/index.ts +6 -0
- package/src/libp2p-node-base.ts +35 -4
- package/src/network/network-manager-service.ts +32 -0
- package/src/repo/cluster-coordinator.ts +72 -2
- package/src/repo/coordinator-repo.ts +13 -4
- package/src/storage/i-kv-store.ts +8 -0
- package/src/storage/memory-kv-store.ts +28 -0
|
@@ -14,6 +14,7 @@ import type { PartitionDetector } from "./partition-detector.js";
|
|
|
14
14
|
import type { FretService } from "p2p-fret";
|
|
15
15
|
import type { IPeerReputation } from "../reputation/types.js";
|
|
16
16
|
import { PenaltyReason } from "../reputation/types.js";
|
|
17
|
+
import type { ITransactionStateStore } from "./i-transaction-state-store.js";
|
|
17
18
|
|
|
18
19
|
const log = createLogger('cluster-member')
|
|
19
20
|
|
|
@@ -45,6 +46,7 @@ interface ClusterMemberComponents {
|
|
|
45
46
|
validator?: ITransactionValidator;
|
|
46
47
|
reputation?: IPeerReputation;
|
|
47
48
|
consensusConfig?: ClusterConsensusConfig;
|
|
49
|
+
stateStore?: ITransactionStateStore;
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
export function clusterMember(components: ClusterMemberComponents): ClusterMember {
|
|
@@ -58,7 +60,8 @@ export function clusterMember(components: ClusterMemberComponents): ClusterMembe
|
|
|
58
60
|
components.fretService,
|
|
59
61
|
components.validator,
|
|
60
62
|
components.reputation,
|
|
61
|
-
components.consensusConfig
|
|
63
|
+
components.consensusConfig,
|
|
64
|
+
components.stateStore
|
|
62
65
|
);
|
|
63
66
|
}
|
|
64
67
|
|
|
@@ -80,6 +83,9 @@ export class ClusterMember implements ICluster {
|
|
|
80
83
|
private pendingUpdates: Map<string, Promise<ClusterRecord>> = new Map();
|
|
81
84
|
// Temporarily set during validateSignatures so verifySignature can access the record
|
|
82
85
|
private currentValidationRecord?: ClusterRecord;
|
|
86
|
+
// Interval handles for periodic cleanup (stored so dispose() can clear them)
|
|
87
|
+
private readonly expirationInterval: NodeJS.Timeout;
|
|
88
|
+
private readonly cleanupInterval: NodeJS.Timeout;
|
|
83
89
|
|
|
84
90
|
/** Effective super-majority threshold. Defaults to 1.0 (unanimity) for backward compatibility. */
|
|
85
91
|
private readonly superMajorityThreshold: number;
|
|
@@ -94,13 +100,31 @@ export class ClusterMember implements ICluster {
|
|
|
94
100
|
private readonly fretService?: FretService,
|
|
95
101
|
private readonly validator?: ITransactionValidator,
|
|
96
102
|
private readonly reputation?: IPeerReputation,
|
|
97
|
-
consensusConfig?: ClusterConsensusConfig
|
|
103
|
+
consensusConfig?: ClusterConsensusConfig,
|
|
104
|
+
private readonly stateStore?: ITransactionStateStore
|
|
98
105
|
) {
|
|
99
106
|
this.superMajorityThreshold = consensusConfig?.superMajorityThreshold ?? 1.0;
|
|
100
107
|
// Periodically clean up expired transactions (.unref() so tests/short-lived processes can exit)
|
|
101
|
-
setInterval(() => this.queueExpiredTransactions(), 60000)
|
|
108
|
+
this.expirationInterval = setInterval(() => this.queueExpiredTransactions(), 60000);
|
|
109
|
+
this.expirationInterval.unref();
|
|
102
110
|
// Process cleanup queue
|
|
103
|
-
setInterval(() => this.processCleanupQueue(), 1000)
|
|
111
|
+
this.cleanupInterval = setInterval(() => this.processCleanupQueue(), 1000);
|
|
112
|
+
this.cleanupInterval.unref();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Clears all interval and timeout handles and empties active state.
|
|
117
|
+
* Called during node shutdown to prevent leaked timers.
|
|
118
|
+
*/
|
|
119
|
+
dispose(): void {
|
|
120
|
+
clearInterval(this.expirationInterval);
|
|
121
|
+
clearInterval(this.cleanupInterval);
|
|
122
|
+
for (const [, state] of this.activeTransactions) {
|
|
123
|
+
if (state.promiseTimeout) clearTimeout(state.promiseTimeout);
|
|
124
|
+
if (state.resolutionTimeout) clearTimeout(state.resolutionTimeout);
|
|
125
|
+
}
|
|
126
|
+
this.activeTransactions.clear();
|
|
127
|
+
this.cleanupQueue.length = 0;
|
|
104
128
|
}
|
|
105
129
|
|
|
106
130
|
/**
|
|
@@ -228,7 +252,10 @@ export class ClusterMember implements ICluster {
|
|
|
228
252
|
log('cluster-member:action-consensus-after-commit', {
|
|
229
253
|
messageHash: record.messageHash
|
|
230
254
|
});
|
|
231
|
-
|
|
255
|
+
// Check persistent store for post-recovery dedup before synchronous guard
|
|
256
|
+
if (!await this.wasTransactionExecutedAsync(currentRecord.messageHash)) {
|
|
257
|
+
await this.handleConsensus(currentRecord);
|
|
258
|
+
}
|
|
232
259
|
}
|
|
233
260
|
}
|
|
234
261
|
shouldPersist = false;
|
|
@@ -237,8 +264,12 @@ export class ClusterMember implements ICluster {
|
|
|
237
264
|
log('cluster-member:action-consensus', {
|
|
238
265
|
messageHash: record.messageHash
|
|
239
266
|
});
|
|
240
|
-
//
|
|
241
|
-
await this.
|
|
267
|
+
// Check persistent store for post-recovery dedup before synchronous guard
|
|
268
|
+
if (await this.wasTransactionExecutedAsync(currentRecord.messageHash)) {
|
|
269
|
+
log('cluster-member:consensus-already-executed', { messageHash: record.messageHash });
|
|
270
|
+
} else {
|
|
271
|
+
await this.handleConsensus(currentRecord);
|
|
272
|
+
}
|
|
242
273
|
// Don't call clearTransaction here - it happens in handleConsensus
|
|
243
274
|
shouldPersist = false;
|
|
244
275
|
break;
|
|
@@ -275,6 +306,7 @@ export class ClusterMember implements ICluster {
|
|
|
275
306
|
promiseTimeout: timeouts.promiseTimeout,
|
|
276
307
|
resolutionTimeout: timeouts.resolutionTimeout
|
|
277
308
|
});
|
|
309
|
+
this.persistParticipantState(record.messageHash, currentRecord);
|
|
278
310
|
log('cluster-member:state-persist', {
|
|
279
311
|
messageHash: record.messageHash,
|
|
280
312
|
storedPromises: Object.keys(currentRecord.promises ?? {}),
|
|
@@ -637,7 +669,10 @@ export class ClusterMember implements ICluster {
|
|
|
637
669
|
return;
|
|
638
670
|
}
|
|
639
671
|
// Mark as executing IMMEDIATELY before any async operations
|
|
640
|
-
|
|
672
|
+
const executedAt = Date.now();
|
|
673
|
+
this.executedTransactions.set(record.messageHash, executedAt);
|
|
674
|
+
this.stateStore?.markExecuted(record.messageHash, executedAt)
|
|
675
|
+
.catch(err => log('cluster-member:persist-executed-error', { messageHash: record.messageHash, error: (err as Error).message }));
|
|
641
676
|
|
|
642
677
|
try {
|
|
643
678
|
// Execute the operations - check return values for failures
|
|
@@ -902,6 +937,8 @@ export class ClusterMember implements ICluster {
|
|
|
902
937
|
this.executedTransactions.delete(messageHash);
|
|
903
938
|
}
|
|
904
939
|
}
|
|
940
|
+
this.stateStore?.pruneExecuted(expirationThreshold)
|
|
941
|
+
.catch(err => log('cluster-member:prune-executed-error', { error: (err as Error).message }));
|
|
905
942
|
}
|
|
906
943
|
|
|
907
944
|
private async processCleanupQueue(): Promise<void> {
|
|
@@ -937,10 +974,77 @@ export class ClusterMember implements ICluster {
|
|
|
937
974
|
clearTimeout(state.resolutionTimeout);
|
|
938
975
|
}
|
|
939
976
|
this.activeTransactions.delete(messageHash);
|
|
977
|
+
this.stateStore?.deleteParticipantState(messageHash)
|
|
978
|
+
.catch(err => log('cluster-member:persist-delete-error', { messageHash, error: (err as Error).message }));
|
|
940
979
|
log('cluster-member:clear-done', {
|
|
941
980
|
messageHash,
|
|
942
981
|
remaining: Array.from(this.activeTransactions.keys())
|
|
943
982
|
});
|
|
944
983
|
}
|
|
984
|
+
|
|
985
|
+
/** Fire-and-forget persist — errors are logged, never thrown. */
|
|
986
|
+
private persistParticipantState(messageHash: string, record: ClusterRecord): void {
|
|
987
|
+
if (!this.stateStore) return;
|
|
988
|
+
this.stateStore.saveParticipantState(messageHash, {
|
|
989
|
+
messageHash,
|
|
990
|
+
record,
|
|
991
|
+
lastUpdate: Date.now()
|
|
992
|
+
}).catch(err => log('cluster-member:persist-error', { messageHash, error: (err as Error).message }));
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Recover member transactions from persistent store after a restart.
|
|
997
|
+
* Called during node startup, before accepting new requests.
|
|
998
|
+
*/
|
|
999
|
+
async recoverTransactions(): Promise<void> {
|
|
1000
|
+
if (!this.stateStore) return;
|
|
1001
|
+
const now = Date.now();
|
|
1002
|
+
|
|
1003
|
+
// 1. Prune expired executed entries from persistent store
|
|
1004
|
+
await this.stateStore.pruneExecuted(now - ExecutedTransactionTtlMs);
|
|
1005
|
+
// Note: executed transactions are checked via wasTransactionExecutedAsync() at runtime,
|
|
1006
|
+
// which falls back to the persistent store when the in-memory map misses.
|
|
1007
|
+
|
|
1008
|
+
// 2. Restore active participant states
|
|
1009
|
+
const participantStates = await this.stateStore.getAllParticipantStates();
|
|
1010
|
+
for (const state of participantStates) {
|
|
1011
|
+
const { messageHash } = state;
|
|
1012
|
+
// Expired — clean up
|
|
1013
|
+
if (state.record.message.expiration && state.record.message.expiration < now) {
|
|
1014
|
+
log('cluster-member:recovery-expired', { messageHash });
|
|
1015
|
+
await this.stateStore.deleteParticipantState(messageHash);
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
// Restore into activeTransactions with fresh timeouts
|
|
1019
|
+
log('cluster-member:recovery-restore', { messageHash });
|
|
1020
|
+
const timeouts = this.setupTimeouts(state.record);
|
|
1021
|
+
this.activeTransactions.set(messageHash, {
|
|
1022
|
+
record: state.record,
|
|
1023
|
+
lastUpdate: state.lastUpdate,
|
|
1024
|
+
promiseTimeout: timeouts.promiseTimeout,
|
|
1025
|
+
resolutionTimeout: timeouts.resolutionTimeout
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
log('cluster-member:recovery-complete', {
|
|
1030
|
+
restoredActive: this.activeTransactions.size,
|
|
1031
|
+
restoredExecuted: this.executedTransactions.size
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Checks if a transaction's operations were already executed during consensus.
|
|
1037
|
+
* Falls back to the persistent store when the in-memory map misses.
|
|
1038
|
+
*/
|
|
1039
|
+
async wasTransactionExecutedAsync(messageHash: string): Promise<boolean> {
|
|
1040
|
+
if (this.executedTransactions.has(messageHash)) return true;
|
|
1041
|
+
if (!this.stateStore) return false;
|
|
1042
|
+
const persisted = await this.stateStore.wasExecuted(messageHash);
|
|
1043
|
+
if (persisted) {
|
|
1044
|
+
// Re-populate in-memory map for future synchronous checks
|
|
1045
|
+
this.executedTransactions.set(messageHash, Date.now());
|
|
1046
|
+
}
|
|
1047
|
+
return persisted;
|
|
1048
|
+
}
|
|
945
1049
|
}
|
|
946
1050
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ClusterRecord } from "@optimystic/db-core";
|
|
2
|
+
|
|
3
|
+
/** Serializable coordinator transaction state (excludes timers, Pending wrapper) */
|
|
4
|
+
export interface PersistedCoordinatorState {
|
|
5
|
+
messageHash: string;
|
|
6
|
+
record: ClusterRecord;
|
|
7
|
+
lastUpdate: number;
|
|
8
|
+
/** Which phase was reached when last persisted */
|
|
9
|
+
phase: 'promising' | 'committing' | 'broadcasting';
|
|
10
|
+
/** Retry state for commit broadcast failures (excludes timer) */
|
|
11
|
+
retryState?: {
|
|
12
|
+
pendingPeers: string[];
|
|
13
|
+
attempt: number;
|
|
14
|
+
intervalMs: number;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Serializable participant transaction state (excludes timers) */
|
|
19
|
+
export interface PersistedParticipantState {
|
|
20
|
+
messageHash: string;
|
|
21
|
+
record: ClusterRecord;
|
|
22
|
+
lastUpdate: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Platform-agnostic store for persisting 2PC transaction state. */
|
|
26
|
+
export interface ITransactionStateStore {
|
|
27
|
+
// --- Coordinator state (keyed by messageHash) ---
|
|
28
|
+
saveCoordinatorState(messageHash: string, state: PersistedCoordinatorState): Promise<void>;
|
|
29
|
+
getCoordinatorState(messageHash: string): Promise<PersistedCoordinatorState | undefined>;
|
|
30
|
+
deleteCoordinatorState(messageHash: string): Promise<void>;
|
|
31
|
+
getAllCoordinatorStates(): Promise<PersistedCoordinatorState[]>;
|
|
32
|
+
|
|
33
|
+
// --- Participant state (keyed by messageHash) ---
|
|
34
|
+
saveParticipantState(messageHash: string, state: PersistedParticipantState): Promise<void>;
|
|
35
|
+
getParticipantState(messageHash: string): Promise<PersistedParticipantState | undefined>;
|
|
36
|
+
deleteParticipantState(messageHash: string): Promise<void>;
|
|
37
|
+
getAllParticipantStates(): Promise<PersistedParticipantState[]>;
|
|
38
|
+
|
|
39
|
+
// --- Executed transaction dedup guard ---
|
|
40
|
+
markExecuted(messageHash: string, timestamp: number): Promise<void>;
|
|
41
|
+
wasExecuted(messageHash: string): Promise<boolean>;
|
|
42
|
+
pruneExecuted(olderThan: number): Promise<void>;
|
|
43
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ITransactionStateStore, PersistedCoordinatorState, PersistedParticipantState } from "./i-transaction-state-store.js";
|
|
2
|
+
|
|
3
|
+
/** In-memory ITransactionStateStore. Default when no persistent store is injected. */
|
|
4
|
+
export class MemoryTransactionStateStore implements ITransactionStateStore {
|
|
5
|
+
private readonly coordinatorStates = new Map<string, PersistedCoordinatorState>();
|
|
6
|
+
private readonly participantStates = new Map<string, PersistedParticipantState>();
|
|
7
|
+
private readonly executedMap = new Map<string, number>();
|
|
8
|
+
|
|
9
|
+
async saveCoordinatorState(messageHash: string, state: PersistedCoordinatorState): Promise<void> {
|
|
10
|
+
this.coordinatorStates.set(messageHash, state);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async getCoordinatorState(messageHash: string): Promise<PersistedCoordinatorState | undefined> {
|
|
14
|
+
return this.coordinatorStates.get(messageHash);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async deleteCoordinatorState(messageHash: string): Promise<void> {
|
|
18
|
+
this.coordinatorStates.delete(messageHash);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async getAllCoordinatorStates(): Promise<PersistedCoordinatorState[]> {
|
|
22
|
+
return Array.from(this.coordinatorStates.values());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async saveParticipantState(messageHash: string, state: PersistedParticipantState): Promise<void> {
|
|
26
|
+
this.participantStates.set(messageHash, state);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async getParticipantState(messageHash: string): Promise<PersistedParticipantState | undefined> {
|
|
30
|
+
return this.participantStates.get(messageHash);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async deleteParticipantState(messageHash: string): Promise<void> {
|
|
34
|
+
this.participantStates.delete(messageHash);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async getAllParticipantStates(): Promise<PersistedParticipantState[]> {
|
|
38
|
+
return Array.from(this.participantStates.values());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async markExecuted(messageHash: string, timestamp: number): Promise<void> {
|
|
42
|
+
this.executedMap.set(messageHash, timestamp);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async wasExecuted(messageHash: string): Promise<boolean> {
|
|
46
|
+
return this.executedMap.has(messageHash);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async pruneExecuted(olderThan: number): Promise<void> {
|
|
50
|
+
for (const [hash, ts] of this.executedMap) {
|
|
51
|
+
if (ts < olderThan) {
|
|
52
|
+
this.executedMap.delete(hash);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { IKVStore } from "../storage/i-kv-store.js";
|
|
2
|
+
import type { ITransactionStateStore, PersistedCoordinatorState, PersistedParticipantState } from "./i-transaction-state-store.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ITransactionStateStore backed by an IKVStore for cross-platform persistence.
|
|
6
|
+
*
|
|
7
|
+
* Key namespace:
|
|
8
|
+
* coordinator/{messageHash} → JSON(PersistedCoordinatorState)
|
|
9
|
+
* participant/{messageHash} → JSON(PersistedParticipantState)
|
|
10
|
+
* executed/{messageHash} → JSON({ timestamp: number })
|
|
11
|
+
*/
|
|
12
|
+
export class PersistentTransactionStateStore implements ITransactionStateStore {
|
|
13
|
+
constructor(private readonly kv: IKVStore) {}
|
|
14
|
+
|
|
15
|
+
// --- Coordinator ---
|
|
16
|
+
|
|
17
|
+
async saveCoordinatorState(messageHash: string, state: PersistedCoordinatorState): Promise<void> {
|
|
18
|
+
await this.kv.set(`coordinator/${messageHash}`, JSON.stringify(state));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async getCoordinatorState(messageHash: string): Promise<PersistedCoordinatorState | undefined> {
|
|
22
|
+
const raw = await this.kv.get(`coordinator/${messageHash}`);
|
|
23
|
+
return raw ? JSON.parse(raw) as PersistedCoordinatorState : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async deleteCoordinatorState(messageHash: string): Promise<void> {
|
|
27
|
+
await this.kv.delete(`coordinator/${messageHash}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async getAllCoordinatorStates(): Promise<PersistedCoordinatorState[]> {
|
|
31
|
+
const keys = await this.kv.list('coordinator/');
|
|
32
|
+
const results: PersistedCoordinatorState[] = [];
|
|
33
|
+
for (const key of keys) {
|
|
34
|
+
const raw = await this.kv.get(key);
|
|
35
|
+
if (raw) {
|
|
36
|
+
results.push(JSON.parse(raw) as PersistedCoordinatorState);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return results;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- Participant ---
|
|
43
|
+
|
|
44
|
+
async saveParticipantState(messageHash: string, state: PersistedParticipantState): Promise<void> {
|
|
45
|
+
await this.kv.set(`participant/${messageHash}`, JSON.stringify(state));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async getParticipantState(messageHash: string): Promise<PersistedParticipantState | undefined> {
|
|
49
|
+
const raw = await this.kv.get(`participant/${messageHash}`);
|
|
50
|
+
return raw ? JSON.parse(raw) as PersistedParticipantState : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async deleteParticipantState(messageHash: string): Promise<void> {
|
|
54
|
+
await this.kv.delete(`participant/${messageHash}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async getAllParticipantStates(): Promise<PersistedParticipantState[]> {
|
|
58
|
+
const keys = await this.kv.list('participant/');
|
|
59
|
+
const results: PersistedParticipantState[] = [];
|
|
60
|
+
for (const key of keys) {
|
|
61
|
+
const raw = await this.kv.get(key);
|
|
62
|
+
if (raw) {
|
|
63
|
+
results.push(JSON.parse(raw) as PersistedParticipantState);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return results;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- Executed ---
|
|
70
|
+
|
|
71
|
+
async markExecuted(messageHash: string, timestamp: number): Promise<void> {
|
|
72
|
+
await this.kv.set(`executed/${messageHash}`, JSON.stringify({ timestamp }));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async wasExecuted(messageHash: string): Promise<boolean> {
|
|
76
|
+
const raw = await this.kv.get(`executed/${messageHash}`);
|
|
77
|
+
return raw !== undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async pruneExecuted(olderThan: number): Promise<void> {
|
|
81
|
+
const keys = await this.kv.list('executed/');
|
|
82
|
+
for (const key of keys) {
|
|
83
|
+
const raw = await this.kv.get(key);
|
|
84
|
+
if (raw) {
|
|
85
|
+
const { timestamp } = JSON.parse(raw) as { timestamp: number };
|
|
86
|
+
if (timestamp < olderThan) {
|
|
87
|
+
await this.kv.delete(key);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import type { Startable, Libp2p } from '@libp2p/interface'
|
|
2
|
+
import type { IRepo, IPeerNetwork } from '@optimystic/db-core'
|
|
3
|
+
import { hashKey } from 'p2p-fret'
|
|
4
|
+
import type { FretService } from 'p2p-fret'
|
|
5
|
+
import { peerIdFromString } from '@libp2p/peer-id'
|
|
6
|
+
import type { PartitionDetector } from './partition-detector.js'
|
|
7
|
+
import { BlockTransferClient } from './block-transfer-service.js'
|
|
8
|
+
import { createLogger } from '../logger.js'
|
|
9
|
+
|
|
10
|
+
const log = createLogger('spread-on-churn')
|
|
11
|
+
const textEncoder = new TextEncoder()
|
|
12
|
+
|
|
13
|
+
// ── Types ────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export interface SpreadOnChurnConfig {
|
|
16
|
+
/** Enable the churn-resilient spread protocol. Default: true */
|
|
17
|
+
enabled: boolean
|
|
18
|
+
/** Number of middle-closest peers eligible to spread (d). Default: 3 */
|
|
19
|
+
spreadDistance: number
|
|
20
|
+
/** Enable dynamic d scaling based on cluster health. Default: true */
|
|
21
|
+
dynamicSpreadDistance: boolean
|
|
22
|
+
/** Cluster size ratio below which spread becomes more aggressive. Default: 0.6 */
|
|
23
|
+
healthThreshold: number
|
|
24
|
+
/** Debounce window for departure detection (ms). Default: 5000 */
|
|
25
|
+
departureDebounceMs: number
|
|
26
|
+
/** Number of peers beyond cluster boundary to target. Default: 4 */
|
|
27
|
+
expansionStep: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SpreadOnChurnDeps {
|
|
31
|
+
libp2p: Libp2p
|
|
32
|
+
fret: FretService
|
|
33
|
+
partitionDetector: PartitionDetector
|
|
34
|
+
repo: IRepo
|
|
35
|
+
peerNetwork: IPeerNetwork
|
|
36
|
+
clusterSize: number
|
|
37
|
+
protocolPrefix?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SpreadEvent {
|
|
41
|
+
/** Blocks that were spread */
|
|
42
|
+
spread: Array<{
|
|
43
|
+
blockId: string
|
|
44
|
+
targets: string[]
|
|
45
|
+
succeeded: string[]
|
|
46
|
+
failed: string[]
|
|
47
|
+
}>
|
|
48
|
+
/** Current effective d */
|
|
49
|
+
effectiveD: number
|
|
50
|
+
/** Timestamp of the departure that triggered this */
|
|
51
|
+
triggeredAt: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type SpreadHandler = (event: SpreadEvent) => void
|
|
55
|
+
|
|
56
|
+
// ── Defaults ─────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
const DEFAULT_CONFIG: SpreadOnChurnConfig = {
|
|
59
|
+
enabled: true,
|
|
60
|
+
spreadDistance: 3,
|
|
61
|
+
dynamicSpreadDistance: true,
|
|
62
|
+
healthThreshold: 0.6,
|
|
63
|
+
departureDebounceMs: 5000,
|
|
64
|
+
expansionStep: 4,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Monitor ──────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
export class SpreadOnChurnMonitor implements Startable {
|
|
70
|
+
private running = false
|
|
71
|
+
private readonly trackedBlocks = new Set<string>()
|
|
72
|
+
private readonly handlers: SpreadHandler[] = []
|
|
73
|
+
private debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
74
|
+
private departureTimestamps: number[] = []
|
|
75
|
+
private departureTimestamp = 0
|
|
76
|
+
|
|
77
|
+
private readonly config: SpreadOnChurnConfig
|
|
78
|
+
private readonly onConnectionClose: () => void
|
|
79
|
+
|
|
80
|
+
constructor(
|
|
81
|
+
private readonly deps: SpreadOnChurnDeps,
|
|
82
|
+
config: Partial<SpreadOnChurnConfig> = {}
|
|
83
|
+
) {
|
|
84
|
+
this.config = { ...DEFAULT_CONFIG, ...config }
|
|
85
|
+
this.onConnectionClose = () => this.handleDeparture()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Startable ────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
async start(): Promise<void> {
|
|
91
|
+
if (this.running) return
|
|
92
|
+
this.running = true
|
|
93
|
+
|
|
94
|
+
this.deps.libp2p.addEventListener('connection:close', this.onConnectionClose)
|
|
95
|
+
|
|
96
|
+
log('started, tracking %d blocks', this.trackedBlocks.size)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async stop(): Promise<void> {
|
|
100
|
+
if (!this.running) return
|
|
101
|
+
this.running = false
|
|
102
|
+
|
|
103
|
+
this.deps.libp2p.removeEventListener('connection:close', this.onConnectionClose)
|
|
104
|
+
|
|
105
|
+
if (this.debounceTimer) {
|
|
106
|
+
clearTimeout(this.debounceTimer)
|
|
107
|
+
this.debounceTimer = null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
log('stopped')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Public API ───────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
onSpread(handler: SpreadHandler): void {
|
|
116
|
+
this.handlers.push(handler)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
trackBlock(blockId: string): void {
|
|
120
|
+
this.trackedBlocks.add(blockId)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
untrackBlock(blockId: string): void {
|
|
124
|
+
this.trackedBlocks.delete(blockId)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
getTrackedBlockCount(): number {
|
|
128
|
+
return this.trackedBlocks.size
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Force an immediate spread check (useful for testing). */
|
|
132
|
+
async checkNow(): Promise<SpreadEvent | null> {
|
|
133
|
+
return this.performSpread(Date.now())
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Internal ─────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
private handleDeparture(): void {
|
|
139
|
+
if (!this.running) return
|
|
140
|
+
if (!this.config.enabled) return
|
|
141
|
+
|
|
142
|
+
if (!this.departureTimestamp) {
|
|
143
|
+
this.departureTimestamp = Date.now()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Record for dynamic-d sliding window
|
|
147
|
+
this.departureTimestamps.push(Date.now())
|
|
148
|
+
|
|
149
|
+
if (this.debounceTimer) {
|
|
150
|
+
clearTimeout(this.debounceTimer)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
this.debounceTimer = setTimeout(() => {
|
|
154
|
+
this.debounceTimer = null
|
|
155
|
+
const ts = this.departureTimestamp
|
|
156
|
+
this.departureTimestamp = 0
|
|
157
|
+
if (this.running) {
|
|
158
|
+
this.performSpread(ts).catch(err => {
|
|
159
|
+
log('spread error: %O', err)
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
}, this.config.departureDebounceMs)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private async performSpread(triggeredAt: number): Promise<SpreadEvent | null> {
|
|
166
|
+
if (!this.config.enabled) return null
|
|
167
|
+
|
|
168
|
+
if (this.deps.partitionDetector.detectPartition()) {
|
|
169
|
+
log('partition detected, suppressing spread')
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (this.trackedBlocks.size === 0) return null
|
|
174
|
+
|
|
175
|
+
const selfId = this.deps.libp2p.peerId.toString()
|
|
176
|
+
const effectiveD = this.computeEffectiveD()
|
|
177
|
+
const spreadResults: SpreadEvent['spread'] = []
|
|
178
|
+
|
|
179
|
+
for (const blockId of this.trackedBlocks) {
|
|
180
|
+
const key = textEncoder.encode(blockId)
|
|
181
|
+
const coord = await hashKey(key)
|
|
182
|
+
|
|
183
|
+
// Check eligibility: only middle peers spread
|
|
184
|
+
const rank = this.deps.fret.neighborDistance(selfId, coord, this.deps.clusterSize)
|
|
185
|
+
if (rank >= effectiveD) continue
|
|
186
|
+
|
|
187
|
+
// Get current cohort and expansion targets
|
|
188
|
+
const cohort = this.deps.fret.assembleCohort(coord, this.deps.clusterSize)
|
|
189
|
+
const cohortSet = new Set(cohort)
|
|
190
|
+
const expanded = this.deps.fret.expandCohort(
|
|
191
|
+
cohort, coord, this.config.expansionStep
|
|
192
|
+
)
|
|
193
|
+
const targets = expanded.filter(id => !cohortSet.has(id) && id !== selfId)
|
|
194
|
+
if (targets.length === 0) continue
|
|
195
|
+
|
|
196
|
+
// Read block data from local storage
|
|
197
|
+
const result = await this.deps.repo.get({ blockIds: [blockId] })
|
|
198
|
+
const blockResult = result[blockId]
|
|
199
|
+
if (!blockResult?.block) {
|
|
200
|
+
log('no-local-data block=%s', blockId)
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const blockData = textEncoder.encode(JSON.stringify(blockResult.block))
|
|
205
|
+
|
|
206
|
+
// Push to each target
|
|
207
|
+
const succeeded: string[] = []
|
|
208
|
+
const failed: string[] = []
|
|
209
|
+
|
|
210
|
+
for (const targetId of targets) {
|
|
211
|
+
try {
|
|
212
|
+
const peerId = peerIdFromString(targetId)
|
|
213
|
+
const client = new BlockTransferClient(
|
|
214
|
+
peerId,
|
|
215
|
+
this.deps.peerNetwork,
|
|
216
|
+
this.deps.protocolPrefix
|
|
217
|
+
)
|
|
218
|
+
await client.pushBlocks([blockId], [blockData], 'replication')
|
|
219
|
+
succeeded.push(targetId)
|
|
220
|
+
log('push:ok block=%s target=%s', blockId, targetId)
|
|
221
|
+
} catch (err) {
|
|
222
|
+
failed.push(targetId)
|
|
223
|
+
log('push:fail block=%s target=%s err=%s',
|
|
224
|
+
blockId, targetId, (err as Error).message)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
spreadResults.push({ blockId, targets, succeeded, failed })
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (spreadResults.length === 0) return null
|
|
232
|
+
|
|
233
|
+
const event: SpreadEvent = {
|
|
234
|
+
spread: spreadResults,
|
|
235
|
+
effectiveD,
|
|
236
|
+
triggeredAt,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
this.emitEvent(event)
|
|
240
|
+
return event
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private computeEffectiveD(): number {
|
|
244
|
+
const d = this.config.spreadDistance
|
|
245
|
+
if (!this.config.dynamicSpreadDistance) return d
|
|
246
|
+
|
|
247
|
+
const maxD = Math.max(d, Math.floor(this.deps.clusterSize / 2))
|
|
248
|
+
const windowMs = this.config.departureDebounceMs * 4
|
|
249
|
+
const now = Date.now()
|
|
250
|
+
|
|
251
|
+
// Prune old departure timestamps
|
|
252
|
+
this.departureTimestamps = this.departureTimestamps.filter(
|
|
253
|
+
ts => now - ts < windowMs
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
// Rapid churn: 3+ departures in window → increase d by 1
|
|
257
|
+
if (this.departureTimestamps.length >= 3) {
|
|
258
|
+
return Math.min(d + 1, maxD)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Low cluster health: observed cohort shrunk relative to expected
|
|
262
|
+
// We approximate observed cohort size from FRET diagnostics
|
|
263
|
+
const diag: any = (this.deps.fret as any).getDiagnostics?.()
|
|
264
|
+
const estimate = diag?.estimate ?? diag?.n
|
|
265
|
+
if (typeof estimate === 'number' && Number.isFinite(estimate) && estimate > 0) {
|
|
266
|
+
const ratio = estimate / this.deps.clusterSize
|
|
267
|
+
if (ratio < this.config.healthThreshold) {
|
|
268
|
+
const scaled = Math.ceil(d * (this.deps.clusterSize / estimate))
|
|
269
|
+
return Math.min(scaled, maxD)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return d
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private emitEvent(event: SpreadEvent): void {
|
|
277
|
+
for (const handler of this.handlers) {
|
|
278
|
+
try {
|
|
279
|
+
handler(event)
|
|
280
|
+
} catch (err) {
|
|
281
|
+
log('handler error: %O', err)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ export * from "./cluster/client.js";
|
|
|
2
2
|
export * from "./cluster/cluster-repo.js";
|
|
3
3
|
export * from "./cluster/service.js";
|
|
4
4
|
export * from "./cluster/rebalance-monitor.js";
|
|
5
|
+
export * from "./cluster/spread-on-churn.js";
|
|
5
6
|
export * from "./cluster/block-transfer.js";
|
|
6
7
|
export * from "./cluster/block-transfer-service.js";
|
|
7
8
|
export * from "./protocol-client.js";
|
|
@@ -31,3 +32,8 @@ export * from "./network/network-manager-service.js";
|
|
|
31
32
|
export * from "./network/get-network-manager.js";
|
|
32
33
|
export * from "./reputation/index.js";
|
|
33
34
|
export * from "./dispute/index.js";
|
|
35
|
+
export * from "./cluster/i-transaction-state-store.js";
|
|
36
|
+
export * from "./cluster/memory-transaction-state-store.js";
|
|
37
|
+
export * from "./cluster/persistent-transaction-state-store.js";
|
|
38
|
+
export * from "./storage/i-kv-store.js";
|
|
39
|
+
export * from "./storage/memory-kv-store.js";
|