@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
@@ -0,0 +1,636 @@
1
+ /**
2
+ * Keeps a relay-only node's circuit reservation alive.
3
+ *
4
+ * A phone or browser reaches its group only through a relay server: it listens on
5
+ * `<relay address>/p2p/<relay id>/p2p-circuit`, holds a slot ("reservation") on that relay, and
6
+ * advertises the circuit address other peers dial. libp2p asks the relay for that slot once, from
7
+ * inside `start()`, and never again. The slot is lost, and the node stays unreachable until the
8
+ * app restarts, whenever the relay restarts, the connection to it drops, or libp2p's own routine
9
+ * renewal of the slot runs — all three verified against the installed `@libp2p/circuit-relay-v2`
10
+ * 4.1.3 (see the `NOTE:` at {@link findCircuitRelayTransport} for the exact mechanics).
11
+ *
12
+ * This module gives the node its own supervisor for every such relay, in two parts:
13
+ *
14
+ * 1. {@link planRelayListenAddrs} rewrites each relay-naming circuit listen address into a bare
15
+ * `/p2p-circuit` entry and records the relay it named. libp2p treats the bare shape as a
16
+ * "search" listener: it registers a pending slot and publishes any `discovered` reservation
17
+ * that fills it, including the re-created one after a renewal. A listener on the relay-naming
18
+ * ("configured") shape publishes only from inside its own `listen()`, so nothing done after
19
+ * start can ever bring its address back — which is why the rewrite is not optional.
20
+ * 2. {@link superviseRelayReservation} runs one supervisor per recorded relay. It dials the relay,
21
+ * asks the circuit-relay transport's reservation store for a `discovered` slot on it, waits
22
+ * until the node advertises a circuit address through it, and repeats that whenever the address
23
+ * goes away. "Held" is judged per relay ({@link routesThroughRelay}), so one relay's slot never
24
+ * satisfies another relay's supervisor.
25
+ *
26
+ * `createLibp2pNodeBase` applies the plan before building libp2p's options, starts the supervisors
27
+ * first thing after `node.start()`, and awaits their first drives so a relay that cannot be
28
+ * reserved still rejects node creation, as it did when libp2p reserved from `listen()`.
29
+ *
30
+ * A host that passes a bare `/p2p-circuit` itself is left alone and not supervised: it owns that
31
+ * reservation (Sereus runs its own supervisor over exactly that shape).
32
+ *
33
+ * Everything here is fail-soft: a drive never throws, a stopped supervisor never schedules
34
+ * anything, and every timer is `unref`'d so a pending retry cannot keep a Node process alive.
35
+ * Adapted from `sereus/packages/cadre-core/src/relay-reservation.ts`, reduced to what this node
36
+ * needs: one relay per supervisor, event-driven re-checks, and no status surface.
37
+ */
38
+ import { multiaddr, type Component } from '@multiformats/multiaddr';
39
+ import { peerIdFromString } from '@libp2p/peer-id';
40
+ import type { Libp2p } from 'libp2p';
41
+ import type { PeerId } from '@libp2p/interface';
42
+ import { routesThroughRelay } from '../peer-address-book.js';
43
+ import { createLogger } from '../logger.js';
44
+
45
+ const log = createLogger('relay-reservation');
46
+
47
+ /** The bare "search" circuit listen address: one pending slot, filled by whoever asks for it. */
48
+ export const CIRCUIT_SEARCH_LISTEN_ADDR = '/p2p-circuit';
49
+
50
+ /** A relay this node must keep a reservation on itself. */
51
+ export interface SupervisedRelay {
52
+ /** Where to dial the relay: the listen entry without its trailing `/p2p-circuit`. */
53
+ readonly dialAddr: string;
54
+ /** The relay's peer id in the form `PeerId.toString()` produces, whatever spelling the listen entry used. */
55
+ readonly peerId: string;
56
+ }
57
+
58
+ /** What {@link planRelayListenAddrs} hands back: the addresses libp2p listens on, and the relays to supervise. */
59
+ export interface RelayListenPlan {
60
+ readonly listenAddrs: string[];
61
+ readonly supervisedRelays: SupervisedRelay[];
62
+ }
63
+
64
+ /**
65
+ * Split a node's listen addresses into what libp2p should listen on and which relays this node
66
+ * must supervise.
67
+ *
68
+ * A relay-naming circuit listen address — exactly one `p2p-circuit` component, last, with a `p2p`
69
+ * component (the relay's peer id) immediately before it — becomes one bare
70
+ * {@link CIRCUIT_SEARCH_LISTEN_ADDR} entry plus one supervised relay. libp2p keeps listen addresses
71
+ * as a plain array with no de-duplication, so N such entries give N bare listeners and N pending
72
+ * slots. Two entries naming one relay (by peer id, whatever the spelling) give one of each; the
73
+ * first entry's dial address wins. Everything else passes through untouched: a host's own bare
74
+ * `/p2p-circuit` (the host owns that reservation), TCP and WebSocket entries, and anything
75
+ * malformed or multi-hop, which libp2p rejects itself.
76
+ */
77
+ export function planRelayListenAddrs(listenAddrs: readonly string[]): RelayListenPlan {
78
+ const listen: string[] = [];
79
+ const supervised: SupervisedRelay[] = [];
80
+ const seen = new Set<string>();
81
+ for (const addr of listenAddrs) {
82
+ const relay = relayNamedByListenAddr(addr);
83
+ if (relay === undefined) {
84
+ listen.push(addr);
85
+ continue;
86
+ }
87
+ if (seen.has(relay.peerId)) continue;
88
+ seen.add(relay.peerId);
89
+ supervised.push(relay);
90
+ listen.push(CIRCUIT_SEARCH_LISTEN_ADDR);
91
+ }
92
+ return { listenAddrs: listen, supervisedRelays: supervised };
93
+ }
94
+
95
+ /** The relay a listen address names, or `undefined` when it is not a relay-naming circuit listen address. */
96
+ function relayNamedByListenAddr(addr: string): SupervisedRelay | undefined {
97
+ let components: Component[];
98
+ try {
99
+ components = multiaddr(addr).getComponents();
100
+ } catch {
101
+ return undefined;
102
+ }
103
+ const circuitHops = components.filter(c => c.name === 'p2p-circuit').length;
104
+ const last = components[components.length - 1];
105
+ const relayHop = components[components.length - 2];
106
+ if (circuitHops !== 1 || last?.name !== 'p2p-circuit' || relayHop?.name !== 'p2p' || relayHop.value === undefined) {
107
+ return undefined;
108
+ }
109
+ let peerId: string;
110
+ try {
111
+ peerId = peerIdFromString(relayHop.value).toString();
112
+ } catch {
113
+ return undefined;
114
+ }
115
+ return { dialAddr: multiaddr(components.slice(0, -1)).toString(), peerId };
116
+ }
117
+
118
+ /**
119
+ * The slice of `@libp2p/utils`' cuckoo `Filter` the reservation store remembers failed relays in.
120
+ * `remove` is optional on that interface, so its absence must degrade, never throw.
121
+ */
122
+ export interface RelayFilterLike {
123
+ has(item: Uint8Array): boolean;
124
+ remove?(item: Uint8Array): boolean;
125
+ }
126
+
127
+ /**
128
+ * The slice of `@libp2p/circuit-relay-v2`'s transport-side `ReservationStore` this module drives.
129
+ * Structural, because the class is internal to that package: naming only the members used keeps
130
+ * the coupling small and greppable, and lets {@link findCircuitRelayTransport} duck-type it.
131
+ */
132
+ export interface RelayReservationStoreLike {
133
+ addRelay(peerId: PeerId, type: 'discovered' | 'configured'): Promise<unknown>;
134
+ hasReservation(peerId: PeerId): boolean;
135
+ relayFilter?: RelayFilterLike;
136
+ }
137
+
138
+ /** The slice of libp2p's circuit-relay transport that owns the reservation store. */
139
+ export interface CircuitRelayTransportLike {
140
+ reservationStore: RelayReservationStoreLike;
141
+ }
142
+
143
+ /**
144
+ * The running node's circuit-relay transport, or `null` when it has none.
145
+ *
146
+ * NOTE: the ONE place this package reaches libp2p internals for relay reservations, pinned against
147
+ * `libp2p` 3.1.3 and `@libp2p/circuit-relay-v2` 4.1.3 by `test/relay-reservation-seam.spec.ts`,
148
+ * which must fail loudly if an upgrade moves any of it. There is no public route: the `Libp2p`
149
+ * interface has no `listen`, and nothing public exposes the reservation store. `node.components`
150
+ * is a real public field on libp2p's node class, just not on the interface; `transportManager`
151
+ * is a component; the circuit-relay transport is the one whose `reservationStore` has `addRelay`
152
+ * and `hasReservation`. What the store does on 4.1.3, read from
153
+ * `node_modules/@libp2p/circuit-relay-v2/src/transport/`: `listener.ts` publishes a `configured`
154
+ * reservation only from inside `listen()` and ignores it on `relay:created-reservation`, while a
155
+ * bare listener publishes any `discovered` reservation carrying its pending id; and
156
+ * `reservation-store.ts` drops a reservation on `connection:close`, re-queues the pending id only
157
+ * for `discovered` ones, removes-then-recreates on refresh, and adds a relay whose request failed
158
+ * with `DialError` or `UnsupportedProtocolError` to the private `relayFilter`, refusing it
159
+ * afterwards with "The relay was previously invalid" until the filter is reset.
160
+ */
161
+ export function findCircuitRelayTransport(node: Libp2p): CircuitRelayTransportLike | null {
162
+ for (const transport of nodeTransports(node)) {
163
+ const store = (transport as { reservationStore?: unknown }).reservationStore;
164
+ if (isReservationStore(store)) {
165
+ return { reservationStore: store };
166
+ }
167
+ }
168
+ return null;
169
+ }
170
+
171
+ /** `components` is a real field on libp2p's node class but not on the `Libp2p` interface. */
172
+ interface Libp2pComponentsLike {
173
+ components?: {
174
+ transportManager?: {
175
+ getTransports?: () => readonly unknown[];
176
+ };
177
+ };
178
+ }
179
+
180
+ function nodeTransports(node: Libp2p): readonly unknown[] {
181
+ const manager = (node as Libp2p & Libp2pComponentsLike).components?.transportManager;
182
+ if (typeof manager?.getTransports !== 'function') {
183
+ log.error('no transportManager.getTransports() on this node — libp2p internals moved?');
184
+ return [];
185
+ }
186
+ try {
187
+ return manager.getTransports();
188
+ } catch (err) {
189
+ log.error('transportManager.getTransports() threw: %o', err);
190
+ return [];
191
+ }
192
+ }
193
+
194
+ function isReservationStore(value: unknown): value is RelayReservationStoreLike {
195
+ if (typeof value !== 'object' || value === null) return false;
196
+ const store = value as Partial<RelayReservationStoreLike>;
197
+ return typeof store.addRelay === 'function' && typeof store.hasReservation === 'function';
198
+ }
199
+
200
+ /**
201
+ * Fail node creation, before anything starts, when a listen address names a relay but no transport
202
+ * could ever reserve on it. libp2p would reject the bare `/p2p-circuit` entry itself during
203
+ * `start()`, with a generic unsupported-listen-address error; this names the actual omission.
204
+ */
205
+ export function assertCircuitRelayTransport(node: Libp2p, relays: readonly SupervisedRelay[]): void {
206
+ if (relays.length === 0) return;
207
+ if (findCircuitRelayTransport(node) !== null) return;
208
+ throw new Error(
209
+ `listen address names a relay (${relays[0]!.dialAddr}) but the node has no circuit-relay transport — add circuitRelayTransport() to transports`
210
+ );
211
+ }
212
+
213
+ /**
214
+ * Fail node creation, before anything is built, when a listen address names a relay but
215
+ * `announceAddrs` is set. libp2p advertises ONLY the announce set then (the readme's "replaces the
216
+ * advertised set entirely"), so the circuit address through the relay could never appear in
217
+ * `node.getMultiaddrs()`, nobody could learn it, and every supervisor drive would time out waiting
218
+ * for it: node creation would reject after a full drive deadline with a message about publishing
219
+ * rather than about the configuration. `appendAnnounceAddrs` keeps the listener addresses and is fine.
220
+ */
221
+ export function assertRelayAddrsAdvertisable(relays: readonly SupervisedRelay[], announceAddrs: readonly string[] | undefined): void {
222
+ if (relays.length === 0 || announceAddrs === undefined || announceAddrs.length === 0) return;
223
+ throw new Error(
224
+ `listen address names a relay (${relays[0]!.dialAddr}) but announceAddrs replaces the advertised address set, so the circuit address through it could never be advertised — drop announceAddrs or use appendAnnounceAddrs`
225
+ );
226
+ }
227
+
228
+ /**
229
+ * Forget that a relay ever failed a reservation request, so the next request is actually made
230
+ * rather than refused with "The relay was previously invalid".
231
+ *
232
+ * The store resets its filter when a reservation is removed while a pending slot exists, which a
233
+ * bare listener always has, so this is a belt-and-braces step: the poisoning path this guards is a
234
+ * failed request from libp2p's own relay discovery while the relay was down. Fails soft in every
235
+ * direction; returns whether an entry was actually removed (for specs).
236
+ */
237
+ export function clearRelayFilterEntry(store: RelayReservationStoreLike, relayPeerId: PeerId): boolean {
238
+ const filter = store.relayFilter;
239
+ if (typeof filter?.remove !== 'function') return false;
240
+ try {
241
+ return filter.remove(relayPeerId.toMultihash().bytes);
242
+ } catch (err) {
243
+ log('could not clear the relayFilter entry for %s: %o', relayPeerId.toString(), err);
244
+ return false;
245
+ }
246
+ }
247
+
248
+ /** Deadline for one drive: dial, reservation request and the wait for the address share it. */
249
+ export const DEFAULT_RELAY_DRIVE_TIMEOUT_MS = 10_000;
250
+ /** Gap between liveness checks while the reservation is held; a fallback in case an event is missed. */
251
+ export const DEFAULT_RELAY_CHECK_MS = 5_000;
252
+ /** Backoff before the first retry after a failed drive. */
253
+ export const DEFAULT_RELAY_MIN_BACKOFF_MS = 1_000;
254
+ /**
255
+ * Backoff ceiling. It bounds recovery time after a long relay outage, because nothing else
256
+ * reconnects a lone phone to its relay; a WebSocket dial every 30 s during an outage is cheap.
257
+ */
258
+ export const DEFAULT_RELAY_MAX_BACKOFF_MS = 30_000;
259
+ /** How often a drive re-reads the address list while waiting for the address to be published. */
260
+ export const DEFAULT_RELAY_POLL_MS = 100;
261
+
262
+ export interface RelayReservationSupervisorOptions {
263
+ /** Default {@link DEFAULT_RELAY_DRIVE_TIMEOUT_MS}. */
264
+ driveTimeoutMs?: number;
265
+ /** Default {@link DEFAULT_RELAY_CHECK_MS}. */
266
+ checkMs?: number;
267
+ /** Default {@link DEFAULT_RELAY_MIN_BACKOFF_MS}. */
268
+ minBackoffMs?: number;
269
+ /** Default {@link DEFAULT_RELAY_MAX_BACKOFF_MS}. */
270
+ maxBackoffMs?: number;
271
+ /** Default {@link DEFAULT_RELAY_POLL_MS}. */
272
+ pollMs?: number;
273
+ }
274
+
275
+ /** A running supervisor for one node and one relay. Obtained from {@link superviseRelayReservation}. */
276
+ export interface RelayReservationSupervisor {
277
+ readonly relay: SupervisedRelay;
278
+ /**
279
+ * Settles once the first drive has: `null` when the reservation was held, otherwise the reason
280
+ * it was not. Also settles on {@link stop}, so an awaiting caller is never left hanging.
281
+ */
282
+ readonly firstDrive: Promise<string | null>;
283
+ /** Whether the node currently advertises a circuit address through this relay. Read live. */
284
+ readonly held: boolean;
285
+ /** True while a drive is in flight. */
286
+ readonly driving: boolean;
287
+ /** Epoch ms of the next scheduled check or retry; `null` while driving or once stopped. */
288
+ readonly retryAtMs: number | null;
289
+ /** Reason the last drive produced no reservation; `null` once one is held. */
290
+ readonly lastError: string | null;
291
+ /**
292
+ * Idempotent. Clears the pending timer, removes both event listeners, aborts an in-flight dial,
293
+ * and discards the result of an in-flight reservation request without starting anything after it.
294
+ */
295
+ stop(): void;
296
+ }
297
+
298
+ /**
299
+ * Keep asking `relay` for a reservation until one is held, then keep watching that it still is.
300
+ *
301
+ * Drives run on: the first tick, right away; libp2p's `self:peer:update` (the listener withdrew or
302
+ * added addresses); `peer:connect` for this relay (a dial by anything on this node reconnected it,
303
+ * which turns a partner's dial through the relay into an immediate re-reserve, and resets the
304
+ * backoff); a liveness poll every `checkMs` while held; and a doubling backoff from `minBackoffMs`
305
+ * to `maxBackoffMs` after a failed drive. Drives are serialized per relay: a trigger that arrives
306
+ * mid-drive does nothing by itself, because every drive ends with the same re-check and reschedule.
307
+ *
308
+ * `HadEnoughRelaysError` from the reservation store means this node's pending slot was already
309
+ * filled with a reservation on a different relay (libp2p's relay discovery, see the accepted
310
+ * tradeoff in `libp2p-node-base.ts`); it is logged once per episode and retried at the backoff cap, since it
311
+ * recovers on its own only if that other reservation drops.
312
+ */
313
+ export function superviseRelayReservation(
314
+ node: Libp2p,
315
+ relay: SupervisedRelay,
316
+ opts: RelayReservationSupervisorOptions = {}
317
+ ): RelayReservationSupervisor {
318
+ return new RelayReservationLoop(node, relay, opts);
319
+ }
320
+
321
+ /** Every supervisor a node runs, with the two operations the node factory needs. */
322
+ export interface RelayReservationSupervisors {
323
+ readonly supervisors: readonly RelayReservationSupervisor[];
324
+ /** Resolves once every first drive held its reservation; rejects naming every relay that did not. */
325
+ awaitFirstDrives(): Promise<void>;
326
+ /** Stops every supervisor. Idempotent. */
327
+ stop(): void;
328
+ }
329
+
330
+ /** One {@link superviseRelayReservation} per relay, started immediately. */
331
+ export function superviseRelayReservations(
332
+ node: Libp2p,
333
+ relays: readonly SupervisedRelay[],
334
+ opts: RelayReservationSupervisorOptions = {}
335
+ ): RelayReservationSupervisors {
336
+ const supervisors = relays.map(relay => superviseRelayReservation(node, relay, opts));
337
+ return {
338
+ supervisors,
339
+ async awaitFirstDrives(): Promise<void> {
340
+ const outcomes = await Promise.all(supervisors.map(async s => ({ relay: s.relay, reason: await s.firstDrive })));
341
+ const failed = outcomes.filter(o => o.reason !== null);
342
+ if (failed.length === 0) return;
343
+ throw new Error(failed.map(f => `could not reserve a circuit on relay ${f.relay.dialAddr}: ${f.reason}`).join('; '));
344
+ },
345
+ stop(): void {
346
+ for (const s of supervisors) s.stop();
347
+ }
348
+ };
349
+ }
350
+
351
+ /** Sentinel resolved by the deadline race around `addRelay`, which takes no signal. */
352
+ const DEADLINE_PASSED = Symbol('relay-reservation-deadline-passed');
353
+
354
+ /** Milliseconds left until `deadline`, never negative. */
355
+ const remaining = (deadline: number): number => Math.max(0, deadline - Date.now());
356
+
357
+ const errorMessage = (err: unknown): string => (err instanceof Error ? err.message : String(err));
358
+
359
+ /** A pending timer must not keep a stopped Node process alive; browsers and React Native have no `unref`. */
360
+ function unref(timer: ReturnType<typeof setTimeout>): void {
361
+ (timer as { unref?: () => void }).unref?.();
362
+ }
363
+
364
+ /** Resolve after `ms`, on an unref'd timer. */
365
+ function delay(ms: number): Promise<void> {
366
+ return new Promise(resolve => {
367
+ unref(setTimeout(resolve, ms));
368
+ });
369
+ }
370
+
371
+ /** The self-rescheduling loop behind {@link superviseRelayReservation}. */
372
+ class RelayReservationLoop implements RelayReservationSupervisor {
373
+ readonly firstDrive: Promise<string | null>;
374
+
375
+ private readonly relayPeerId: PeerId;
376
+ private readonly driveTimeoutMs: number;
377
+ private readonly checkMs: number;
378
+ private readonly minBackoffMs: number;
379
+ private readonly maxBackoffMs: number;
380
+ private readonly pollMs: number;
381
+ private backoffMs: number;
382
+ private timer: ReturnType<typeof setTimeout> | null = null;
383
+ private stopped = false;
384
+ private inFlight = false;
385
+ private nextTickAtMs: number | null = null;
386
+ private failure: string | null = null;
387
+ private dialAbort: AbortController | null = null;
388
+ private slotTakenLogged = false;
389
+ private settleFirstDrive: (reason: string | null) => void = () => {};
390
+ private firstDriveSettled = false;
391
+
392
+ constructor(
393
+ private readonly node: Libp2p,
394
+ readonly relay: SupervisedRelay,
395
+ opts: RelayReservationSupervisorOptions
396
+ ) {
397
+ this.relayPeerId = peerIdFromString(relay.peerId);
398
+ // Clamped away from 0: a caller-supplied 0 would spin the loop hot.
399
+ this.driveTimeoutMs = Math.max(1, opts.driveTimeoutMs ?? DEFAULT_RELAY_DRIVE_TIMEOUT_MS);
400
+ this.checkMs = Math.max(1, opts.checkMs ?? DEFAULT_RELAY_CHECK_MS);
401
+ this.minBackoffMs = Math.max(1, opts.minBackoffMs ?? DEFAULT_RELAY_MIN_BACKOFF_MS);
402
+ this.maxBackoffMs = Math.max(this.minBackoffMs, opts.maxBackoffMs ?? DEFAULT_RELAY_MAX_BACKOFF_MS);
403
+ this.pollMs = Math.max(1, opts.pollMs ?? DEFAULT_RELAY_POLL_MS);
404
+ this.backoffMs = this.minBackoffMs;
405
+ this.firstDrive = new Promise(resolve => {
406
+ this.settleFirstDrive = resolve;
407
+ });
408
+ node.addEventListener('self:peer:update', this.onSelfPeerUpdate);
409
+ node.addEventListener('peer:connect', this.onPeerConnect);
410
+ void this.tick('start');
411
+ }
412
+
413
+ get held(): boolean {
414
+ return this.node.getMultiaddrs().some(addr => routesThroughRelay(addr.toString(), this.relay.peerId, log));
415
+ }
416
+
417
+ get driving(): boolean {
418
+ return this.inFlight;
419
+ }
420
+
421
+ get retryAtMs(): number | null {
422
+ return this.nextTickAtMs;
423
+ }
424
+
425
+ get lastError(): string | null {
426
+ return this.failure;
427
+ }
428
+
429
+ stop(): void {
430
+ if (this.stopped) return;
431
+ this.stopped = true;
432
+ this.clearTimer();
433
+ this.nextTickAtMs = null;
434
+ this.node.removeEventListener('self:peer:update', this.onSelfPeerUpdate);
435
+ this.node.removeEventListener('peer:connect', this.onPeerConnect);
436
+ this.dialAbort?.abort();
437
+ this.settleFirst('the relay reservation supervisor was stopped before its first drive settled');
438
+ }
439
+
440
+ private readonly onSelfPeerUpdate = (): void => {
441
+ this.wake('self:peer:update', false);
442
+ };
443
+
444
+ private readonly onPeerConnect = (evt: CustomEvent<PeerId>): void => {
445
+ if (!evt.detail.equals(this.relayPeerId)) return;
446
+ this.wake('peer:connect', true);
447
+ };
448
+
449
+ /**
450
+ * Something may have changed the reservation: re-check now. Mid-drive, only the backoff reset
451
+ * takes effect — the drive's own tail re-checks and reschedules, so nothing is lost.
452
+ */
453
+ private wake(trigger: string, resetBackoff: boolean): void {
454
+ if (this.stopped) return;
455
+ if (resetBackoff) this.backoffMs = this.minBackoffMs;
456
+ if (this.inFlight) return;
457
+ this.clearTimer();
458
+ void this.tick(trigger);
459
+ }
460
+
461
+ private async tick(trigger: string): Promise<void> {
462
+ if (this.stopped || this.inFlight) return;
463
+ this.nextTickAtMs = null;
464
+ if (this.held) {
465
+ this.onHeld();
466
+ return;
467
+ }
468
+ await this.driveOnce(trigger);
469
+ if (this.stopped) return;
470
+ // A drive that landed the reservation resumes the healthy cadence at once; backing off here
471
+ // would leave the first liveness check after a long outage a fully grown backoff away.
472
+ if (this.held) {
473
+ this.onHeld();
474
+ return;
475
+ }
476
+ this.scheduleBackoff();
477
+ }
478
+
479
+ /** Healthy tick: nothing to request, so only reset and re-check later. */
480
+ private onHeld(): void {
481
+ this.backoffMs = this.minBackoffMs;
482
+ this.failure = null;
483
+ // Once held, a later slot-taken episode is a new event and deserves its own log line.
484
+ this.slotTakenLogged = false;
485
+ this.settleFirst(null);
486
+ this.schedule(this.checkMs);
487
+ }
488
+
489
+ private async driveOnce(trigger: string): Promise<void> {
490
+ this.inFlight = true;
491
+ log('relay-reservation:drive relay=%s trigger=%s', this.relay.dialAddr, trigger);
492
+ try {
493
+ const reason = await this.drive();
494
+ if (this.stopped) return;
495
+ this.failure = reason;
496
+ if (reason === null) {
497
+ log('relay-reservation:held relay=%s', this.relay.dialAddr);
498
+ } else {
499
+ log.error('relay-reservation:failed relay=%s reason=%s', this.relay.dialAddr, reason);
500
+ }
501
+ } catch (err) {
502
+ // `drive` is fail-soft by contract, so reaching here means that contract broke. The loop
503
+ // must survive it anyway: an escaping rejection would leave `firstDrive` pending forever
504
+ // and schedule no further attempt, which is the failure this supervisor exists to prevent.
505
+ const message = errorMessage(err);
506
+ log.error('relay-reservation:drive-threw relay=%s err=%s', this.relay.dialAddr, message);
507
+ if (!this.stopped) this.failure = message;
508
+ } finally {
509
+ this.inFlight = false;
510
+ if (!this.stopped) this.settleFirst(this.failure);
511
+ }
512
+ }
513
+
514
+ /** One attempt under one deadline. Never throws; returns `null` when the address is held afterwards. */
515
+ private async drive(): Promise<string | null> {
516
+ const transport = findCircuitRelayTransport(this.node);
517
+ if (transport === null) {
518
+ return 'the node has no circuit-relay transport — add circuitRelayTransport() to transports';
519
+ }
520
+ const deadline = Date.now() + this.driveTimeoutMs;
521
+ clearRelayFilterEntry(transport.reservationStore, this.relayPeerId);
522
+ const dialFailure = await this.dialRelay(deadline);
523
+ if (dialFailure !== null) return dialFailure;
524
+ if (this.stopped) return 'stopped';
525
+ const requestFailure = await this.requestReservation(transport.reservationStore, deadline);
526
+ if (requestFailure !== null) return requestFailure;
527
+ if (await this.waitUntilHeld(deadline)) return null;
528
+ return `relay ${this.relay.dialAddr} granted a reservation but no circuit address through it was published within ${this.driveTimeoutMs}ms`;
529
+ }
530
+
531
+ /**
532
+ * Dial the relay, or return why that failed. An explicit controller rather than
533
+ * `AbortSignal.timeout`, which is unreliable on Hermes; {@link stop} aborts it too.
534
+ */
535
+ private async dialRelay(deadline: number): Promise<string | null> {
536
+ const controller = new AbortController();
537
+ this.dialAbort = controller;
538
+ const timer = setTimeout(() => controller.abort(), remaining(deadline));
539
+ unref(timer);
540
+ try {
541
+ await this.node.dial(multiaddr(this.relay.dialAddr), { signal: controller.signal });
542
+ return null;
543
+ } catch (err) {
544
+ return `dial to relay ${this.relay.dialAddr} failed: ${errorMessage(err)}`;
545
+ } finally {
546
+ clearTimeout(timer);
547
+ this.dialAbort = null;
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Ask the store for a `discovered` reservation on the relay, raced against the deadline because
553
+ * `addRelay` takes no signal. `'discovered'`, never `'configured'`: only a `discovered`
554
+ * reservation is published by the bare listener the plan gave libp2p. A concurrent `addRelay`
555
+ * for the same relay from libp2p's own discovery joins the same queue job, so there is no
556
+ * double reservation. The abandoned promise stays handled — `Promise.race` attaches handlers.
557
+ */
558
+ private async requestReservation(store: RelayReservationStoreLike, deadline: number): Promise<string | null> {
559
+ let timer: ReturnType<typeof setTimeout> | undefined;
560
+ const expired = new Promise<typeof DEADLINE_PASSED>(resolve => {
561
+ timer = setTimeout(() => resolve(DEADLINE_PASSED), remaining(deadline));
562
+ unref(timer);
563
+ });
564
+ try {
565
+ const outcome = await Promise.race([store.addRelay(this.relayPeerId, 'discovered'), expired]);
566
+ if (outcome === DEADLINE_PASSED) {
567
+ return `reservation request to relay ${this.relay.dialAddr} did not complete within ${this.driveTimeoutMs}ms`;
568
+ }
569
+ return null;
570
+ } catch (err) {
571
+ return this.describeReservationFailure(err);
572
+ } finally {
573
+ clearTimeout(timer);
574
+ }
575
+ }
576
+
577
+ /** Turn the store's rejection into a reason that names the cause, and apply its consequence. */
578
+ private describeReservationFailure(err: unknown): string {
579
+ const name = err instanceof Error ? err.name : '';
580
+ switch (name) {
581
+ case 'HadEnoughRelaysError': {
582
+ // The pending slot the bare listener registered is already filled, by a reservation on
583
+ // some other relay. Nothing this supervisor does frees it, so retry only at the cap.
584
+ this.backoffMs = this.maxBackoffMs;
585
+ const reason = `this node's pending circuit slot is already filled by a reservation on another relay, so relay ${this.relay.dialAddr} was not asked; retrying every ${this.maxBackoffMs}ms until that reservation drops`;
586
+ if (!this.slotTakenLogged) {
587
+ this.slotTakenLogged = true;
588
+ log.error('relay-reservation:slot-taken relay=%s', this.relay.dialAddr);
589
+ }
590
+ return reason;
591
+ }
592
+ case 'UnsupportedProtocolError':
593
+ return `the peer at ${this.relay.dialAddr} does not serve the circuit-relay hop protocol, so it is not a relay`;
594
+ default:
595
+ return `reservation request to relay ${this.relay.dialAddr} failed: ${errorMessage(err)}`;
596
+ }
597
+ }
598
+
599
+ /** `addRelay` resolving means the relay accepted; the listener publishes the address a tick later. */
600
+ private async waitUntilHeld(deadline: number): Promise<boolean> {
601
+ for (;;) {
602
+ if (this.held) return true;
603
+ if (this.stopped || Date.now() >= deadline) return false;
604
+ await delay(Math.min(this.pollMs, remaining(deadline)));
605
+ }
606
+ }
607
+
608
+ private scheduleBackoff(): void {
609
+ const wait = this.backoffMs;
610
+ this.backoffMs = Math.min(this.backoffMs * 2, this.maxBackoffMs);
611
+ this.schedule(wait);
612
+ }
613
+
614
+ private schedule(ms: number): void {
615
+ if (this.stopped) return;
616
+ this.clearTimer();
617
+ this.nextTickAtMs = Date.now() + ms;
618
+ this.timer = setTimeout(() => {
619
+ this.timer = null;
620
+ void this.tick('timer');
621
+ }, ms);
622
+ unref(this.timer);
623
+ }
624
+
625
+ private clearTimer(): void {
626
+ if (this.timer === null) return;
627
+ clearTimeout(this.timer);
628
+ this.timer = null;
629
+ }
630
+
631
+ private settleFirst(reason: string | null): void {
632
+ if (this.firstDriveSettled) return;
633
+ this.firstDriveSettled = true;
634
+ this.settleFirstDrive(reason);
635
+ }
636
+ }
@@ -38,8 +38,9 @@ export type PendValidationEvent =
38
38
  *
39
39
  * Two decisions live here:
40
40
  *
41
- * - **No `validation` pair** (the single-collection `Collection.sync` shape bare transforms, so
42
- * there is nothing to re-execute): an explicit, LOGGED policy branch on both arms, never a silent
41
+ * - **No `validation` pair** (bare transforms, so there is nothing to re-execute: the
42
+ * single-collection `Collection.sync` shape, and the Quereus adapter's legacy multi-tree commit,
43
+ * whose coordinator is built with `pendValidation: 'none'`): an explicit, LOGGED policy branch on both arms, never a silent
43
44
  * fall-through. `'accept'` admits it unchecked, preserving the historical behaviour; `'reject'`
44
45
  * refuses with {@link PEND_NOT_VALIDATABLE}. The presence test is on the whole pair, so a sender
45
46
  * cannot talk a receiver out of validating by omitting half of it.
@@ -1,7 +1,8 @@
1
1
  import { pipe } from 'it-pipe';
2
2
  import { encode as lpEncode, decode as lpDecode } from 'it-length-prefixed';
3
3
  import type { Stream as Libp2pStream } from '@libp2p/interface';
4
- import type { PeerId, IPeerNetwork } from '@optimystic/db-core';
4
+ import type { PeerId, IPeerNetwork, IKeyNetwork, BlockId } from '@optimystic/db-core';
5
+ import { routingKeyForBlock } from '@optimystic/db-core';
5
6
  import { first } from './it-utility.js';
6
7
  import { createLogger } from './logger.js';
7
8
  import { MAX_BLOCK_MESSAGE_BYTES } from './protocol-limits.js';
@@ -48,6 +49,17 @@ export class ProtocolClient {
48
49
  protected readonly peerNetwork: IPeerNetwork,
49
50
  ) { }
50
51
 
52
+ /**
53
+ * Remember that `coordinator` coordinates `blockId`, so a follow-up op on that block dials it directly
54
+ * instead of being redirected again. Only a key network keeps coordinator hints — a plain
55
+ * {@link IPeerNetwork} omits `recordCoordinator`, and this is then a no-op. The hint is keyed on the
56
+ * block's routing key, the key a later `findCoordinator` looks it up by.
57
+ */
58
+ protected recordCoordinatorHint(blockId: BlockId, coordinator: PeerId): void {
59
+ const hints = this.peerNetwork as IPeerNetwork & Partial<Pick<IKeyNetwork, 'recordCoordinator'>>;
60
+ hints.recordCoordinator?.(routingKeyForBlock(blockId), coordinator);
61
+ }
62
+
51
63
  protected async processMessage<T>(
52
64
  message: unknown,
53
65
  protocol: string,
@@ -311,7 +311,8 @@ export class ReactivityForwarderHost {
311
311
  * (`isDraining(now)`) returns its {@link RotationRedirectV1}; once `T_drain` closes, evicts the gate **and**
312
312
  * the topic's served `PushState` (the old tail originates nothing further — this also reclaims the
313
313
  * `served` / `ingestTails` maps the 12.31 review flagged as un-evicted on rotation) and returns `undefined`,
314
- * so the next request gets no reply and the subscriber re-walks / chain-reads.
314
+ * so this member declines the next request and the subscriber's transport tries the next cohort member
315
+ * (re-walking / chain-reading only once every member declines).
315
316
  */
316
317
  rotationRedirectFor(oldTopicId: Uint8Array, now: number): RotationRedirectV1 | undefined {
317
318
  const key = this.topicKey(oldTopicId);