@optimystic/db-p2p 1.0.0-beta.3 → 1.1.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 (234) hide show
  1. package/dist/src/cluster/block-transfer-service.d.ts +73 -1
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  3. package/dist/src/cluster/block-transfer-service.js +70 -3
  4. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  5. package/dist/src/cluster/block-transfer.d.ts +16 -1
  6. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  7. package/dist/src/cluster/block-transfer.js +73 -72
  8. package/dist/src/cluster/block-transfer.js.map +1 -1
  9. package/dist/src/cluster/client.d.ts +4 -6
  10. package/dist/src/cluster/client.d.ts.map +1 -1
  11. package/dist/src/cluster/client.js +8 -13
  12. package/dist/src/cluster/client.js.map +1 -1
  13. package/dist/src/cluster/cluster-policy.d.ts +2 -1
  14. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  15. package/dist/src/cluster/cluster-policy.js.map +1 -1
  16. package/dist/src/cluster/cluster-repo.d.ts +155 -9
  17. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  18. package/dist/src/cluster/cluster-repo.js +446 -58
  19. package/dist/src/cluster/cluster-repo.js.map +1 -1
  20. package/dist/src/cluster/commit-proof.js +2 -2
  21. package/dist/src/cluster/race-resolution.d.ts +51 -7
  22. package/dist/src/cluster/race-resolution.d.ts.map +1 -1
  23. package/dist/src/cluster/race-resolution.js +71 -7
  24. package/dist/src/cluster/race-resolution.js.map +1 -1
  25. package/dist/src/cluster/rebalance-monitor.d.ts +67 -5
  26. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  27. package/dist/src/cluster/rebalance-monitor.js +120 -26
  28. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  29. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  30. package/dist/src/cluster/spread-on-churn.js +34 -47
  31. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  32. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  33. package/dist/src/cohort-topic/host.js +8 -6
  34. package/dist/src/cohort-topic/host.js.map +1 -1
  35. package/dist/src/cohort-topic/membership-source.js +1 -1
  36. package/dist/src/cohort-topic/membership-source.js.map +1 -1
  37. package/dist/src/cohort-topic/peer-codec.d.ts.map +1 -1
  38. package/dist/src/cohort-topic/peer-codec.js +5 -2
  39. package/dist/src/cohort-topic/peer-codec.js.map +1 -1
  40. package/dist/src/cohort-topic/stream-util.d.ts +24 -3
  41. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  42. package/dist/src/cohort-topic/stream-util.js +36 -3
  43. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  44. package/dist/src/cohort-topic/topic-router.d.ts +9 -0
  45. package/dist/src/cohort-topic/topic-router.d.ts.map +1 -1
  46. package/dist/src/cohort-topic/topic-router.js +12 -2
  47. package/dist/src/cohort-topic/topic-router.js.map +1 -1
  48. package/dist/src/dispute/client.d.ts.map +1 -1
  49. package/dist/src/dispute/client.js +15 -3
  50. package/dist/src/dispute/client.js.map +1 -1
  51. package/dist/src/index.d.ts +5 -0
  52. package/dist/src/index.d.ts.map +1 -1
  53. package/dist/src/index.js +5 -0
  54. package/dist/src/index.js.map +1 -1
  55. package/dist/src/libp2p-key-network.d.ts +78 -17
  56. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  57. package/dist/src/libp2p-key-network.js +234 -147
  58. package/dist/src/libp2p-key-network.js.map +1 -1
  59. package/dist/src/libp2p-node-base.d.ts +35 -1
  60. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  61. package/dist/src/libp2p-node-base.js +151 -26
  62. package/dist/src/libp2p-node-base.js.map +1 -1
  63. package/dist/src/libp2p-node-rn.d.ts +2 -2
  64. package/dist/src/libp2p-node-rn.d.ts.map +1 -1
  65. package/dist/src/libp2p-node-rn.js.map +1 -1
  66. package/dist/src/libp2p-node.d.ts +2 -2
  67. package/dist/src/libp2p-node.d.ts.map +1 -1
  68. package/dist/src/libp2p-node.js.map +1 -1
  69. package/dist/src/logger.d.ts.map +1 -1
  70. package/dist/src/logger.js +6 -1
  71. package/dist/src/logger.js.map +1 -1
  72. package/dist/src/matchmaking/query-transport.d.ts +3 -4
  73. package/dist/src/matchmaking/query-transport.d.ts.map +1 -1
  74. package/dist/src/matchmaking/query-transport.js +9 -9
  75. package/dist/src/matchmaking/query-transport.js.map +1 -1
  76. package/dist/src/network/network-manager-service.d.ts +10 -5
  77. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  78. package/dist/src/network/network-manager-service.js +11 -2
  79. package/dist/src/network/network-manager-service.js.map +1 -1
  80. package/dist/src/network/relay-reservation.d.ts +170 -0
  81. package/dist/src/network/relay-reservation.d.ts.map +1 -0
  82. package/dist/src/network/relay-reservation.js +527 -0
  83. package/dist/src/network/relay-reservation.js.map +1 -0
  84. package/dist/src/pend-validation.d.ts +3 -2
  85. package/dist/src/pend-validation.d.ts.map +1 -1
  86. package/dist/src/pend-validation.js +3 -2
  87. package/dist/src/pend-validation.js.map +1 -1
  88. package/dist/src/protocol-client.d.ts +8 -1
  89. package/dist/src/protocol-client.d.ts.map +1 -1
  90. package/dist/src/protocol-client.js +11 -0
  91. package/dist/src/protocol-client.js.map +1 -1
  92. package/dist/src/reactivity/forwarder-host.d.ts +2 -1
  93. package/dist/src/reactivity/forwarder-host.d.ts.map +1 -1
  94. package/dist/src/reactivity/forwarder-host.js +2 -1
  95. package/dist/src/reactivity/forwarder-host.js.map +1 -1
  96. package/dist/src/reactivity/origination-manager.d.ts +3 -3
  97. package/dist/src/reactivity/origination-manager.js +3 -3
  98. package/dist/src/reactivity/recover-transport.d.ts +22 -14
  99. package/dist/src/reactivity/recover-transport.d.ts.map +1 -1
  100. package/dist/src/reactivity/recover-transport.js +32 -19
  101. package/dist/src/reactivity/recover-transport.js.map +1 -1
  102. package/dist/src/reactivity/subscription-manager.d.ts +2 -2
  103. package/dist/src/reactivity/topic-bytes.d.ts +6 -7
  104. package/dist/src/reactivity/topic-bytes.d.ts.map +1 -1
  105. package/dist/src/reactivity/topic-bytes.js +8 -9
  106. package/dist/src/reactivity/topic-bytes.js.map +1 -1
  107. package/dist/src/repo/client.d.ts +5 -2
  108. package/dist/src/repo/client.d.ts.map +1 -1
  109. package/dist/src/repo/client.js +49 -40
  110. package/dist/src/repo/client.js.map +1 -1
  111. package/dist/src/repo/cluster-coordinator.d.ts +96 -11
  112. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  113. package/dist/src/repo/cluster-coordinator.js +164 -25
  114. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  115. package/dist/src/repo/coordinator-repo.d.ts +240 -122
  116. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  117. package/dist/src/repo/coordinator-repo.js +627 -473
  118. package/dist/src/repo/coordinator-repo.js.map +1 -1
  119. package/dist/src/repo/i-under-replication-ledger.d.ts +87 -0
  120. package/dist/src/repo/i-under-replication-ledger.d.ts.map +1 -0
  121. package/dist/src/repo/i-under-replication-ledger.js +2 -0
  122. package/dist/src/repo/i-under-replication-ledger.js.map +1 -0
  123. package/dist/src/repo/kv-under-replication-ledger.d.ts +91 -0
  124. package/dist/src/repo/kv-under-replication-ledger.d.ts.map +1 -0
  125. package/dist/src/repo/kv-under-replication-ledger.js +287 -0
  126. package/dist/src/repo/kv-under-replication-ledger.js.map +1 -0
  127. package/dist/src/repo/responsibility.d.ts +37 -0
  128. package/dist/src/repo/responsibility.d.ts.map +1 -0
  129. package/dist/src/repo/responsibility.js +37 -0
  130. package/dist/src/repo/responsibility.js.map +1 -0
  131. package/dist/src/repo/service.d.ts +44 -15
  132. package/dist/src/repo/service.d.ts.map +1 -1
  133. package/dist/src/repo/service.js +64 -27
  134. package/dist/src/repo/service.js.map +1 -1
  135. package/dist/src/repo/stuck-reservation.d.ts +127 -0
  136. package/dist/src/repo/stuck-reservation.d.ts.map +1 -0
  137. package/dist/src/repo/stuck-reservation.js +179 -0
  138. package/dist/src/repo/stuck-reservation.js.map +1 -0
  139. package/dist/src/repo/under-replication-drain.d.ts +208 -0
  140. package/dist/src/repo/under-replication-drain.d.ts.map +1 -0
  141. package/dist/src/repo/under-replication-drain.js +461 -0
  142. package/dist/src/repo/under-replication-drain.js.map +1 -0
  143. package/dist/src/rn.d.ts +5 -0
  144. package/dist/src/rn.d.ts.map +1 -1
  145. package/dist/src/rn.js +5 -0
  146. package/dist/src/rn.js.map +1 -1
  147. package/dist/src/storage/block-latch.d.ts +3 -2
  148. package/dist/src/storage/block-latch.d.ts.map +1 -1
  149. package/dist/src/storage/block-latch.js +7 -4
  150. package/dist/src/storage/block-latch.js.map +1 -1
  151. package/dist/src/storage/block-storage.d.ts +45 -3
  152. package/dist/src/storage/block-storage.d.ts.map +1 -1
  153. package/dist/src/storage/block-storage.js +156 -9
  154. package/dist/src/storage/block-storage.js.map +1 -1
  155. package/dist/src/storage/i-block-storage.d.ts +57 -5
  156. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  157. package/dist/src/storage/i-block-storage.js.map +1 -1
  158. package/dist/src/storage/pending-claim.d.ts +167 -0
  159. package/dist/src/storage/pending-claim.d.ts.map +1 -0
  160. package/dist/src/storage/pending-claim.js +156 -0
  161. package/dist/src/storage/pending-claim.js.map +1 -0
  162. package/dist/src/storage/raw-store-codec.d.ts +13 -0
  163. package/dist/src/storage/raw-store-codec.d.ts.map +1 -1
  164. package/dist/src/storage/raw-store-codec.js +24 -3
  165. package/dist/src/storage/raw-store-codec.js.map +1 -1
  166. package/dist/src/storage/restoration-coordinator.d.ts.map +1 -1
  167. package/dist/src/storage/restoration-coordinator.js +2 -1
  168. package/dist/src/storage/restoration-coordinator.js.map +1 -1
  169. package/dist/src/storage/ring-shift-coordinator.d.ts.map +1 -1
  170. package/dist/src/storage/ring-shift-coordinator.js +3 -3
  171. package/dist/src/storage/ring-shift-coordinator.js.map +1 -1
  172. package/dist/src/storage/storage-repo.d.ts +122 -5
  173. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  174. package/dist/src/storage/storage-repo.js +245 -68
  175. package/dist/src/storage/storage-repo.js.map +1 -1
  176. package/dist/src/storage/struct.d.ts +58 -0
  177. package/dist/src/storage/struct.d.ts.map +1 -1
  178. package/dist/src/testing/mesh-harness.d.ts +77 -11
  179. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  180. package/dist/src/testing/mesh-harness.js +226 -100
  181. package/dist/src/testing/mesh-harness.js.map +1 -1
  182. package/package.json +2 -2
  183. package/readme.md +27 -3
  184. package/src/cluster/block-transfer-service.ts +132 -4
  185. package/src/cluster/block-transfer.ts +79 -84
  186. package/src/cluster/client.ts +115 -119
  187. package/src/cluster/cluster-policy.ts +2 -1
  188. package/src/cluster/cluster-repo.ts +493 -65
  189. package/src/cluster/commit-proof.ts +2 -2
  190. package/src/cluster/race-resolution.ts +76 -7
  191. package/src/cluster/rebalance-monitor.ts +677 -539
  192. package/src/cluster/spread-on-churn.ts +34 -54
  193. package/src/cohort-topic/host.ts +8 -6
  194. package/src/cohort-topic/membership-source.ts +1 -1
  195. package/src/cohort-topic/peer-codec.ts +34 -31
  196. package/src/cohort-topic/stream-util.ts +39 -4
  197. package/src/cohort-topic/topic-router.ts +12 -2
  198. package/src/dispute/client.ts +17 -7
  199. package/src/index.ts +64 -59
  200. package/src/libp2p-key-network.ts +1377 -1260
  201. package/src/libp2p-node-base.ts +186 -27
  202. package/src/libp2p-node-rn.ts +31 -30
  203. package/src/libp2p-node.ts +37 -36
  204. package/src/logger.ts +6 -1
  205. package/src/matchmaking/query-transport.ts +9 -9
  206. package/src/network/network-manager-service.ts +16 -5
  207. package/src/network/relay-reservation.ts +636 -0
  208. package/src/pend-validation.ts +3 -2
  209. package/src/protocol-client.ts +13 -1
  210. package/src/reactivity/forwarder-host.ts +2 -1
  211. package/src/reactivity/origination-manager.ts +3 -3
  212. package/src/reactivity/recover-transport.ts +39 -22
  213. package/src/reactivity/subscription-manager.ts +2 -2
  214. package/src/reactivity/topic-bytes.ts +35 -37
  215. package/src/repo/client.ts +172 -173
  216. package/src/repo/cluster-coordinator.ts +183 -25
  217. package/src/repo/coordinator-repo.ts +656 -506
  218. package/src/repo/i-under-replication-ledger.ts +89 -0
  219. package/src/repo/kv-under-replication-ledger.ts +302 -0
  220. package/src/repo/responsibility.ts +48 -0
  221. package/src/repo/service.ts +76 -39
  222. package/src/repo/stuck-reservation.ts +224 -0
  223. package/src/repo/under-replication-drain.ts +582 -0
  224. package/src/rn.ts +74 -69
  225. package/src/storage/block-latch.ts +7 -4
  226. package/src/storage/block-storage.ts +161 -10
  227. package/src/storage/i-block-storage.ts +60 -5
  228. package/src/storage/pending-claim.ts +200 -0
  229. package/src/storage/raw-store-codec.ts +26 -3
  230. package/src/storage/restoration-coordinator.ts +212 -211
  231. package/src/storage/ring-shift-coordinator.ts +271 -271
  232. package/src/storage/storage-repo.ts +280 -72
  233. package/src/storage/struct.ts +58 -0
  234. package/src/testing/mesh-harness.ts +293 -112
