@optimystic/db-p2p 0.1.1 → 0.1.3
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 → README.md} +7 -0
- package/dist/index.min.js +31 -30
- package/dist/index.min.js.map +4 -4
- package/dist/src/cluster/cluster-repo.d.ts +27 -0
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +139 -18
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/cluster/service.d.ts +13 -2
- package/dist/src/cluster/service.d.ts.map +1 -1
- package/dist/src/cluster/service.js +17 -7
- package/dist/src/cluster/service.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/libp2p-node.d.ts +13 -2
- package/dist/src/libp2p-node.d.ts.map +1 -1
- package/dist/src/libp2p-node.js +35 -16
- package/dist/src/libp2p-node.js.map +1 -1
- package/dist/src/protocol-client.d.ts.map +1 -1
- package/dist/src/protocol-client.js +8 -7
- package/dist/src/protocol-client.js.map +1 -1
- package/dist/src/repo/cluster-coordinator.d.ts +7 -2
- package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
- package/dist/src/repo/cluster-coordinator.js +18 -3
- package/dist/src/repo/cluster-coordinator.js.map +1 -1
- package/dist/src/repo/coordinator-repo.d.ts +26 -3
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +117 -22
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/repo/service.d.ts +13 -2
- package/dist/src/repo/service.d.ts.map +1 -1
- package/dist/src/repo/service.js +25 -12
- package/dist/src/repo/service.js.map +1 -1
- package/dist/src/storage/memory-storage.d.ts +15 -0
- package/dist/src/storage/memory-storage.d.ts.map +1 -1
- package/dist/src/storage/memory-storage.js +23 -4
- package/dist/src/storage/memory-storage.js.map +1 -1
- package/dist/src/storage/storage-repo.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.js.map +1 -1
- package/dist/src/sync/service.d.ts.map +1 -1
- package/dist/src/sync/service.js +7 -2
- package/dist/src/sync/service.js.map +1 -1
- package/package.json +27 -21
- package/src/cluster/cluster-repo.ts +836 -711
- package/src/cluster/service.ts +44 -31
- package/src/index.ts +1 -1
- package/src/libp2p-key-network.ts +334 -334
- package/src/libp2p-node.ts +371 -339
- package/src/network/network-manager-service.ts +334 -334
- package/src/protocol-client.ts +53 -54
- package/src/repo/client.ts +112 -112
- package/src/repo/cluster-coordinator.ts +613 -592
- package/src/repo/coordinator-repo.ts +269 -137
- package/src/repo/service.ts +237 -219
- package/src/storage/block-storage.ts +182 -182
- package/src/storage/memory-storage.ts +24 -5
- package/src/storage/storage-repo.ts +321 -320
- package/src/sync/service.ts +7 -6
- package/dist/src/storage/file-storage.d.ts +0 -30
- package/dist/src/storage/file-storage.d.ts.map +0 -1
- package/dist/src/storage/file-storage.js +0 -127
- package/dist/src/storage/file-storage.js.map +0 -1
- package/src/storage/file-storage.ts +0 -163
|
@@ -1,334 +1,334 @@
|
|
|
1
|
-
import type { AbortOptions, Libp2p, PeerId, Stream } from "@libp2p/interface";
|
|
2
|
-
import { toString as u8ToString } from 'uint8arrays/to-string'
|
|
3
|
-
import type { ClusterPeers, FindCoordinatorOptions, IKeyNetwork, IPeerNetwork } from "@optimystic/db-core";
|
|
4
|
-
import { peerIdFromString } from '@libp2p/peer-id'
|
|
5
|
-
import { multiaddr } from '@multiformats/multiaddr'
|
|
6
|
-
import type { FretService } from 'p2p-fret'
|
|
7
|
-
import { hashKey } from 'p2p-fret'
|
|
8
|
-
import { createLogger } from './logger.js'
|
|
9
|
-
|
|
10
|
-
interface WithFretService { services?: { fret?: FretService } }
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Configuration options for self-coordination behavior
|
|
14
|
-
*/
|
|
15
|
-
export interface SelfCoordinationConfig {
|
|
16
|
-
/** Time (ms) after last connection before allowing self-coordination. Default: 30000 */
|
|
17
|
-
gracePeriodMs?: number;
|
|
18
|
-
/** Threshold for suspicious network shrinkage (0-1). >50% drop is suspicious. Default: 0.5 */
|
|
19
|
-
shrinkageThreshold?: number;
|
|
20
|
-
/** Allow self-coordination at all. Default: true (for testing). Set false in production. */
|
|
21
|
-
allowSelfCoordination?: boolean;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Decision result from self-coordination guard
|
|
26
|
-
*/
|
|
27
|
-
export interface SelfCoordinationDecision {
|
|
28
|
-
allow: boolean;
|
|
29
|
-
reason: 'bootstrap-node' | 'partition-detected' | 'suspicious-shrinkage' | 'grace-period-not-elapsed' | 'extended-isolation' | 'disabled';
|
|
30
|
-
warn?: boolean;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
|
|
34
|
-
private readonly selfCoordinationConfig: Required<SelfCoordinationConfig>;
|
|
35
|
-
private networkHighWaterMark = 1;
|
|
36
|
-
private lastConnectedTime = Date.now();
|
|
37
|
-
|
|
38
|
-
constructor(
|
|
39
|
-
private readonly libp2p: Libp2p,
|
|
40
|
-
private readonly clusterSize: number = 16,
|
|
41
|
-
selfCoordinationConfig?: SelfCoordinationConfig
|
|
42
|
-
) {
|
|
43
|
-
this.selfCoordinationConfig = {
|
|
44
|
-
gracePeriodMs: selfCoordinationConfig?.gracePeriodMs ?? 30_000,
|
|
45
|
-
shrinkageThreshold: selfCoordinationConfig?.shrinkageThreshold ?? 0.5,
|
|
46
|
-
allowSelfCoordination: selfCoordinationConfig?.allowSelfCoordination ?? true
|
|
47
|
-
};
|
|
48
|
-
this.setupConnectionTracking();
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// coordinator cache: key (base64url) -> peerId until expiry (bounded LRU-ish via Map insertion order)
|
|
52
|
-
private readonly coordinatorCache = new Map<string, { id: PeerId, expires: number }>()
|
|
53
|
-
private static readonly MAX_CACHE_ENTRIES = 1000
|
|
54
|
-
private readonly log = createLogger('libp2p-key-network')
|
|
55
|
-
|
|
56
|
-
private toCacheKey(key: Uint8Array): string { return u8ToString(key, 'base64url') }
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Set up connection event tracking to update high water mark and last connected time.
|
|
60
|
-
*/
|
|
61
|
-
private setupConnectionTracking(): void {
|
|
62
|
-
this.libp2p.addEventListener('connection:open', () => {
|
|
63
|
-
this.updateNetworkObservations();
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Update network high water mark and last connected time.
|
|
69
|
-
* Called on new connections.
|
|
70
|
-
*/
|
|
71
|
-
private updateNetworkObservations(): void {
|
|
72
|
-
const connections = this.libp2p.getConnections?.() ?? [];
|
|
73
|
-
if (connections.length > 0) {
|
|
74
|
-
this.lastConnectedTime = Date.now();
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
try {
|
|
78
|
-
const fret = this.getFret();
|
|
79
|
-
const estimate = fret.getNetworkSizeEstimate();
|
|
80
|
-
if (estimate.size_estimate > this.networkHighWaterMark) {
|
|
81
|
-
this.networkHighWaterMark = estimate.size_estimate;
|
|
82
|
-
this.log('network-hwm-updated mark=%d confidence=%f', this.networkHighWaterMark, estimate.confidence);
|
|
83
|
-
}
|
|
84
|
-
} catch {
|
|
85
|
-
// FRET not available - use connection count as fallback
|
|
86
|
-
const connectionCount = this.libp2p.getConnections?.().length ?? 0;
|
|
87
|
-
const observedSize = connectionCount + 1; // +1 for self
|
|
88
|
-
if (observedSize > this.networkHighWaterMark) {
|
|
89
|
-
this.networkHighWaterMark = observedSize;
|
|
90
|
-
this.log('network-hwm-updated mark=%d (from connections)', this.networkHighWaterMark);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Determine if self-coordination should be allowed based on network observations.
|
|
97
|
-
*
|
|
98
|
-
* Principle: If we've ever seen a larger network, assume our connectivity is the problem,
|
|
99
|
-
* not the network shrinking.
|
|
100
|
-
*/
|
|
101
|
-
shouldAllowSelfCoordination(): SelfCoordinationDecision {
|
|
102
|
-
// Check global disable
|
|
103
|
-
if (!this.selfCoordinationConfig.allowSelfCoordination) {
|
|
104
|
-
return { allow: false, reason: 'disabled' };
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Case 1: New/bootstrap node (never seen larger network)
|
|
108
|
-
if (this.networkHighWaterMark <= 1) {
|
|
109
|
-
return { allow: true, reason: 'bootstrap-node' };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Case 2: Check for partition via FRET
|
|
113
|
-
try {
|
|
114
|
-
const fret = this.getFret();
|
|
115
|
-
if (fret.detectPartition()) {
|
|
116
|
-
this.log('self-coord-blocked: partition-detected');
|
|
117
|
-
return { allow: false, reason: 'partition-detected' };
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Case 3: Suspicious network shrinkage (>threshold drop)
|
|
121
|
-
const estimate = fret.getNetworkSizeEstimate();
|
|
122
|
-
const shrinkage = 1 - (estimate.size_estimate / this.networkHighWaterMark);
|
|
123
|
-
if (shrinkage > this.selfCoordinationConfig.shrinkageThreshold) {
|
|
124
|
-
this.log('self-coord-blocked: suspicious-shrinkage current=%d hwm=%d shrinkage=%f',
|
|
125
|
-
estimate.size_estimate, this.networkHighWaterMark, shrinkage);
|
|
126
|
-
return { allow: false, reason: 'suspicious-shrinkage' };
|
|
127
|
-
}
|
|
128
|
-
} catch {
|
|
129
|
-
// FRET not available - be conservative
|
|
130
|
-
const connections = this.libp2p.getConnections?.() ?? [];
|
|
131
|
-
if (this.networkHighWaterMark > 1 && connections.length === 0) {
|
|
132
|
-
// We've seen peers before but have none now - suspicious
|
|
133
|
-
const timeSinceConnection = Date.now() - this.lastConnectedTime;
|
|
134
|
-
if (timeSinceConnection < this.selfCoordinationConfig.gracePeriodMs) {
|
|
135
|
-
this.log('self-coord-blocked: grace-period-not-elapsed since=%dms', timeSinceConnection);
|
|
136
|
-
return { allow: false, reason: 'grace-period-not-elapsed' };
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Case 4: Recently connected (grace period not elapsed)
|
|
142
|
-
const timeSinceConnection = Date.now() - this.lastConnectedTime;
|
|
143
|
-
if (timeSinceConnection < this.selfCoordinationConfig.gracePeriodMs) {
|
|
144
|
-
const connections = this.libp2p.getConnections?.() ?? [];
|
|
145
|
-
// Only block if we have no connections but did recently
|
|
146
|
-
if (connections.length === 0) {
|
|
147
|
-
this.log('self-coord-blocked: grace-period-not-elapsed since=%dms', timeSinceConnection);
|
|
148
|
-
return { allow: false, reason: 'grace-period-not-elapsed' };
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Case 5: Extended isolation with gradual shrinkage - allow with warning
|
|
153
|
-
this.log('self-coord-allowed: extended-isolation (warn)');
|
|
154
|
-
return { allow: true, reason: 'extended-isolation', warn: true };
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
public recordCoordinator(key: Uint8Array, peerId: PeerId, ttlMs = 30 * 60 * 1000): void {
|
|
158
|
-
const k = this.toCacheKey(key)
|
|
159
|
-
const now = Date.now()
|
|
160
|
-
for (const [ck, entry] of this.coordinatorCache) {
|
|
161
|
-
if (entry.expires <= now) this.coordinatorCache.delete(ck)
|
|
162
|
-
}
|
|
163
|
-
this.coordinatorCache.set(k, { id: peerId, expires: now + ttlMs })
|
|
164
|
-
while (this.coordinatorCache.size > Libp2pKeyPeerNetwork.MAX_CACHE_ENTRIES) {
|
|
165
|
-
const firstKey = this.coordinatorCache.keys().next().value as string | undefined
|
|
166
|
-
if (firstKey == null) break
|
|
167
|
-
this.coordinatorCache.delete(firstKey)
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
private getCachedCoordinator(key: Uint8Array): PeerId | undefined {
|
|
172
|
-
const k = this.toCacheKey(key)
|
|
173
|
-
const hit = this.coordinatorCache.get(k)
|
|
174
|
-
if (hit && hit.expires > Date.now()) return hit.id
|
|
175
|
-
if (hit) this.coordinatorCache.delete(k)
|
|
176
|
-
return undefined
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
connect(peerId: PeerId, protocol: string, _options?: AbortOptions): Promise<Stream> {
|
|
180
|
-
const conns = (this.libp2p as any).getConnections?.(peerId) ?? []
|
|
181
|
-
if (Array.isArray(conns) && conns.length > 0 && typeof conns[0]?.newStream === 'function') {
|
|
182
|
-
return conns[0].newStream([protocol]) as Promise<Stream>
|
|
183
|
-
}
|
|
184
|
-
const dialOptions = { runOnLimitedConnection: true, negotiateFully: false } as const
|
|
185
|
-
return this.libp2p.dialProtocol(peerId, [protocol], dialOptions)
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
private getFret(): FretService {
|
|
189
|
-
const svc = (this.libp2p as unknown as WithFretService).services?.fret
|
|
190
|
-
if (svc == null) throw new Error('FRET service is not registered on this libp2p node')
|
|
191
|
-
return svc
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
private async getNeighborIdsForKey(key: Uint8Array, wants: number): Promise<string[]> {
|
|
195
|
-
const fret = this.getFret()
|
|
196
|
-
const coord = await hashKey(key)
|
|
197
|
-
const both = fret.getNeighbors(coord, 'both', wants)
|
|
198
|
-
return Array.from(new Set(both)).slice(0, wants)
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
async findCoordinator(key: Uint8Array, _options?: Partial<FindCoordinatorOptions>): Promise<PeerId> {
|
|
202
|
-
const excludedSet = new Set<string>((_options?.excludedPeers ?? []).map(p => p.toString()))
|
|
203
|
-
const keyStr = this.toCacheKey(key).substring(0, 12);
|
|
204
|
-
|
|
205
|
-
this.log('findCoordinator:start key=%s excluded=%o', keyStr, Array.from(excludedSet).map(s => s.substring(0, 12)))
|
|
206
|
-
|
|
207
|
-
// honor cache if not excluded
|
|
208
|
-
const cached = this.getCachedCoordinator(key)
|
|
209
|
-
if (cached != null && !excludedSet.has(cached.toString())) {
|
|
210
|
-
this.log('findCoordinator:cached-hit key=%s coordinator=%s', keyStr, cached.toString().substring(0, 12))
|
|
211
|
-
return cached
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// Retry logic: connections can be temporarily down, so retry a few times with delay
|
|
215
|
-
const maxRetries = 3;
|
|
216
|
-
const retryDelayMs = 500;
|
|
217
|
-
|
|
218
|
-
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
219
|
-
// Get currently connected peers for filtering
|
|
220
|
-
const connected = (this.libp2p.getConnections?.() ?? []).map((c: any) => c.remotePeer) as PeerId[]
|
|
221
|
-
const connectedSet = new Set(connected.map(p => p.toString()))
|
|
222
|
-
this.log('findCoordinator:connected-peers key=%s count=%d peers=%o attempt=%d', keyStr, connected.length, connected.map(p => p.toString().substring(0, 12)), attempt)
|
|
223
|
-
|
|
224
|
-
// prefer FRET neighbors that are also connected, pick first non-excluded
|
|
225
|
-
try {
|
|
226
|
-
const ids = await this.getNeighborIdsForKey(key, this.clusterSize)
|
|
227
|
-
this.log('findCoordinator:fret-neighbors key=%s candidates=%o', keyStr, ids.map(s => s.substring(0, 12)))
|
|
228
|
-
|
|
229
|
-
// Filter to only connected FRET neighbors
|
|
230
|
-
const connectedFretIds = ids.filter(id => connectedSet.has(id) || id === this.libp2p.peerId.toString())
|
|
231
|
-
this.log('findCoordinator:fret-connected key=%s count=%d peers=%o', keyStr, connectedFretIds.length, connectedFretIds.map(s => s.substring(0, 12)))
|
|
232
|
-
|
|
233
|
-
const pick = connectedFretIds.find(id => !excludedSet.has(id))
|
|
234
|
-
if (pick) {
|
|
235
|
-
const pid = peerIdFromString(pick)
|
|
236
|
-
this.recordCoordinator(key, pid)
|
|
237
|
-
this.log('findCoordinator:fret-selected key=%s coordinator=%s', keyStr, pick.substring(0, 12))
|
|
238
|
-
return pid
|
|
239
|
-
}
|
|
240
|
-
} catch (err) {
|
|
241
|
-
this.log('findCoordinator getNeighborIdsForKey failed - %o', err)
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// fallback: prefer any existing connected peer that's not excluded
|
|
245
|
-
const connectedPick = connected.find(p => !excludedSet.has(p.toString()))
|
|
246
|
-
if (connectedPick) {
|
|
247
|
-
this.recordCoordinator(key, connectedPick)
|
|
248
|
-
this.log('findCoordinator:connected-fallback key=%s coordinator=%s', keyStr, connectedPick.toString().substring(0, 12))
|
|
249
|
-
return connectedPick
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// If no connections and not the last attempt, wait and retry
|
|
253
|
-
if (connected.length === 0 && attempt < maxRetries - 1) {
|
|
254
|
-
this.log('findCoordinator:no-connections-retry key=%s attempt=%d delay=%dms', keyStr, attempt, retryDelayMs)
|
|
255
|
-
await new Promise(resolve => setTimeout(resolve, retryDelayMs))
|
|
256
|
-
continue
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// last resort: prefer self only if not excluded and guard allows
|
|
261
|
-
const self = this.libp2p.peerId
|
|
262
|
-
if (!excludedSet.has(self.toString())) {
|
|
263
|
-
const decision = this.shouldAllowSelfCoordination();
|
|
264
|
-
if (!decision.allow) {
|
|
265
|
-
this.log('findCoordinator:self-coord-blocked key=%s reason=%s', keyStr, decision.reason);
|
|
266
|
-
throw new Error(`Self-coordination blocked: ${decision.reason}. No coordinator available for key.`);
|
|
267
|
-
}
|
|
268
|
-
if (decision.warn) {
|
|
269
|
-
this.log('findCoordinator:self-selected-warn key=%s coordinator=%s reason=%s',
|
|
270
|
-
keyStr, self.toString().substring(0, 12), decision.reason);
|
|
271
|
-
} else {
|
|
272
|
-
this.log('findCoordinator:self-selected key=%s coordinator=%s reason=%s',
|
|
273
|
-
keyStr, self.toString().substring(0, 12), decision.reason);
|
|
274
|
-
}
|
|
275
|
-
return self
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
this.log('findCoordinator:all-excluded key=%s self=%s', keyStr, self.toString().substring(0, 12))
|
|
279
|
-
throw new Error('No coordinator available for key (all candidates excluded)')
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
private getConnectedAddrsByPeer(): Record<string, string[]> {
|
|
283
|
-
const conns = this.libp2p.getConnections()
|
|
284
|
-
const byPeer: Record<string, string[]> = {}
|
|
285
|
-
for (const c of conns) {
|
|
286
|
-
const id = c.remotePeer.toString()
|
|
287
|
-
const addr = c.remoteAddr?.toString?.()
|
|
288
|
-
if (addr) (byPeer[id] ??= []).push(addr)
|
|
289
|
-
}
|
|
290
|
-
return byPeer
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
private parseMultiaddrs(addrs: string[]): ReturnType<typeof multiaddr>[] {
|
|
294
|
-
const out: ReturnType<typeof multiaddr>[] = []
|
|
295
|
-
for (const a of addrs) {
|
|
296
|
-
try { out.push(multiaddr(a)) } catch (err) { console.warn('invalid multiaddr from connection', a, err) }
|
|
297
|
-
}
|
|
298
|
-
return out
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
async findCluster(key: Uint8Array): Promise<ClusterPeers> {
|
|
302
|
-
const fret = this.getFret()
|
|
303
|
-
const coord = await hashKey(key)
|
|
304
|
-
const cohort = fret.assembleCohort(coord, this.clusterSize)
|
|
305
|
-
const keyStr = this.toCacheKey(key).substring(0, 12);
|
|
306
|
-
|
|
307
|
-
// Include self in the cohort
|
|
308
|
-
const ids = Array.from(new Set([...cohort, this.libp2p.peerId.toString()]))
|
|
309
|
-
|
|
310
|
-
const connectedByPeer = this.getConnectedAddrsByPeer()
|
|
311
|
-
const connectedPeerIds = Object.keys(connectedByPeer)
|
|
312
|
-
|
|
313
|
-
this.log('findCluster key=%s fretCohort=%d connected=%d cohortPeers=%o',
|
|
314
|
-
keyStr, cohort.length, connectedPeerIds.length, ids.map(s => s.substring(0, 12)))
|
|
315
|
-
|
|
316
|
-
const peers: ClusterPeers = {}
|
|
317
|
-
|
|
318
|
-
for (const idStr of ids) {
|
|
319
|
-
if (idStr === this.libp2p.peerId.toString()) {
|
|
320
|
-
peers[idStr] = { multiaddrs: this.libp2p.getMultiaddrs(), publicKey: this.libp2p.peerId.publicKey?.raw ?? new Uint8Array() }
|
|
321
|
-
continue
|
|
322
|
-
}
|
|
323
|
-
const strings = connectedByPeer[idStr] ?? []
|
|
324
|
-
const addrs = this.parseMultiaddrs(strings)
|
|
325
|
-
peers[idStr] = { multiaddrs: addrs, publicKey: new Uint8Array() }
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
this.log('findCluster:result key=%s clusterSize=%d withAddrs=%d connectedInCohort=%d',
|
|
329
|
-
keyStr, Object.keys(peers).length,
|
|
330
|
-
Object.values(peers).filter(p => p.multiaddrs.length > 0).length,
|
|
331
|
-
ids.filter(id => connectedPeerIds.includes(id) || id === this.libp2p.peerId.toString()).length)
|
|
332
|
-
return peers
|
|
333
|
-
}
|
|
334
|
-
}
|
|
1
|
+
import type { AbortOptions, Libp2p, PeerId, Stream } from "@libp2p/interface";
|
|
2
|
+
import { toString as u8ToString } from 'uint8arrays/to-string'
|
|
3
|
+
import type { ClusterPeers, FindCoordinatorOptions, IKeyNetwork, IPeerNetwork } from "@optimystic/db-core";
|
|
4
|
+
import { peerIdFromString } from '@libp2p/peer-id'
|
|
5
|
+
import { multiaddr } from '@multiformats/multiaddr'
|
|
6
|
+
import type { FretService } from 'p2p-fret'
|
|
7
|
+
import { hashKey } from 'p2p-fret'
|
|
8
|
+
import { createLogger } from './logger.js'
|
|
9
|
+
|
|
10
|
+
interface WithFretService { services?: { fret?: FretService } }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Configuration options for self-coordination behavior
|
|
14
|
+
*/
|
|
15
|
+
export interface SelfCoordinationConfig {
|
|
16
|
+
/** Time (ms) after last connection before allowing self-coordination. Default: 30000 */
|
|
17
|
+
gracePeriodMs?: number;
|
|
18
|
+
/** Threshold for suspicious network shrinkage (0-1). >50% drop is suspicious. Default: 0.5 */
|
|
19
|
+
shrinkageThreshold?: number;
|
|
20
|
+
/** Allow self-coordination at all. Default: true (for testing). Set false in production. */
|
|
21
|
+
allowSelfCoordination?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Decision result from self-coordination guard
|
|
26
|
+
*/
|
|
27
|
+
export interface SelfCoordinationDecision {
|
|
28
|
+
allow: boolean;
|
|
29
|
+
reason: 'bootstrap-node' | 'partition-detected' | 'suspicious-shrinkage' | 'grace-period-not-elapsed' | 'extended-isolation' | 'disabled';
|
|
30
|
+
warn?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
|
|
34
|
+
private readonly selfCoordinationConfig: Required<SelfCoordinationConfig>;
|
|
35
|
+
private networkHighWaterMark = 1;
|
|
36
|
+
private lastConnectedTime = Date.now();
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
private readonly libp2p: Libp2p,
|
|
40
|
+
private readonly clusterSize: number = 16,
|
|
41
|
+
selfCoordinationConfig?: SelfCoordinationConfig
|
|
42
|
+
) {
|
|
43
|
+
this.selfCoordinationConfig = {
|
|
44
|
+
gracePeriodMs: selfCoordinationConfig?.gracePeriodMs ?? 30_000,
|
|
45
|
+
shrinkageThreshold: selfCoordinationConfig?.shrinkageThreshold ?? 0.5,
|
|
46
|
+
allowSelfCoordination: selfCoordinationConfig?.allowSelfCoordination ?? true
|
|
47
|
+
};
|
|
48
|
+
this.setupConnectionTracking();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// coordinator cache: key (base64url) -> peerId until expiry (bounded LRU-ish via Map insertion order)
|
|
52
|
+
private readonly coordinatorCache = new Map<string, { id: PeerId, expires: number }>()
|
|
53
|
+
private static readonly MAX_CACHE_ENTRIES = 1000
|
|
54
|
+
private readonly log = createLogger('libp2p-key-network')
|
|
55
|
+
|
|
56
|
+
private toCacheKey(key: Uint8Array): string { return u8ToString(key, 'base64url') }
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Set up connection event tracking to update high water mark and last connected time.
|
|
60
|
+
*/
|
|
61
|
+
private setupConnectionTracking(): void {
|
|
62
|
+
this.libp2p.addEventListener('connection:open', () => {
|
|
63
|
+
this.updateNetworkObservations();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Update network high water mark and last connected time.
|
|
69
|
+
* Called on new connections.
|
|
70
|
+
*/
|
|
71
|
+
private updateNetworkObservations(): void {
|
|
72
|
+
const connections = this.libp2p.getConnections?.() ?? [];
|
|
73
|
+
if (connections.length > 0) {
|
|
74
|
+
this.lastConnectedTime = Date.now();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const fret = this.getFret();
|
|
79
|
+
const estimate = fret.getNetworkSizeEstimate();
|
|
80
|
+
if (estimate.size_estimate > this.networkHighWaterMark) {
|
|
81
|
+
this.networkHighWaterMark = estimate.size_estimate;
|
|
82
|
+
this.log('network-hwm-updated mark=%d confidence=%f', this.networkHighWaterMark, estimate.confidence);
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// FRET not available - use connection count as fallback
|
|
86
|
+
const connectionCount = this.libp2p.getConnections?.().length ?? 0;
|
|
87
|
+
const observedSize = connectionCount + 1; // +1 for self
|
|
88
|
+
if (observedSize > this.networkHighWaterMark) {
|
|
89
|
+
this.networkHighWaterMark = observedSize;
|
|
90
|
+
this.log('network-hwm-updated mark=%d (from connections)', this.networkHighWaterMark);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Determine if self-coordination should be allowed based on network observations.
|
|
97
|
+
*
|
|
98
|
+
* Principle: If we've ever seen a larger network, assume our connectivity is the problem,
|
|
99
|
+
* not the network shrinking.
|
|
100
|
+
*/
|
|
101
|
+
shouldAllowSelfCoordination(): SelfCoordinationDecision {
|
|
102
|
+
// Check global disable
|
|
103
|
+
if (!this.selfCoordinationConfig.allowSelfCoordination) {
|
|
104
|
+
return { allow: false, reason: 'disabled' };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Case 1: New/bootstrap node (never seen larger network)
|
|
108
|
+
if (this.networkHighWaterMark <= 1) {
|
|
109
|
+
return { allow: true, reason: 'bootstrap-node' };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Case 2: Check for partition via FRET
|
|
113
|
+
try {
|
|
114
|
+
const fret = this.getFret();
|
|
115
|
+
if (fret.detectPartition()) {
|
|
116
|
+
this.log('self-coord-blocked: partition-detected');
|
|
117
|
+
return { allow: false, reason: 'partition-detected' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Case 3: Suspicious network shrinkage (>threshold drop)
|
|
121
|
+
const estimate = fret.getNetworkSizeEstimate();
|
|
122
|
+
const shrinkage = 1 - (estimate.size_estimate / this.networkHighWaterMark);
|
|
123
|
+
if (shrinkage > this.selfCoordinationConfig.shrinkageThreshold) {
|
|
124
|
+
this.log('self-coord-blocked: suspicious-shrinkage current=%d hwm=%d shrinkage=%f',
|
|
125
|
+
estimate.size_estimate, this.networkHighWaterMark, shrinkage);
|
|
126
|
+
return { allow: false, reason: 'suspicious-shrinkage' };
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
// FRET not available - be conservative
|
|
130
|
+
const connections = this.libp2p.getConnections?.() ?? [];
|
|
131
|
+
if (this.networkHighWaterMark > 1 && connections.length === 0) {
|
|
132
|
+
// We've seen peers before but have none now - suspicious
|
|
133
|
+
const timeSinceConnection = Date.now() - this.lastConnectedTime;
|
|
134
|
+
if (timeSinceConnection < this.selfCoordinationConfig.gracePeriodMs) {
|
|
135
|
+
this.log('self-coord-blocked: grace-period-not-elapsed since=%dms', timeSinceConnection);
|
|
136
|
+
return { allow: false, reason: 'grace-period-not-elapsed' };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Case 4: Recently connected (grace period not elapsed)
|
|
142
|
+
const timeSinceConnection = Date.now() - this.lastConnectedTime;
|
|
143
|
+
if (timeSinceConnection < this.selfCoordinationConfig.gracePeriodMs) {
|
|
144
|
+
const connections = this.libp2p.getConnections?.() ?? [];
|
|
145
|
+
// Only block if we have no connections but did recently
|
|
146
|
+
if (connections.length === 0) {
|
|
147
|
+
this.log('self-coord-blocked: grace-period-not-elapsed since=%dms', timeSinceConnection);
|
|
148
|
+
return { allow: false, reason: 'grace-period-not-elapsed' };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Case 5: Extended isolation with gradual shrinkage - allow with warning
|
|
153
|
+
this.log('self-coord-allowed: extended-isolation (warn)');
|
|
154
|
+
return { allow: true, reason: 'extended-isolation', warn: true };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
public recordCoordinator(key: Uint8Array, peerId: PeerId, ttlMs = 30 * 60 * 1000): void {
|
|
158
|
+
const k = this.toCacheKey(key)
|
|
159
|
+
const now = Date.now()
|
|
160
|
+
for (const [ck, entry] of this.coordinatorCache) {
|
|
161
|
+
if (entry.expires <= now) this.coordinatorCache.delete(ck)
|
|
162
|
+
}
|
|
163
|
+
this.coordinatorCache.set(k, { id: peerId, expires: now + ttlMs })
|
|
164
|
+
while (this.coordinatorCache.size > Libp2pKeyPeerNetwork.MAX_CACHE_ENTRIES) {
|
|
165
|
+
const firstKey = this.coordinatorCache.keys().next().value as string | undefined
|
|
166
|
+
if (firstKey == null) break
|
|
167
|
+
this.coordinatorCache.delete(firstKey)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private getCachedCoordinator(key: Uint8Array): PeerId | undefined {
|
|
172
|
+
const k = this.toCacheKey(key)
|
|
173
|
+
const hit = this.coordinatorCache.get(k)
|
|
174
|
+
if (hit && hit.expires > Date.now()) return hit.id
|
|
175
|
+
if (hit) this.coordinatorCache.delete(k)
|
|
176
|
+
return undefined
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
connect(peerId: PeerId, protocol: string, _options?: AbortOptions): Promise<Stream> {
|
|
180
|
+
const conns = (this.libp2p as any).getConnections?.(peerId) ?? []
|
|
181
|
+
if (Array.isArray(conns) && conns.length > 0 && typeof conns[0]?.newStream === 'function') {
|
|
182
|
+
return conns[0].newStream([protocol]) as Promise<Stream>
|
|
183
|
+
}
|
|
184
|
+
const dialOptions = { runOnLimitedConnection: true, negotiateFully: false } as const
|
|
185
|
+
return this.libp2p.dialProtocol(peerId, [protocol], dialOptions)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private getFret(): FretService {
|
|
189
|
+
const svc = (this.libp2p as unknown as WithFretService).services?.fret
|
|
190
|
+
if (svc == null) throw new Error('FRET service is not registered on this libp2p node')
|
|
191
|
+
return svc
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private async getNeighborIdsForKey(key: Uint8Array, wants: number): Promise<string[]> {
|
|
195
|
+
const fret = this.getFret()
|
|
196
|
+
const coord = await hashKey(key)
|
|
197
|
+
const both = fret.getNeighbors(coord, 'both', wants)
|
|
198
|
+
return Array.from(new Set(both)).slice(0, wants)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async findCoordinator(key: Uint8Array, _options?: Partial<FindCoordinatorOptions>): Promise<PeerId> {
|
|
202
|
+
const excludedSet = new Set<string>((_options?.excludedPeers ?? []).map(p => p.toString()))
|
|
203
|
+
const keyStr = this.toCacheKey(key).substring(0, 12);
|
|
204
|
+
|
|
205
|
+
this.log('findCoordinator:start key=%s excluded=%o', keyStr, Array.from(excludedSet).map(s => s.substring(0, 12)))
|
|
206
|
+
|
|
207
|
+
// honor cache if not excluded
|
|
208
|
+
const cached = this.getCachedCoordinator(key)
|
|
209
|
+
if (cached != null && !excludedSet.has(cached.toString())) {
|
|
210
|
+
this.log('findCoordinator:cached-hit key=%s coordinator=%s', keyStr, cached.toString().substring(0, 12))
|
|
211
|
+
return cached
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Retry logic: connections can be temporarily down, so retry a few times with delay
|
|
215
|
+
const maxRetries = 3;
|
|
216
|
+
const retryDelayMs = 500;
|
|
217
|
+
|
|
218
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
219
|
+
// Get currently connected peers for filtering
|
|
220
|
+
const connected = (this.libp2p.getConnections?.() ?? []).map((c: any) => c.remotePeer) as PeerId[]
|
|
221
|
+
const connectedSet = new Set(connected.map(p => p.toString()))
|
|
222
|
+
this.log('findCoordinator:connected-peers key=%s count=%d peers=%o attempt=%d', keyStr, connected.length, connected.map(p => p.toString().substring(0, 12)), attempt)
|
|
223
|
+
|
|
224
|
+
// prefer FRET neighbors that are also connected, pick first non-excluded
|
|
225
|
+
try {
|
|
226
|
+
const ids = await this.getNeighborIdsForKey(key, this.clusterSize)
|
|
227
|
+
this.log('findCoordinator:fret-neighbors key=%s candidates=%o', keyStr, ids.map(s => s.substring(0, 12)))
|
|
228
|
+
|
|
229
|
+
// Filter to only connected FRET neighbors
|
|
230
|
+
const connectedFretIds = ids.filter(id => connectedSet.has(id) || id === this.libp2p.peerId.toString())
|
|
231
|
+
this.log('findCoordinator:fret-connected key=%s count=%d peers=%o', keyStr, connectedFretIds.length, connectedFretIds.map(s => s.substring(0, 12)))
|
|
232
|
+
|
|
233
|
+
const pick = connectedFretIds.find(id => !excludedSet.has(id))
|
|
234
|
+
if (pick) {
|
|
235
|
+
const pid = peerIdFromString(pick)
|
|
236
|
+
this.recordCoordinator(key, pid)
|
|
237
|
+
this.log('findCoordinator:fret-selected key=%s coordinator=%s', keyStr, pick.substring(0, 12))
|
|
238
|
+
return pid
|
|
239
|
+
}
|
|
240
|
+
} catch (err) {
|
|
241
|
+
this.log('findCoordinator getNeighborIdsForKey failed - %o', err)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// fallback: prefer any existing connected peer that's not excluded
|
|
245
|
+
const connectedPick = connected.find(p => !excludedSet.has(p.toString()))
|
|
246
|
+
if (connectedPick) {
|
|
247
|
+
this.recordCoordinator(key, connectedPick)
|
|
248
|
+
this.log('findCoordinator:connected-fallback key=%s coordinator=%s', keyStr, connectedPick.toString().substring(0, 12))
|
|
249
|
+
return connectedPick
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// If no connections and not the last attempt, wait and retry
|
|
253
|
+
if (connected.length === 0 && attempt < maxRetries - 1) {
|
|
254
|
+
this.log('findCoordinator:no-connections-retry key=%s attempt=%d delay=%dms', keyStr, attempt, retryDelayMs)
|
|
255
|
+
await new Promise(resolve => setTimeout(resolve, retryDelayMs))
|
|
256
|
+
continue
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// last resort: prefer self only if not excluded and guard allows
|
|
261
|
+
const self = this.libp2p.peerId
|
|
262
|
+
if (!excludedSet.has(self.toString())) {
|
|
263
|
+
const decision = this.shouldAllowSelfCoordination();
|
|
264
|
+
if (!decision.allow) {
|
|
265
|
+
this.log('findCoordinator:self-coord-blocked key=%s reason=%s', keyStr, decision.reason);
|
|
266
|
+
throw new Error(`Self-coordination blocked: ${decision.reason}. No coordinator available for key.`);
|
|
267
|
+
}
|
|
268
|
+
if (decision.warn) {
|
|
269
|
+
this.log('findCoordinator:self-selected-warn key=%s coordinator=%s reason=%s',
|
|
270
|
+
keyStr, self.toString().substring(0, 12), decision.reason);
|
|
271
|
+
} else {
|
|
272
|
+
this.log('findCoordinator:self-selected key=%s coordinator=%s reason=%s',
|
|
273
|
+
keyStr, self.toString().substring(0, 12), decision.reason);
|
|
274
|
+
}
|
|
275
|
+
return self
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
this.log('findCoordinator:all-excluded key=%s self=%s', keyStr, self.toString().substring(0, 12))
|
|
279
|
+
throw new Error('No coordinator available for key (all candidates excluded)')
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private getConnectedAddrsByPeer(): Record<string, string[]> {
|
|
283
|
+
const conns = this.libp2p.getConnections()
|
|
284
|
+
const byPeer: Record<string, string[]> = {}
|
|
285
|
+
for (const c of conns) {
|
|
286
|
+
const id = c.remotePeer.toString()
|
|
287
|
+
const addr = c.remoteAddr?.toString?.()
|
|
288
|
+
if (addr) (byPeer[id] ??= []).push(addr)
|
|
289
|
+
}
|
|
290
|
+
return byPeer
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private parseMultiaddrs(addrs: string[]): ReturnType<typeof multiaddr>[] {
|
|
294
|
+
const out: ReturnType<typeof multiaddr>[] = []
|
|
295
|
+
for (const a of addrs) {
|
|
296
|
+
try { out.push(multiaddr(a)) } catch (err) { console.warn('invalid multiaddr from connection', a, err) }
|
|
297
|
+
}
|
|
298
|
+
return out
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async findCluster(key: Uint8Array): Promise<ClusterPeers> {
|
|
302
|
+
const fret = this.getFret()
|
|
303
|
+
const coord = await hashKey(key)
|
|
304
|
+
const cohort = fret.assembleCohort(coord, this.clusterSize)
|
|
305
|
+
const keyStr = this.toCacheKey(key).substring(0, 12);
|
|
306
|
+
|
|
307
|
+
// Include self in the cohort
|
|
308
|
+
const ids = Array.from(new Set([...cohort, this.libp2p.peerId.toString()]))
|
|
309
|
+
|
|
310
|
+
const connectedByPeer = this.getConnectedAddrsByPeer()
|
|
311
|
+
const connectedPeerIds = Object.keys(connectedByPeer)
|
|
312
|
+
|
|
313
|
+
this.log('findCluster key=%s fretCohort=%d connected=%d cohortPeers=%o',
|
|
314
|
+
keyStr, cohort.length, connectedPeerIds.length, ids.map(s => s.substring(0, 12)))
|
|
315
|
+
|
|
316
|
+
const peers: ClusterPeers = {}
|
|
317
|
+
|
|
318
|
+
for (const idStr of ids) {
|
|
319
|
+
if (idStr === this.libp2p.peerId.toString()) {
|
|
320
|
+
peers[idStr] = { multiaddrs: this.libp2p.getMultiaddrs(), publicKey: this.libp2p.peerId.publicKey?.raw ?? new Uint8Array() }
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
323
|
+
const strings = connectedByPeer[idStr] ?? []
|
|
324
|
+
const addrs = this.parseMultiaddrs(strings)
|
|
325
|
+
peers[idStr] = { multiaddrs: addrs, publicKey: new Uint8Array() }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
this.log('findCluster:result key=%s clusterSize=%d withAddrs=%d connectedInCohort=%d',
|
|
329
|
+
keyStr, Object.keys(peers).length,
|
|
330
|
+
Object.values(peers).filter(p => p.multiaddrs.length > 0).length,
|
|
331
|
+
ids.filter(id => connectedPeerIds.includes(id) || id === this.libp2p.peerId.toString()).length)
|
|
332
|
+
return peers
|
|
333
|
+
}
|
|
334
|
+
}
|