@@ -194,7 +194,7 @@ export class Libp2pKeyPeerNetwork {
194
194
  * and near-zero for a dial-only client; do not quote the headline figure at a profile that
195
195
  * never pays it. Every
196
196
  * `findCluster` builds a cluster record containing self's addresses, and every commit calls
197
- * `findCluster` through `getClusterPeerIds` — so a cold `apply schema` paid one NIC sweep per
197
+ * `findCluster` through `ClusterCoordinator.resolveCohort` — so a cold `apply schema` paid one NIC sweep per
198
198
  * commit. On a solo node with zero peers that was ~13.8 ms per call and **49% of the whole
199
199
  * apply** (issue #8); the addresses it recomputed were identical every time.
200
200
  *
@@ -352,7 +352,7 @@ export class Libp2pKeyPeerNetwork {
352
352
  /**
353
353
  * The caller-independent half of eligibility: this peer is neither excluded by the caller
354
354
  * nor banned by reputation. Shared by all three places `findCoordinator` narrows a candidate
355
- * list — the FRET tier, the connected-peer fallback, and the retry-futility input — so the
355
+ * list — the cohort tier, the connected-peer fallback, and the retry-futility input — so the
356
356
  * futility test can never disagree with the tiers about who is pickable.
357
357
  *
358
358
  * NOTE: eligibility here is deliberately blind to self-dialability, so on a relay
@@ -500,6 +500,14 @@ export class Libp2pKeyPeerNetwork {
500
500
  recordPeerAddresses(peerId, multiaddrs) {
501
501
  mergePeerAddresses(this.libp2p, peerId, multiaddrs, this.addressLog);
502
502
  }
503
+ /**
504
+ * NOTE: a cached coordinator is trusted without re-deriving the key's cohort. A peer that
505
+ * has since LEFT the cohort (ring growth shifted the nearest set) is corrected on the next
506
+ * hop rather than here: the server-side responsibility check redirects to a current member
507
+ * (`RepoService.checkRedirect`, whose target overwrites this hint), and a peer that has gone
508
+ * away fails its dial and is excluded by the caller. Re-deriving the cohort on every cache
509
+ * hit would cost the same FRET walk and peerStore reads the cache exists to skip.
510
+ */
503
511
  getCachedCoordinator(key) {
504
512
  const k = this.toCacheKey(key);
505
513
  const hit = this.coordinatorCache.get(k);
@@ -570,12 +578,6 @@ export class Libp2pKeyPeerNetwork {
570
578
  throw new Error('FRET service is not registered on this libp2p node');
571
579
  return svc;
572
580
  }
573
- async getNeighborIdsForKey(key, wants) {
574
- const fret = this.getFret();
575
- const coord = await hashKey(key);
576
- const both = fret.getNeighbors(coord, 'both', wants);
577
- return Array.from(new Set(both)).slice(0, wants);
578
- }
579
581
  async findCoordinator(key, _options) {
580
582
  const t0 = Date.now();
581
583
  const excludedSet = new Set((_options?.excludedPeers ?? []).map(p => p.toString()));
@@ -583,12 +585,17 @@ export class Libp2pKeyPeerNetwork {
583
585
  // intent is held to the stricter self-coordination bar.
584
586
  const intent = _options?.intent ?? 'write';
585
587
  const keyStr = this.toCacheKey(key).substring(0, 12);
588
+ const selfStr = this.libp2p.peerId.toString();
586
589
  // Tracks whether the network-membership filter excluded an UNCONFIRMED candidate
587
590
  // — `foreign` (another network) OR `unknown` (not yet confirmed to serve this
588
591
  // network) — during any attempt. If selection ultimately fails with self
589
592
  // unavailable, this lets us surface NO_NETWORK_COORDINATOR (the real cause)
590
593
  // instead of the generic NO_COORDINATOR_AVAILABLE.
591
594
  let droppedUnconfirmedAnyAttempt = false;
595
+ // The most recent attempt's cohort, kept for the last-resort tier below: self may
596
+ // coordinate a key only when it is among that key's responsible peers, and the
597
+ // verdict is the one the attempt that gave up on every better tier was working from.
598
+ let lastCohort;
592
599
  this.log('findCoordinator:start key=%s excluded=%o', keyStr, Array.from(excludedSet).map(s => s.substring(0, 12)));
593
600
  // honor cache if not excluded
594
601
  const cached = this.getCachedCoordinator(key);
@@ -604,22 +611,29 @@ export class Libp2pKeyPeerNetwork {
604
611
  const connected = (this.libp2p.getConnections?.() ?? []).map((c) => c.remotePeer);
605
612
  const connectedSet = new Set(connected.map(p => p.toString()));
606
613
  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);
607
- // prefer FRET neighbors that are also connected, pick first non-excluded
608
- let ids = [];
614
+ // The proximity band this attempt's cohort was cut from, for the retry-futility
615
+ // test below. Empty when the assembly threw.
616
+ let band = [];
617
+ // Cohort tier: the key's responsible peers, in proximity order, from the SAME
618
+ // assembly `findCluster` builds the replica set with — so the coordinator is
619
+ // always one of the peers that will hold the block, never a neighbour just
620
+ // outside that set.
609
621
  try {
610
- ids = await this.getNeighborIdsForKey(key, this.clusterSize);
611
- this.log('findCoordinator:fret-neighbors key=%s candidates=%d', keyStr, ids.length);
622
+ const assembled = await this.assembleServingCohort(key);
623
+ lastCohort = assembled.cohort;
624
+ band = assembled.band;
625
+ this.log('findCoordinator:cohort key=%s size=%d selfInCohort=%s', keyStr, assembled.cohort.length, assembled.cohort.includes(selfStr));
612
626
  if (verbose)
613
- this.log('findCoordinator:fret-candidates key=%s ids=%o connected=%o', keyStr, ids, Array.from(connectedSet));
614
- // Filter to only connected FRET neighbors, excluding banned peers. Self is
627
+ this.log('findCoordinator:cohort-candidates key=%s ids=%o connected=%o', keyStr, assembled.cohort, Array.from(connectedSet));
628
+ // Filter to only connected cohort members, excluding banned peers. Self is
615
629
  // never "connected" to itself, so it is admitted by the explicit self clause
616
- // below — but ONLY when the self-coordination guard allows it, otherwise a
617
- // node whose FRET neighborhood contains self (essentially always on a small or
618
- // forming network) would bypass the guard and the last-resort tier's
619
- // SELF_COORDINATION_BLOCKED would never fire. On refusal self is merely DROPPED
620
- // from the candidate list, so the connected-peer fallback below still gets its
621
- // chance at a good remote peer; only if that also comes up empty does the
622
- // last-resort tier raise the accurate error.
630
+ // below — but ONLY when it is in the cohort (the list being filtered) AND the
631
+ // self-coordination guard allows it, otherwise a node that heads the cohort
632
+ // of nearly every key on a small or forming network would bypass the guard
633
+ // and the last-resort tier's SELF_COORDINATION_BLOCKED would never fire. On
634
+ // refusal self is merely DROPPED from the candidate list, so the connected-peer
635
+ // fallback below still gets its chance at a good remote peer; only if that also
636
+ // comes up empty does the last-resort tier raise the accurate error.
623
637
  //
624
638
  // An ISOLATED READ is the exception: with no connection left there is no better
625
639
  // answer to wait for, and a deferrable denial is not evidence that answering
@@ -627,13 +641,12 @@ export class Libp2pKeyPeerNetwork {
627
641
  // immediately instead of paying the ~1s retry loop before the last-resort tier
628
642
  // degrades to the same answer. A WRITE keeps dropping self exactly as before,
629
643
  // so a peer that lands during the retry window still wins the key.
630
- const selfStr = this.libp2p.peerId.toString();
631
644
  let selfAllowedThisAttempt;
632
- // Memoized per ATTEMPT, and evaluated lazily so an all-remote neighborhood never
645
+ // Memoized per ATTEMPT, and evaluated lazily so an all-remote cohort never
633
646
  // pays detectPartition() / getNetworkSizeEstimate(). Re-evaluated on each attempt
634
647
  // because a connection can land during the 500ms inter-attempt sleep and
635
- // legitimately flip the answer — as filterByMembership re-reads the peerStore.
636
- // NOTE: on a small network self is a neighbor of nearly every key, so this runs
648
+ // legitimately flip the answer — as the assembly re-reads the peerStore.
649
+ // NOTE: on a small network self is in the cohort of nearly every key, so this runs
637
650
  // per findCoordinator call and self-coordinated keys are never cached to absorb
638
651
  // it. Fine while detectPartition()/getNetworkSizeEstimate() stay local FRET
639
652
  // table reads; if either ever grows a probe or other network round-trip, cache
@@ -651,7 +664,7 @@ export class Libp2pKeyPeerNetwork {
651
664
  // Gated on ISOLATION, not just on the read intent. Self carries no reputation
652
665
  // record, so it scores 0 and sorts ahead of every remote candidate in the rank
653
666
  // below — admitting it while a connection is live would hand the key to a node
654
- // its own guard just called partitioned, over a reachable FRET neighbour. And
667
+ // its own guard just called partitioned, over a reachable cohort member. And
655
668
  // waiting costs a connected read nothing: the inter-attempt sleep further down
656
669
  // only runs when `connected.length === 0`, so with peers present the remaining
657
670
  // attempts and the last-resort degrade run back-to-back with no delay.
@@ -659,28 +672,28 @@ export class Libp2pKeyPeerNetwork {
659
672
  && intent === 'read' && connected.length === 0;
660
673
  selfAllowedThisAttempt = decision.allow || degradedRead;
661
674
  if (degradedRead) {
662
- this.log('findCoordinator:fret-self-degraded key=%s reason=%s intent=read attempt=%d', keyStr, decision.reason, attempt);
675
+ this.log('findCoordinator:cohort-self-degraded key=%s reason=%s intent=read attempt=%d', keyStr, decision.reason, attempt);
663
676
  }
664
677
  else if (!decision.allow) {
665
- this.log('findCoordinator:fret-self-dropped key=%s reason=%s intent=%s attempt=%d', keyStr, decision.reason, intent, attempt);
678
+ this.log('findCoordinator:cohort-self-dropped key=%s reason=%s intent=%s attempt=%d', keyStr, decision.reason, intent, attempt);
666
679
  }
667
680
  }
668
681
  return selfAllowedThisAttempt;
669
682
  };
670
- const connectedFretIds = ids
683
+ const reachable = assembled.cohort
671
684
  .filter(id => this.isSelectable(id, excludedSet))
672
685
  .filter(id => connectedSet.has(id) || (id === selfStr && isSelfAdmissible()))
686
+ // Ranked by reputation, best (lowest) score first. The sort MUST be stable so
687
+ // that equal-score members keep their proximity order — otherwise two writers
688
+ // with the same cohort could name different coordinators for one block.
689
+ // `Array.prototype.sort` is stable in every supported runtime (ES2019+).
673
690
  .sort((a, b) => (this.reputation?.getScore(a) ?? 0) - (this.reputation?.getScore(b) ?? 0));
674
- this.log('findCoordinator:fret-connected key=%s count=%d peers=%o', keyStr, connectedFretIds.length, connectedFretIds.map(s => s.substring(0, 12)));
675
- // Network-membership scoping (no-op when protocolPrefix is unset): only a peer
676
- // CONFIRMED to serve this network ('serves') is eligible — both `foreign`
677
- // (another network) and `unknown` (not yet identified) peers are excluded
678
- // from selection. A cross-network peer is permanently 'unknown' (its
679
- // namespaced identify never completes), so it is never gambled on; over the
680
- // 3×500ms retry window a genuine same-network peer flips to 'serves' on a
681
- // re-read of the peerStore and is selected normally on that attempt. Self
682
- // always classifies as 'serves' and stays eligible.
683
- const { ranked, droppedUnconfirmed } = await this.filterByMembership(connectedFretIds);
691
+ this.log('findCoordinator:cohort-reachable key=%s count=%d peers=%o', keyStr, reachable.length, reachable.map(s => s.substring(0, 12)));
692
+ // Network-membership scoping (no-op when protocolPrefix is unset). The cohort
693
+ // was already cut to peers CONFIRMED to serve this network by the assembly, so
694
+ // this is the final scope check over the same peerStore records — it costs no
695
+ // second read and cannot disagree with the assembly.
696
+ const { ranked, droppedUnconfirmed } = await this.filterByMembership(reachable, assembled.protocolsByPeer);
684
697
  if (droppedUnconfirmed)
685
698
  droppedUnconfirmedAnyAttempt = true;
686
699
  const pick = ranked[0];
@@ -689,12 +702,12 @@ export class Libp2pKeyPeerNetwork {
689
702
  // A self pick is a no-op here — recordCoordinator ignores self-valued
690
703
  // writes (see its doc comment), matching the last-resort self tier below.
691
704
  this.recordCoordinator(key, pid);
692
- this.log('findCoordinator:done key=%s ms=%d source=%s', keyStr, Date.now() - t0, 'fret');
705
+ this.log('findCoordinator:done key=%s ms=%d source=%s', keyStr, Date.now() - t0, 'cohort');
693
706
  return pid;
694
707
  }
695
708
  }
696
709
  catch (err) {
697
- this.log('findCoordinator getNeighborIdsForKey failed - %o', err);
710
+ this.log('findCoordinator:cohort-assembly-failed key=%s attempt=%d - %o', keyStr, attempt, err);
698
711
  }
699
712
  // fallback: prefer any existing connected peer that's not excluded or banned,
700
713
  // scoped to this network's serving peers (a `foreign` or not-yet-confirmed
@@ -702,7 +715,12 @@ export class Libp2pKeyPeerNetwork {
702
715
  // connected REMOTE peers and never includes self, so when no serving peer is
703
716
  // present selection falls through to the last-resort self-coordination block.
704
717
  // Being remote-only, this tier needs no self-coordination guard check, unlike the
705
- // FRET tier above.
718
+ // cohort tier above.
719
+ // NOTE: this tier can pick a serving peer OUTSIDE the key's cohort when no cohort
720
+ // member is connected. That is a redirect hop, not a wrong placement: the receiving
721
+ // node's responsibility check redirects the request to a current cohort member (or
722
+ // refuses it). If redirect hops ever show up in profiles, prefer a not-connected
723
+ // cohort member we hold an address for over an out-of-cohort connected peer here.
706
724
  const connectedCandidates = connected
707
725
  .filter(p => this.isSelectable(p.toString(), excludedSet))
708
726
  .sort((a, b) => (this.reputation?.getScore(a.toString()) ?? 0) - (this.reputation?.getScore(b.toString()) ?? 0))
@@ -720,14 +738,16 @@ export class Libp2pKeyPeerNetwork {
720
738
  // If no connections and not the last attempt, wait and retry
721
739
  if (connected.length === 0 && attempt < maxRetries - 1) {
722
740
  // Exclusion/ban filtered — a neighbour we may never pick is not something to
723
- // wait for. This network's membership filter (peerStore protocols) is deliberately
724
- // NOT applied: a neighbour still `unknown` to it is exactly the peer that flips to
725
- // `serves` inside the retry window, so its presence must keep the window. FRET's
726
- // own ring membership has already applied a stricter cut upstream `getNeighbors`
727
- // returns confirmed ring members only — so a configured-but-never-reached bootstrap
728
- // peer is absent from `ids` entirely, and only the dial-in-flight signal below can
729
- // keep the window for it.
730
- const knowable = ids.filter(id => this.isSelectable(id, excludedSet));
741
+ // wait for. Fed the whole proximity BAND rather than the cut cohort, and this
742
+ // network's membership filter (peerStore protocols) is deliberately NOT applied:
743
+ // a band member still `unknown` to it is exactly the peer that flips to `serves`
744
+ // inside the retry window, and a connection to ANY serving band member makes the
745
+ // connected fallback above succeed — so each one's presence must keep the window.
746
+ // FRET's own ring membership has already applied a stricter cut upstream
747
+ // `assembleCohort` returns confirmed live ring members only — so a
748
+ // configured-but-never-reached bootstrap peer is absent from the band entirely,
749
+ // and only the dial-in-flight signal below can keep the window for it.
750
+ const knowable = band.filter(id => this.isSelectable(id, excludedSet));
731
751
  if (!this.retryCouldImprove(knowable)) {
732
752
  this.log('findCoordinator:retry-futile key=%s neighbors=%d dialsInFlight=%d mode=%s hwm=%d', keyStr, knowable.length, this.dialsInFlight(), this.networkMode, this.networkHighWaterMark);
733
753
  break;
@@ -737,9 +757,21 @@ export class Libp2pKeyPeerNetwork {
737
757
  continue;
738
758
  }
739
759
  }
740
- // last resort: prefer self only if not excluded and guard allows
760
+ // last resort: self, only if not excluded, only if self is among the key's responsible
761
+ // peers, and only if the guard allows. A node that is NOT responsible for the key — one
762
+ // that does not serve storage at all, or one that `clusterSize` nearer serving peers
763
+ // outrank — never coordinates it, however isolated it is: a self-only commit there would
764
+ // leave a copy nobody looks for and a responsible peer without one. `lastCohort` is
765
+ // undefined only when every attempt's assembly threw (FRET unavailable), which is read
766
+ // as "not known to be responsible".
767
+ // NOTE: this also fails an ISOLATED READ of a block this node is not responsible for, even
768
+ // when an older local copy exists (before the cohort rule, such a read degraded to the
769
+ // node's own replica). No effect while the serving peers number at most `clusterSize`,
770
+ // where every node is in every cohort. If offline reads on a wider network ever matter,
771
+ // let a read fall back to a local copy flagged as unverified rather than widening this tier.
741
772
  const self = this.libp2p.peerId;
742
- if (!excludedSet.has(self.toString())) {
773
+ const selfInCohort = lastCohort?.includes(selfStr) ?? false;
774
+ if (!excludedSet.has(selfStr) && selfInCohort) {
743
775
  const decision = this.shouldAllowSelfCoordination(intent);
744
776
  // Only a HARD denial fails the caller. A deferrable one (see
745
777
  // SelfCoordinationDecision.deferrable) means self is merely not the preferred
@@ -751,39 +783,44 @@ export class Libp2pKeyPeerNetwork {
751
783
  throw new FindCoordinatorError(FIND_COORDINATOR_ERROR_CODES.SELF_COORDINATION_BLOCKED, `Self-coordination blocked: ${decision.reason}. No coordinator available for key.`);
752
784
  }
753
785
  if (!decision.allow) {
754
- this.log('findCoordinator:self-selected-degraded key=%s coordinator=%s reason=%s intent=%s', keyStr, self.toString().substring(0, 12), decision.reason, intent);
786
+ this.log('findCoordinator:self-selected-degraded key=%s coordinator=%s reason=%s intent=%s', keyStr, selfStr.substring(0, 12), decision.reason, intent);
755
787
  this.log('findCoordinator:done key=%s ms=%d source=%s', keyStr, Date.now() - t0, 'self-degraded');
756
788
  return self;
757
789
  }
758
790
  if (decision.warn) {
759
- this.log('findCoordinator:self-selected-warn key=%s coordinator=%s reason=%s', keyStr, self.toString().substring(0, 12), decision.reason);
791
+ this.log('findCoordinator:self-selected-warn key=%s coordinator=%s reason=%s', keyStr, selfStr.substring(0, 12), decision.reason);
760
792
  }
761
793
  else {
762
- this.log('findCoordinator:self-selected key=%s coordinator=%s reason=%s', keyStr, self.toString().substring(0, 12), decision.reason);
794
+ this.log('findCoordinator:self-selected key=%s coordinator=%s reason=%s', keyStr, selfStr.substring(0, 12), decision.reason);
763
795
  }
764
796
  this.log('findCoordinator:done key=%s ms=%d source=%s', keyStr, Date.now() - t0, 'self');
765
797
  return self;
766
798
  }
767
- // Self is excluded and selection found no eligible peer. If the membership filter is
768
- // the reason the candidate set emptied (the only other peers are `foreign` — serving
769
- // a DIFFERENT network or `unknown` — not yet confirmed to serve this network),
770
- // surface a distinct, accurate cause instead of the generic codes below.
799
+ // Self is unavailable (excluded by the caller, or not responsible for the key) and
800
+ // selection found no eligible peer. If the membership filter is the reason the
801
+ // candidate set emptied (the only other peers are `foreign` — serving a DIFFERENT
802
+ // network or `unknown` not yet confirmed to serve this network), surface a
803
+ // distinct, accurate cause instead of the generic codes below.
771
804
  if (droppedUnconfirmedAnyAttempt) {
772
- this.log('findCoordinator:no-network-coordinator key=%s prefix=%s self=%s', keyStr, this.protocolPrefix ?? '?', self.toString().substring(0, 12));
805
+ this.log('findCoordinator:no-network-coordinator key=%s prefix=%s self=%s selfInCohort=%s', keyStr, this.protocolPrefix ?? '?', selfStr.substring(0, 12), selfInCohort);
773
806
  throw new FindCoordinatorError(FIND_COORDINATOR_ERROR_CODES.NO_NETWORK_COORDINATOR, `No coordinator available for key on network ${this.protocolPrefix ?? '?'}: ` +
774
807
  `the remaining candidate peer(s) are foreign or not-yet-confirmed to serve this network's cluster/repo protocol.`);
775
808
  }
776
809
  // Self is excluded. On a solo/bootstrap node (HWM<=1 and no other connected/FRET peers),
777
810
  // this means the caller already tried self and the retry has nowhere to go — surface a
778
811
  // distinct error so retry logic stops and the original first-attempt cause is preserved.
779
- const isSoloBootstrap = this.networkHighWaterMark <= 1;
780
- if (isSoloBootstrap) {
781
- this.log('findCoordinator:self-exhausted-solo key=%s self=%s', keyStr, self.toString().substring(0, 12));
812
+ if (excludedSet.has(selfStr) && this.networkHighWaterMark <= 1) {
813
+ this.log('findCoordinator:self-exhausted-solo key=%s self=%s', keyStr, selfStr.substring(0, 12));
782
814
  throw new FindCoordinatorError(FIND_COORDINATOR_ERROR_CODES.SELF_COORDINATION_EXHAUSTED, 'Self-coordination exhausted on solo/bootstrap node (self already attempted). ' +
783
815
  'The original first-attempt error describes the actual failure cause.');
784
816
  }
785
- this.log('findCoordinator:all-excluded key=%s self=%s', keyStr, self.toString().substring(0, 12));
786
- throw new FindCoordinatorError(FIND_COORDINATOR_ERROR_CODES.NO_COORDINATOR_AVAILABLE, 'No coordinator available for key (all candidates excluded)');
817
+ const why = excludedSet.has(selfStr)
818
+ ? 'all candidates excluded'
819
+ : lastCohort === undefined
820
+ ? 'the responsible cohort could not be derived and no serving peer is connected'
821
+ : 'this node is not among the responsible peers and none of them is connected';
822
+ this.log('findCoordinator:no-coordinator key=%s self=%s selfInCohort=%s cohort=%d why=%s', keyStr, selfStr.substring(0, 12), selfInCohort, lastCohort?.length ?? -1, why);
823
+ throw new FindCoordinatorError(FIND_COORDINATOR_ERROR_CODES.NO_COORDINATOR_AVAILABLE, `No coordinator available for key (${why})`);
787
824
  }
788
825
  /**
789
826
  * Connection-derived addresses for the cluster record, keyed by peer id.
@@ -815,72 +852,14 @@ export class Libp2pKeyPeerNetwork {
815
852
  // material; then memoize ONLY the solo answer, invalidated on connection:open and peer:identify.
816
853
  async findCluster(key) {
817
854
  const t0 = Date.now();
818
- const fret = this.getFret();
819
- const coord = await hashKey(key);
820
- // When membership scoping is active, over-fetch a wider proximity band so the
821
- // nearest peers that SERVE this network are in the candidate pool even if cross-
822
- // network peers sit nearer the key (see membershipOverfetch).
823
- const wants = this.protocolPrefix != null ? this.membershipOverfetch() : this.clusterSize;
824
- const cohort = fret.assembleCohort(coord, wants);
825
855
  const keyStr = this.toCacheKey(key).substring(0, 12);
826
856
  this.log('findCluster:start key=%s', keyStr);
827
- // Include self in the cohort
857
+ // The responsible peers for the key, in proximity order — self among them only when it
858
+ // is one of the nearest `clusterSize` serving peers (see `assembleServingCohort`). On the
859
+ // scoped path the assembly's peerStore reads are handed back so the address backfill
860
+ // below does not read the same records twice.
861
+ const { cohort: ids, band, peerStoreRecords } = await this.assembleServingCohort(key);
828
862
  const selfId = this.libp2p.peerId.toString();
829
- let ids = Array.from(new Set([...cohort, selfId]));
830
- // Network-membership scoping (no-op when protocolPrefix is unset): a cohort
831
- // member that serves a DIFFERENT network's protocol can never negotiate THIS
832
- // network's cluster/repo dial, so it guarantees a super-majority failure rather
833
- // than contributing a promise. Drop such 'foreign' members; build the cohort from
834
- // positively-'serves' members only and NEVER admit a not-yet-identified ('unknown')
835
- // member. A permanently cross-network peer and a freshly-discovered same-network
836
- // peer mid-identify are indistinguishable while 'unknown' (both have an empty
837
- // peerStore protocol list), so admitting an 'unknown' on the strength of a viability
838
- // floor risks pulling a cross-network contaminant into the cohort — its repo dial
839
- // then negotiates a different network's protocol and the whole write fails. A fresh
840
- // same-network peer is not starved: it flips to 'serves' once identify completes and
841
- // is re-included on the caller's retry, and in the meantime a self-only cohort still
842
- // completes the write under allowClusterDownsize (the default).
843
- // Scoped path only: one peerStore read per cohort member yields both protocols
844
- // (for membership classification here) and addresses (reused at backfill below),
845
- // so a finally-selected member isn't fetched from the peerStore twice. Left
846
- // undefined on the unscoped path, which never classifies membership.
847
- let peerStoreRecords;
848
- if (this.protocolPrefix != null) {
849
- // `cohort` is the over-fetched nearest-first band. Classify each non-self
850
- // member, preserving proximity order within each tier.
851
- const nonSelf = cohort.filter(id => id !== selfId);
852
- peerStoreRecords = await this.getPeerStoreRecordsByPeer(nonSelf);
853
- const serves = [];
854
- const unknown = [];
855
- let foreignDropped = 0;
856
- for (const id of nonSelf) {
857
- const m = this.membershipOf(id, peerStoreRecords[id]?.protocols);
858
- if (m === 'serves')
859
- serves.push(id);
860
- else if (m === 'unknown')
861
- unknown.push(id);
862
- else
863
- foreignDropped++;
864
- }
865
- // Take the nearest `clusterSize - 1` SERVING peers. Self is ALWAYS added below and
866
- // counts toward `clusterSize` (matching the unscoped path, where `assembleCohort`
867
- // returns the nearest `clusterSize` peers INCLUDING self when self is near the key —
868
- // the coordinator case), so reserving a slot for self keeps a healthy same-network
869
- // cohort at exactly `clusterSize` members rather than `clusterSize + 1`. Over-sizing
870
- // would inflate the super-majority promise count (ceil(peerCount * threshold)) above
871
- // what the configured `clusterSize` intends and hurt write availability. 'unknown'
872
- // members are never backfilled: an 'unknown' peer may be a permanently cross-network
873
- // contaminant whose repo dial cannot negotiate this network's protocol, and a fresh
874
- // same-network peer mid-identify is indistinguishable from it. We therefore admit
875
- // only positively-'serves' peers; when self is the sole serving member the cohort is
876
- // self-only, which completes the write under allowClusterDownsize (the default) and
877
- // re-includes any legitimate peer as 'serves' on the caller's retry once identify
878
- // completes. `unknown.length` is still computed above for the diagnostic log line.
879
- const nonSelfTarget = Math.max(0, this.clusterSize - 1);
880
- const others = serves.slice(0, nonSelfTarget);
881
- ids = Array.from(new Set([selfId, ...others]));
882
- this.log('findCluster:membership key=%s serves=%d unknown=%d foreignDropped=%d kept=%d', keyStr, serves.length, unknown.length, foreignDropped, ids.length);
883
- }
884
863
  const connectedByPeer = this.getConnectedAddrsByPeer();
885
864
  const connectedPeerIds = Object.keys(connectedByPeer);
886
865
  // Backfill addresses from the peerStore for cohort members we don't have
@@ -895,7 +874,7 @@ export class Libp2pKeyPeerNetwork {
895
874
  .map(id => [id, peerStoreRecords[id]?.addrs ?? []])
896
875
  .filter(([, addrs]) => addrs.length > 0))
897
876
  : await this.getPeerStoreAddrsByPeer(backfillIds);
898
- this.log('findCluster key=%s fretCohort=%d connected=%d', keyStr, cohort.length, connectedPeerIds.length);
877
+ this.log('findCluster key=%s band=%d cohort=%d connected=%d', keyStr, band.length, ids.length, connectedPeerIds.length);
899
878
  if (verbose)
900
879
  this.log('findCluster:detail key=%s cohortPeers=%o connectedPeers=%o', keyStr, ids, connectedPeerIds);
901
880
  const peers = {};
@@ -1044,31 +1023,139 @@ export class Libp2pKeyPeerNetwork {
1044
1023
  return Math.max(this.clusterSize * 4, this.clusterSize + 16);
1045
1024
  }
1046
1025
  /**
1047
- * Classify a peer's network membership from its advertised protocols. Self always
1048
- * `serves` (it trivially serves its own network). When no `protocolPrefix` is
1049
- * configured the filter is disabled and EVERY peer is reported `serves`, so all
1050
- * callers behave exactly as before this scoping was added.
1026
+ * Does an advertised protocol list serve THIS network's storage its namespaced `cluster`
1027
+ * or `repo` protocol? The one test behind every membership verdict, self's included.
1028
+ */
1029
+ servesThisNetwork(protocols) {
1030
+ return protocols.includes(`${this.protocolPrefix}/cluster/1.0.0`)
1031
+ || protocols.includes(`${this.protocolPrefix}/repo/1.0.0`);
1032
+ }
1033
+ /**
1034
+ * Does THIS node serve storage on this network? True when no `protocolPrefix` is
1035
+ * configured (the unscoped path, where every peer counts as serving), or when libp2p
1036
+ * advertises this network's `cluster` or `repo` protocol — the same test
1037
+ * {@link membershipOf} applies to a remote peer's peerStore protocol list, so self is
1038
+ * classified on exactly the same footing. A libp2p double with no `getProtocols` counts
1039
+ * as serving, the convention `getConnections?.()` already follows for mocks.
1040
+ *
1041
+ * Every production node registers both storage services today (`createLibp2pNode`), so the
1042
+ * `false` branch is defence in depth: it is what keeps a future client-only node out of every
1043
+ * cohort and away from every coordinator pick.
1044
+ */
1045
+ selfServes() {
1046
+ if (this.protocolPrefix == null)
1047
+ return true;
1048
+ const protocols = this.libp2p.getProtocols?.();
1049
+ if (protocols == null)
1050
+ return true;
1051
+ return this.servesThisNetwork(protocols);
1052
+ }
1053
+ /**
1054
+ * Classify a peer's network membership from its advertised protocols. Self is classified
1055
+ * from its own registered protocols ({@link selfServes}) rather than assumed to serve.
1056
+ * When no `protocolPrefix` is configured the filter is disabled and EVERY peer is
1057
+ * reported `serves`, so all callers behave exactly as before this scoping was added.
1051
1058
  */
1052
1059
  membershipOf(idStr, protocols) {
1053
1060
  if (this.protocolPrefix == null)
1054
1061
  return 'serves';
1055
1062
  if (idStr === this.libp2p.peerId.toString())
1056
- return 'serves';
1063
+ return this.selfServes() ? 'serves' : 'foreign';
1057
1064
  if (protocols == null || protocols.length === 0)
1058
1065
  return 'unknown';
1059
- if (protocols.includes(`${this.protocolPrefix}/cluster/1.0.0`)
1060
- || protocols.includes(`${this.protocolPrefix}/repo/1.0.0`))
1061
- return 'serves';
1062
- return 'foreign';
1066
+ return this.servesThisNetwork(protocols) ? 'serves' : 'foreign';
1067
+ }
1068
+ /**
1069
+ * The ordered serving cohort for `key`: the nearest `clusterSize` peers that serve this
1070
+ * network, in proximity order, with THIS node among them only when it genuinely is one of
1071
+ * them. Both `findCluster` (the replica set) and `findCoordinator` (the pick) derive from
1072
+ * this one assembly, so the two can never disagree about who is responsible for a block.
1073
+ *
1074
+ * The rule, stated once:
1075
+ *
1076
+ * 1. `coord = hashKey(key)` — the only hash between a block id and its cohort.
1077
+ * 2. `band = fret.assembleCohort(coord, wants)`: the nearest live ring members, alternating
1078
+ * successor/predecessor outward from the coordinate; `wants` is the over-fetch width on
1079
+ * the scoped path (see {@link membershipOverfetch}) and `clusterSize` otherwise.
1080
+ * 3. FRET's ring store holds this node as a live member, so `band` already contains self
1081
+ * whenever self is among the nearest; absence means self is farther than every band
1082
+ * member. A serving self absent from the band is appended LAST, so the cut below keeps
1083
+ * it only when the band has room; a non-serving self is removed wherever it sits.
1084
+ * 4. Scoped path: keep only members whose membership is `serves`, order preserved.
1085
+ * 5. `cohort` = the first `clusterSize` of what remains.
1086
+ *
1087
+ * Consequences: a node with no live ring members other than itself gets a self-only cohort
1088
+ * (the solo short-circuits downstream are untouched); on a ring no wider than `clusterSize`
1089
+ * every serving node is in every cohort; on a wider ring self is in a block's cohort iff it
1090
+ * is among the nearest `clusterSize` serving members; a client-only node is in no cohort at
1091
+ * any width, and its `findCluster` may legitimately come back empty.
1092
+ *
1093
+ * NOTE: on the scoped path this classifies the WHOLE over-fetch band — one peerStore read per
1094
+ * band member, bounded by the number of live ring members — where the coordinator tier used
1095
+ * to classify only the connected neighbours. On a small network that is at most one read per
1096
+ * known peer. If peerStore reads per lookup ever show in a profile, memoize the per-peer
1097
+ * membership verdict with a short TTL rather than caching cohorts, which would have to be
1098
+ * invalidated on every ring change.
1099
+ */
1100
+ async assembleServingCohort(key) {
1101
+ const fret = this.getFret();
1102
+ // The only hash between a block id and its cohort: `key` is the id's raw utf8 (`routingKeyForBlock`).
1103
+ const coord = await hashKey(key);
1104
+ const scoped = this.protocolPrefix != null;
1105
+ // When membership scoping is active, over-fetch a wider proximity band so the nearest
1106
+ // peers that SERVE this network are in the candidate pool even if cross-network peers
1107
+ // sit nearer the key (see membershipOverfetch).
1108
+ const wants = scoped ? this.membershipOverfetch() : this.clusterSize;
1109
+ const nearest = fret.assembleCohort(coord, wants);
1110
+ const selfId = this.libp2p.peerId.toString();
1111
+ const band = this.selfServes()
1112
+ ? (nearest.includes(selfId) ? nearest : [...nearest, selfId])
1113
+ : nearest.filter(id => id !== selfId);
1114
+ if (!scoped)
1115
+ return { cohort: band.slice(0, this.clusterSize), band };
1116
+ // Network-membership scoping: a band member that serves a DIFFERENT network's protocol
1117
+ // can never negotiate THIS network's cluster/repo dial, so it guarantees a super-majority
1118
+ // failure rather than contributing a promise. Drop such 'foreign' members; build the
1119
+ // cohort from positively-'serves' members only and NEVER admit a not-yet-identified
1120
+ // ('unknown') member. A permanently cross-network peer and a freshly-discovered
1121
+ // same-network peer mid-identify are indistinguishable while 'unknown' (both have an
1122
+ // empty peerStore protocol list), so admitting an 'unknown' on the strength of a
1123
+ // viability floor risks pulling a cross-network contaminant into the cohort — its repo
1124
+ // dial then negotiates a different network's protocol and the whole write fails. A fresh
1125
+ // same-network peer is not starved: it flips to 'serves' once identify completes and is
1126
+ // re-included on the caller's retry, and in the meantime a self-only cohort (when self is
1127
+ // the only serving member known) still completes the write under allowClusterDownsize
1128
+ // (the default). One peerStore read per non-self band member yields both protocols (for
1129
+ // the classification here) and addresses (reused by `findCluster`'s backfill).
1130
+ const peerStoreRecords = await this.getPeerStoreRecordsByPeer(band.filter(id => id !== selfId));
1131
+ const serving = [];
1132
+ let unknown = 0;
1133
+ let foreign = 0;
1134
+ for (const id of band) {
1135
+ const m = this.membershipOf(id, peerStoreRecords[id]?.protocols);
1136
+ if (m === 'serves')
1137
+ serving.push(id);
1138
+ else if (m === 'unknown')
1139
+ unknown++;
1140
+ else
1141
+ foreign++;
1142
+ }
1143
+ const cohort = serving.slice(0, this.clusterSize);
1144
+ this.log('cohort:membership key=%s band=%d serves=%d unknown=%d foreign=%d cohort=%d selfInCohort=%s', this.toCacheKey(key).substring(0, 12), band.length, serving.length, unknown, foreign, cohort.length, cohort.includes(selfId));
1145
+ const protocolsByPeer = Object.fromEntries(Object.entries(peerStoreRecords).map(([id, r]) => [id, r.protocols]));
1146
+ return { cohort, band, peerStoreRecords, protocolsByPeer };
1063
1147
  }
1064
1148
  /**
1065
1149
  * Scope a reputation-ordered candidate id list to this network for COORDINATOR
1066
- * selection: keep ONLY peers confirmed to serve this network (`serves`, which always
1067
- * includes self), dropping both `foreign` peers (serving another network) and
1068
- * `unknown` peers (peerStore protocol list empty — not yet confirmed). Incoming
1069
- * (reputation) order is preserved among the surviving `serves` peers. A no-op
1070
- * (returns the input unchanged, no drops) when `protocolPrefix` is unset or the list
1071
- * is empty — the membership-disabled path is therefore untouched.
1150
+ * selection: keep ONLY peers confirmed to serve this network (`serves`), dropping both
1151
+ * `foreign` peers (serving another network) and `unknown` peers (peerStore protocol
1152
+ * list empty — not yet confirmed). Incoming (reputation) order is preserved among the
1153
+ * surviving `serves` peers. A no-op (returns the input unchanged, no drops) when
1154
+ * `protocolPrefix` is unset or the list is empty — the membership-disabled path is
1155
+ * therefore untouched.
1156
+ *
1157
+ * `protocolsByPeer`, when given, is a peerStore read the caller already made for these
1158
+ * peers (the cohort assembly's); otherwise the protocols are read fresh here.
1072
1159
  *
1073
1160
  * `droppedUnconfirmed` reports whether any candidate was excluded because it was not
1074
1161
  * confirmed to serve this network — `foreign` OR `unknown` under scoping — so the
@@ -1079,15 +1166,15 @@ export class Libp2pKeyPeerNetwork {
1079
1166
  * same-network peer that completes `identify` within the retry window flips to `serves`
1080
1167
  * and is selected normally on that attempt.
1081
1168
  */
1082
- async filterByMembership(ids) {
1169
+ async filterByMembership(ids, protocolsByPeer) {
1083
1170
  if (this.protocolPrefix == null || ids.length === 0)
1084
1171
  return { ranked: ids, droppedUnconfirmed: false };
1085
1172
  const selfStr = this.libp2p.peerId.toString();
1086
- const protocolsByPeer = await this.getPeerStoreProtocolsByPeer(ids.filter(id => id !== selfStr));
1173
+ const protocols = protocolsByPeer ?? await this.getPeerStoreProtocolsByPeer(ids.filter(id => id !== selfStr));
1087
1174
  const serves = [];
1088
1175
  let droppedUnconfirmed = false;
1089
1176
  for (const id of ids) {
1090
- const m = this.membershipOf(id, protocolsByPeer[id]);
1177
+ const m = this.membershipOf(id, protocols[id]);
1091
1178
  if (m === 'serves')
1092
1179
  serves.push(id);
1093
1180
  else