@optimystic/db-p2p 0.22.0 → 0.24.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 (177) hide show
  1. package/dist/src/cluster/client.d.ts +10 -0
  2. package/dist/src/cluster/client.d.ts.map +1 -1
  3. package/dist/src/cluster/client.js +30 -1
  4. package/dist/src/cluster/client.js.map +1 -1
  5. package/dist/src/cluster/cluster-repo.d.ts +39 -14
  6. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  7. package/dist/src/cluster/cluster-repo.js +226 -119
  8. package/dist/src/cluster/cluster-repo.js.map +1 -1
  9. package/dist/src/cluster/service.d.ts +32 -1
  10. package/dist/src/cluster/service.d.ts.map +1 -1
  11. package/dist/src/cluster/service.js +43 -2
  12. package/dist/src/cluster/service.js.map +1 -1
  13. package/dist/src/cohort-topic/stream-util.d.ts +22 -6
  14. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  15. package/dist/src/cohort-topic/stream-util.js +56 -10
  16. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  17. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  18. package/dist/src/dispute/dispute-service.js +9 -3
  19. package/dist/src/dispute/dispute-service.js.map +1 -1
  20. package/dist/src/index.d.ts +3 -0
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/index.js +3 -0
  23. package/dist/src/index.js.map +1 -1
  24. package/dist/src/libp2p-key-network.d.ts +88 -2
  25. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  26. package/dist/src/libp2p-key-network.js +134 -28
  27. package/dist/src/libp2p-key-network.js.map +1 -1
  28. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  29. package/dist/src/libp2p-node-base.js +25 -1
  30. package/dist/src/libp2p-node-base.js.map +1 -1
  31. package/dist/src/logger.d.ts +17 -1
  32. package/dist/src/logger.d.ts.map +1 -1
  33. package/dist/src/logger.js +19 -2
  34. package/dist/src/logger.js.map +1 -1
  35. package/dist/src/owned-block-seed.d.ts +6 -3
  36. package/dist/src/owned-block-seed.d.ts.map +1 -1
  37. package/dist/src/owned-block-seed.js +16 -3
  38. package/dist/src/owned-block-seed.js.map +1 -1
  39. package/dist/src/peer-address-book.d.ts +72 -0
  40. package/dist/src/peer-address-book.d.ts.map +1 -0
  41. package/dist/src/peer-address-book.js +123 -0
  42. package/dist/src/peer-address-book.js.map +1 -0
  43. package/dist/src/repo/client.d.ts.map +1 -1
  44. package/dist/src/repo/client.js +11 -2
  45. package/dist/src/repo/client.js.map +1 -1
  46. package/dist/src/repo/cluster-coordinator.d.ts +30 -0
  47. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  48. package/dist/src/repo/cluster-coordinator.js +95 -3
  49. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  50. package/dist/src/repo/coordinator-repo.d.ts +62 -9
  51. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  52. package/dist/src/repo/coordinator-repo.js +242 -73
  53. package/dist/src/repo/coordinator-repo.js.map +1 -1
  54. package/dist/src/rn.d.ts +3 -0
  55. package/dist/src/rn.d.ts.map +1 -1
  56. package/dist/src/rn.js +3 -0
  57. package/dist/src/rn.js.map +1 -1
  58. package/dist/src/storage/cached-raw-storage.d.ts +83 -0
  59. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -0
  60. package/dist/src/storage/cached-raw-storage.js +152 -0
  61. package/dist/src/storage/cached-raw-storage.js.map +1 -0
  62. package/dist/src/storage/cached-store-driver.d.ts +186 -0
  63. package/dist/src/storage/cached-store-driver.d.ts.map +1 -0
  64. package/dist/src/storage/cached-store-driver.js +775 -0
  65. package/dist/src/storage/cached-store-driver.js.map +1 -0
  66. package/dist/src/storage/i-raw-storage.d.ts +12 -5
  67. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  68. package/dist/src/storage/shared-cache-pool.d.ts +234 -0
  69. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -0
  70. package/dist/src/storage/shared-cache-pool.js +354 -0
  71. package/dist/src/storage/shared-cache-pool.js.map +1 -0
  72. package/dist/src/testing/raw-storage-conformance.d.ts +2 -1
  73. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  74. package/dist/src/testing/raw-storage-conformance.js +35 -2
  75. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  76. package/package.json +3 -3
  77. package/readme.md +668 -668
  78. package/src/cluster/block-transfer.ts +424 -424
  79. package/src/cluster/client.ts +119 -88
  80. package/src/cluster/cluster-error.ts +64 -64
  81. package/src/cluster/cluster-policy.ts +203 -203
  82. package/src/cluster/cluster-repo.ts +242 -122
  83. package/src/cluster/cluster-size-coupling.ts +45 -45
  84. package/src/cluster/commit-cert.ts +139 -139
  85. package/src/cluster/i-transaction-state-store.ts +43 -43
  86. package/src/cluster/memory-transaction-state-store.ts +56 -56
  87. package/src/cluster/peer-key-binding.ts +37 -37
  88. package/src/cluster/persistent-transaction-state-store.ts +92 -92
  89. package/src/cluster/quorum-restore.ts +223 -223
  90. package/src/cluster/reconcile-block.ts +203 -203
  91. package/src/cluster/service.ts +293 -241
  92. package/src/cluster/supermajority-coupling.ts +37 -37
  93. package/src/cohort-topic/bootstrap-evidence-builder.ts +122 -122
  94. package/src/cohort-topic/bootstrap-evidence-verifiers.ts +132 -132
  95. package/src/cohort-topic/bootstrap-parent-reference.ts +159 -159
  96. package/src/cohort-topic/change-bridge.ts +109 -109
  97. package/src/cohort-topic/cohort-gossip-driver.ts +231 -231
  98. package/src/cohort-topic/cohort-gossip-transport.ts +84 -84
  99. package/src/cohort-topic/fret-trust-anchor.ts +153 -153
  100. package/src/cohort-topic/host.ts +2901 -2901
  101. package/src/cohort-topic/index.ts +13 -13
  102. package/src/cohort-topic/membership-publish-sink.ts +20 -20
  103. package/src/cohort-topic/membership-source.ts +68 -68
  104. package/src/cohort-topic/peer-codec.ts +31 -31
  105. package/src/cohort-topic/peer-sig.ts +86 -86
  106. package/src/cohort-topic/protocols.ts +71 -71
  107. package/src/cohort-topic/reactivity-membership-gate.ts +77 -77
  108. package/src/cohort-topic/size-estimator.ts +16 -16
  109. package/src/cohort-topic/stream-util.ts +135 -87
  110. package/src/cohort-topic/threshold-crypto.ts +239 -239
  111. package/src/cohort-topic/topic-router.ts +77 -77
  112. package/src/dispute/arbitrator-selection.ts +138 -138
  113. package/src/dispute/cascade.ts +524 -524
  114. package/src/dispute/dispute-service.ts +11 -5
  115. package/src/dispute/invalidation.ts +625 -625
  116. package/src/inbound-authorization.ts +190 -190
  117. package/src/index.ts +52 -49
  118. package/src/libp2p-key-network.ts +1120 -990
  119. package/src/libp2p-node-base.ts +1675 -1651
  120. package/src/libp2p-node-rn.ts +30 -30
  121. package/src/libp2p-node.ts +36 -36
  122. package/src/logger.ts +19 -2
  123. package/src/matchmaking/aggregate-counts.ts +104 -104
  124. package/src/matchmaking/index.ts +20 -20
  125. package/src/matchmaking/module.ts +363 -363
  126. package/src/matchmaking/protocols.ts +51 -51
  127. package/src/matchmaking/provider-manager.ts +95 -95
  128. package/src/matchmaking/query-handler.ts +88 -88
  129. package/src/matchmaking/query-transport.ts +492 -492
  130. package/src/matchmaking/seeker-manager.ts +64 -64
  131. package/src/matchmaking/seeker-walk-client.ts +293 -293
  132. package/src/matchmaking/traffic-validation.ts +195 -195
  133. package/src/optimystic-node.ts +36 -36
  134. package/src/owned-block-seed.ts +53 -40
  135. package/src/peer-address-book.ts +149 -0
  136. package/src/protocol-limits.ts +33 -33
  137. package/src/reactivity/forwarder-host.ts +438 -438
  138. package/src/reactivity/index.ts +19 -19
  139. package/src/reactivity/notify-transport.ts +144 -144
  140. package/src/reactivity/origination-manager.ts +192 -192
  141. package/src/reactivity/protocols.ts +61 -61
  142. package/src/reactivity/push-state-gossip.ts +291 -291
  143. package/src/reactivity/recover-transport.ts +408 -408
  144. package/src/reactivity/rotation-rereg-scheduler.ts +256 -256
  145. package/src/reactivity/subscriber-registry.ts +96 -96
  146. package/src/reactivity/subscription-manager.ts +450 -450
  147. package/src/reactivity/topic-bytes.ts +37 -37
  148. package/src/repo/client.ts +12 -2
  149. package/src/repo/cluster-coordinator.ts +99 -3
  150. package/src/repo/coordinator-repo.ts +281 -74
  151. package/src/repo/types.ts +7 -7
  152. package/src/rn.ts +39 -36
  153. package/src/rpc-deadline.ts +45 -45
  154. package/src/storage/arachnode-partition.ts +74 -74
  155. package/src/storage/cached-raw-storage.ts +180 -0
  156. package/src/storage/cached-store-driver.ts +859 -0
  157. package/src/storage/i-kv-store.ts +8 -8
  158. package/src/storage/i-raw-storage.ts +12 -5
  159. package/src/storage/kv-raw-storage.ts +135 -135
  160. package/src/storage/memory-kv-store.ts +28 -28
  161. package/src/storage/memory-storage.ts +25 -25
  162. package/src/storage/memory-store-driver.ts +157 -157
  163. package/src/storage/raw-store-codec.ts +42 -42
  164. package/src/storage/raw-store-driver.ts +80 -80
  165. package/src/storage/ring-selector.ts +317 -317
  166. package/src/storage/ring-shift-coordinator.ts +271 -271
  167. package/src/storage/shared-cache-pool.ts +452 -0
  168. package/src/storage/storage-repo.ts +1014 -1014
  169. package/src/testing/cohort-topic-mesh-harness.ts +663 -663
  170. package/src/testing/index.ts +8 -8
  171. package/src/testing/matchmaking-mesh-harness.ts +475 -475
  172. package/src/testing/raw-storage-conformance.ts +453 -417
  173. package/src/testing/reactivity-mesh-harness.ts +922 -922
  174. package/dist/src/storage/restoration-coordinator-v2.d.ts +0 -67
  175. package/dist/src/storage/restoration-coordinator-v2.d.ts.map +0 -1
  176. package/dist/src/storage/restoration-coordinator-v2.js +0 -172
  177. package/dist/src/storage/restoration-coordinator-v2.js.map +0 -1
@@ -1,293 +1,293 @@
1
- /**
2
- * Matchmaking — seeker walk client (db-p2p, drives the hang-out-vs-continue walk over the substrate).
3
- *
4
- * `docs/matchmaking.md` §Hang-out vs. continue. This is the RPC orchestration on top of the pure
5
- * db-core {@link decide} engine: starting at `d_max`, it registers the seeker, issues `QueryV1`,
6
- * re-validates and dedupes returned providers, and on each `Accepted` reply asks {@link decide} whether
7
- * to finish, hang out (renew + requery on the `requery_interval_ms` poll cadence), or escalate (withdraw
8
- * + re-register one tier toward the root). At `d = 0` there is nowhere left to walk, so it hangs out for
9
- * the remaining patience and returns whatever matched (possibly `< wantCount`).
10
- *
11
- * Patience is a single budget that **drains across hops** (`docs/matchmaking.md` §Patience budgeting):
12
- * a wall-clock deadline is fixed at the start, so every walked tier and every hang-out poll consumes
13
- * the same `patienceMs` — escalation makes the next tier's hang-out progressively less attractive, which
14
- * is correct since the root is terminal.
15
- *
16
- * The transport, clock, sleep, and per-entry verifier are all injected, so the walk unit-tests without a
17
- * live libp2p stack (mock-tier e2e is a documented follow-on). This client implements the **poll path**
18
- * only; the arrival-push path (`pushOnArrival`) is a separate slice.
19
- *
20
- * Edge cases encoded here (`docs/matchmaking.md` §Edge cases — the rest live in {@link decide}):
21
- * - **`topicTraffic` absent (case 1):** the cohort is treated as zero-rate; the seeker issues one query
22
- * (immediate-match) and walks one tier toward the root **without** hanging out.
23
- * - **Stale `arrivalsPerMin = 0` after an epoch change (case 2):** never withdraw on a single zero
24
- * reading — the immediate `QueryV1` runs first; a cohort that actually holds `>= wantCount` providers
25
- * resolves to `done`, and only a query that also yields below threshold escalates.
26
- * - **`UnwillingCohort`/`UnwillingMember` (case 3):** the hang-out decision is never entered (no
27
- * `Accepted`); the walk terminates with whatever matched and standard substrate back-off applies.
28
- */
29
-
30
- import {
31
- decide,
32
- filterAcceptRatio,
33
- matchesFilter,
34
- newFilterAcceptRatioState,
35
- observeYield,
36
- verifyProviderEntry,
37
- DEFAULT_HANG_OUT_CONFIG,
38
- DEFAULT_MEAN_WANT_COUNT,
39
- FILTER_ACCEPT_RATIO_INITIAL,
40
- type CapabilityFilter,
41
- type EntrySigVerifier,
42
- type FilterAcceptRatioState,
43
- type HangOutConfig,
44
- type ProviderEntryV1,
45
- type QueryReplyV1,
46
- } from "@optimystic/db-core";
47
-
48
- /** A seeker register/probe reply at a tree tier (the matchmaking-relevant subset of `RegisterReplyV1`). */
49
- export interface SeekerProbeReply {
50
- readonly result: "accepted" | "no_state" | "promoted" | "unwilling_member" | "unwilling_cohort";
51
- /** Present on `accepted` and `promoted` (the substrate's barometer); absent triggers edge case 1. */
52
- readonly topicTraffic?: QueryReplyV1["topicTraffic"];
53
- /** Present on `promoted` — the tier to descend to. */
54
- readonly targetTier?: number;
55
- }
56
-
57
- /** The substrate seam the walk drives: register/query/renew/withdraw against a tree tier. */
58
- export interface SeekerWalkTransport {
59
- /** Register (or re-register) the seeker at tree tier `treeTier`; resolves the probe reply. */
60
- register(treeTier: number): Promise<SeekerProbeReply>;
61
- /** Issue a `QueryV1` against the cohort the seeker is currently registered with. */
62
- query(treeTier: number): Promise<QueryReplyV1>;
63
- /** Renew the live seeker registration (hang-out keep-alive via TTL renewal). */
64
- renew(): Promise<void>;
65
- /** Withdraw the seeker registration before escalating (polite `RenewV1` TTL = 0; optional). */
66
- withdraw(): Promise<void>;
67
- }
68
-
69
- /** Construction inputs for {@link SeekerWalkClient}. */
70
- export interface SeekerWalkClientDeps {
71
- readonly transport: SeekerWalkTransport;
72
- /** The matchmaking topic id (used to re-validate each forwarded entry's `registrationSig`). */
73
- readonly topicId: Uint8Array;
74
- /** Providers the seeker needs. */
75
- readonly wantCount: number;
76
- /** The starting tree tier `d_max`. */
77
- readonly dMax: number;
78
- /** Total patience budget (ms); drains across hops + hang-out. */
79
- readonly patienceMs: number;
80
- /** Optional capability filter (re-applied seeker-side over the returned set). */
81
- readonly filter?: CapabilityFilter;
82
- /** Per-entry signature verifier (db-p2p binds `verifyPeerSig`). */
83
- readonly verifyEntry: EntrySigVerifier;
84
- /** Hang-out decision config. Default {@link DEFAULT_HANG_OUT_CONFIG}. */
85
- readonly config?: HangOutConfig;
86
- /** Assumed competing-seeker mean `wantCount`. Default {@link DEFAULT_MEAN_WANT_COUNT}. */
87
- readonly meanWantCount?: number;
88
- /** Starting `filterAcceptRatio` (refined per walk). Default {@link FILTER_ACCEPT_RATIO_INITIAL}. */
89
- readonly filterAcceptRatioInitial?: number;
90
- /** Wall clock (unix ms); injectable for tests. Default `Date.now`. */
91
- readonly clock?: () => number;
92
- /** Sleep for the requery cadence; injectable for tests. Default a real timer. */
93
- readonly sleep?: (ms: number) => Promise<void>;
94
- }
95
-
96
- /** The result of a completed walk. */
97
- export interface SeekerWalkResult {
98
- /** Matched, re-validated, deduped providers (up to whatever accumulated; may be `< wantCount`). */
99
- readonly providers: ProviderEntryV1[];
100
- /** Whether `wantCount` was met. */
101
- readonly metWantCount: boolean;
102
- /** The tier the walk terminated at. */
103
- readonly terminalTier: number;
104
- /** Total register hops issued (probes + escalations + descends). */
105
- readonly hops: number;
106
- /**
107
- * Max `topicTraffic.childCohortCount` observed across every `Accepted` reply this walk saw. `> 0`
108
- * means the topic has promoted (it is hot), so the single-cohort sample is unrepresentative — the
109
- * public seeker session / voting `QuorumDiscovery` binding uses this to decide whether to escalate to
110
- * the multi-cohort sweep (`docs/matchmaking.md` §Multi-cohort sweep).
111
- */
112
- readonly maxChildCohortCount: number;
113
- }
114
-
115
- /** Outcome of evaluating one `Accepted` tier. */
116
- type AcceptedOutcome = "done" | "escalate" | "terminal";
117
-
118
- /** Drives the seeker hang-out-vs-continue walk for one matchmaking topic. See the module header. */
119
- export class SeekerWalkClient {
120
- private readonly transport: SeekerWalkTransport;
121
- private readonly topicId: Uint8Array;
122
- private readonly wantCount: number;
123
- private readonly dMax: number;
124
- private readonly patienceMs: number;
125
- private readonly filter?: CapabilityFilter;
126
- private readonly verifyEntry: EntrySigVerifier;
127
- private readonly config: HangOutConfig;
128
- private readonly meanWantCount: number;
129
- private readonly filterAcceptRatioInitial: number;
130
- private readonly clock: () => number;
131
- private readonly sleep: (ms: number) => Promise<void>;
132
-
133
- /** Matched providers, deduped by `participantId` (a provider seen via two queries counts once). */
134
- private readonly matched = new Map<string, ProviderEntryV1>();
135
- private ratioState: FilterAcceptRatioState = newFilterAcceptRatioState();
136
- private deadline = 0;
137
- private hops = 0;
138
- /** Max `childCohortCount` seen across `Accepted` replies — the hot-topic / sweep-escalation signal. */
139
- private maxChildCohortCount = 0;
140
-
141
- constructor(deps: SeekerWalkClientDeps) {
142
- this.transport = deps.transport;
143
- this.topicId = deps.topicId;
144
- this.wantCount = deps.wantCount;
145
- this.dMax = deps.dMax;
146
- this.patienceMs = deps.patienceMs;
147
- this.filter = deps.filter;
148
- this.verifyEntry = deps.verifyEntry;
149
- this.config = deps.config ?? DEFAULT_HANG_OUT_CONFIG;
150
- this.meanWantCount = deps.meanWantCount ?? DEFAULT_MEAN_WANT_COUNT;
151
- this.filterAcceptRatioInitial = deps.filterAcceptRatioInitial ?? FILTER_ACCEPT_RATIO_INITIAL;
152
- this.clock = deps.clock ?? ((): number => Date.now());
153
- this.sleep = deps.sleep ?? ((ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)));
154
- }
155
-
156
- /** Run the walk from `d_max` toward the root; resolves the matched providers + termination info. */
157
- async run(): Promise<SeekerWalkResult> {
158
- this.deadline = this.clock() + this.patienceMs;
159
- let d = this.dMax;
160
-
161
- while (true) {
162
- this.hops++;
163
- const reply = await this.transport.register(d);
164
- switch (reply.result) {
165
- case "no_state": {
166
- if (d <= 0) {
167
- return this.finish(d);
168
- }
169
- d -= 1;
170
- continue;
171
- }
172
- case "unwilling_member":
173
- case "unwilling_cohort": {
174
- // Substrate-level refusal — never received Accepted, so hang-out is not entered (case 3).
175
- return this.finish(d);
176
- }
177
- case "promoted": {
178
- const target = reply.targetTier ?? d + 1;
179
- if (target <= d) {
180
- return this.finish(d);
181
- }
182
- d = target;
183
- continue;
184
- }
185
- case "accepted": {
186
- const outcome = await this.handleAccepted(d, reply.topicTraffic);
187
- if (outcome === "done" || outcome === "terminal") {
188
- return this.finish(d);
189
- }
190
- await this.transport.withdraw();
191
- d -= 1;
192
- continue;
193
- }
194
- }
195
- }
196
- }
197
-
198
- /** Remaining patience (ms); the wall-clock deadline drains uniformly across hops + hang-out. */
199
- private remaining(): number {
200
- return Math.max(0, this.deadline - this.clock());
201
- }
202
-
203
- /** Filter, re-validate (`registrationSig`), and dedupe a query reply's providers into {@link matched}. */
204
- private collect(reply: QueryReplyV1): void {
205
- const returned = reply.providers ?? [];
206
- let matchedThisQuery = 0;
207
- for (const entry of returned) {
208
- if (!matchesFilter(entry, this.filter)) {
209
- continue;
210
- }
211
- if (!verifyProviderEntry(this.topicId, entry, this.verifyEntry)) {
212
- continue;
213
- }
214
- matchedThisQuery++;
215
- this.matched.set(entry.participantId, entry);
216
- }
217
- this.ratioState = observeYield(this.ratioState, matchedThisQuery, returned.length);
218
- }
219
-
220
- /** Evaluate one `Accepted` tier: immediate query, then {@link decide} → done / hangOut / escalate. */
221
- private async handleAccepted(d: number, traffic: SeekerProbeReply["topicTraffic"]): Promise<AcceptedOutcome> {
222
- // Record the hottest tier seen as soon as this Accepted reply's traffic is available — *before* the
223
- // immediate-match/done short-circuit below, so the single-cohort-vs-sweep decision (public session /
224
- // voting QuorumDiscovery binding) still sees a hot topic even when one cohort's query already met
225
- // wantCount. Folding it only on the `decide` path would drop the signal for a small quorum that a
226
- // single hot cohort satisfies, leaving the assembler with a prefix-biased sample it should sweep.
227
- if (traffic !== undefined) {
228
- this.maxChildCohortCount = Math.max(this.maxChildCohortCount, traffic.childCohortCount);
229
- }
230
-
231
- // Immediate-match check runs in every case — also satisfies edge case 2 (a stale arrivalsPerMin=0
232
- // cohort that actually holds enough providers resolves here rather than escalating spuriously).
233
- this.collect(await this.transport.query(d));
234
- if (this.matched.size >= this.wantCount) {
235
- return "done";
236
- }
237
-
238
- // Edge case 1: a reply without topicTraffic is treated as zero-rate — walk one tier toward the
239
- // root without hanging out (no estimation against absent inputs).
240
- if (traffic === undefined) {
241
- return d <= 0 ? "terminal" : "escalate";
242
- }
243
-
244
- const decision = decide(
245
- {
246
- currentMatches: this.matched.size,
247
- directParticipants: traffic.directParticipants,
248
- arrivalsPerMin: traffic.arrivalsPerMin,
249
- queriesPerMin: traffic.queriesPerMin,
250
- childCohortCount: traffic.childCohortCount,
251
- wantCount: this.wantCount,
252
- patienceMsRemaining: this.remaining(),
253
- filterAcceptRatio: filterAcceptRatio(this.ratioState, this.filterAcceptRatioInitial),
254
- meanWantCount: this.meanWantCount,
255
- },
256
- this.config,
257
- );
258
-
259
- if (decision.action === "hangOut") {
260
- await this.hangOut(d, decision.requeryIntervalMs);
261
- if (this.matched.size >= this.wantCount) {
262
- return "done";
263
- }
264
- return d <= 0 ? "terminal" : "escalate";
265
- }
266
-
267
- // escalate — but at the root there is nowhere to walk: hang out the remaining patience, then end.
268
- if (d <= 0) {
269
- await this.hangOut(d, this.config.requeryIntervalMs);
270
- return "terminal";
271
- }
272
- return "escalate";
273
- }
274
-
275
- /** Keep the registration alive and re-query on the poll cadence until `wantCount` met or patience drains. */
276
- private async hangOut(d: number, requeryIntervalMs: number): Promise<void> {
277
- while (this.remaining() > 0 && this.matched.size < this.wantCount) {
278
- await this.sleep(Math.min(requeryIntervalMs, this.remaining()));
279
- await this.transport.renew();
280
- this.collect(await this.transport.query(d));
281
- }
282
- }
283
-
284
- private finish(terminalTier: number): SeekerWalkResult {
285
- return {
286
- providers: [...this.matched.values()],
287
- metWantCount: this.matched.size >= this.wantCount,
288
- terminalTier,
289
- hops: this.hops,
290
- maxChildCohortCount: this.maxChildCohortCount,
291
- };
292
- }
293
- }
1
+ /**
2
+ * Matchmaking — seeker walk client (db-p2p, drives the hang-out-vs-continue walk over the substrate).
3
+ *
4
+ * `docs/matchmaking.md` §Hang-out vs. continue. This is the RPC orchestration on top of the pure
5
+ * db-core {@link decide} engine: starting at `d_max`, it registers the seeker, issues `QueryV1`,
6
+ * re-validates and dedupes returned providers, and on each `Accepted` reply asks {@link decide} whether
7
+ * to finish, hang out (renew + requery on the `requery_interval_ms` poll cadence), or escalate (withdraw
8
+ * + re-register one tier toward the root). At `d = 0` there is nowhere left to walk, so it hangs out for
9
+ * the remaining patience and returns whatever matched (possibly `< wantCount`).
10
+ *
11
+ * Patience is a single budget that **drains across hops** (`docs/matchmaking.md` §Patience budgeting):
12
+ * a wall-clock deadline is fixed at the start, so every walked tier and every hang-out poll consumes
13
+ * the same `patienceMs` — escalation makes the next tier's hang-out progressively less attractive, which
14
+ * is correct since the root is terminal.
15
+ *
16
+ * The transport, clock, sleep, and per-entry verifier are all injected, so the walk unit-tests without a
17
+ * live libp2p stack (mock-tier e2e is a documented follow-on). This client implements the **poll path**
18
+ * only; the arrival-push path (`pushOnArrival`) is a separate slice.
19
+ *
20
+ * Edge cases encoded here (`docs/matchmaking.md` §Edge cases — the rest live in {@link decide}):
21
+ * - **`topicTraffic` absent (case 1):** the cohort is treated as zero-rate; the seeker issues one query
22
+ * (immediate-match) and walks one tier toward the root **without** hanging out.
23
+ * - **Stale `arrivalsPerMin = 0` after an epoch change (case 2):** never withdraw on a single zero
24
+ * reading — the immediate `QueryV1` runs first; a cohort that actually holds `>= wantCount` providers
25
+ * resolves to `done`, and only a query that also yields below threshold escalates.
26
+ * - **`UnwillingCohort`/`UnwillingMember` (case 3):** the hang-out decision is never entered (no
27
+ * `Accepted`); the walk terminates with whatever matched and standard substrate back-off applies.
28
+ */
29
+
30
+ import {
31
+ decide,
32
+ filterAcceptRatio,
33
+ matchesFilter,
34
+ newFilterAcceptRatioState,
35
+ observeYield,
36
+ verifyProviderEntry,
37
+ DEFAULT_HANG_OUT_CONFIG,
38
+ DEFAULT_MEAN_WANT_COUNT,
39
+ FILTER_ACCEPT_RATIO_INITIAL,
40
+ type CapabilityFilter,
41
+ type EntrySigVerifier,
42
+ type FilterAcceptRatioState,
43
+ type HangOutConfig,
44
+ type ProviderEntryV1,
45
+ type QueryReplyV1,
46
+ } from "@optimystic/db-core";
47
+
48
+ /** A seeker register/probe reply at a tree tier (the matchmaking-relevant subset of `RegisterReplyV1`). */
49
+ export interface SeekerProbeReply {
50
+ readonly result: "accepted" | "no_state" | "promoted" | "unwilling_member" | "unwilling_cohort";
51
+ /** Present on `accepted` and `promoted` (the substrate's barometer); absent triggers edge case 1. */
52
+ readonly topicTraffic?: QueryReplyV1["topicTraffic"];
53
+ /** Present on `promoted` — the tier to descend to. */
54
+ readonly targetTier?: number;
55
+ }
56
+
57
+ /** The substrate seam the walk drives: register/query/renew/withdraw against a tree tier. */
58
+ export interface SeekerWalkTransport {
59
+ /** Register (or re-register) the seeker at tree tier `treeTier`; resolves the probe reply. */
60
+ register(treeTier: number): Promise<SeekerProbeReply>;
61
+ /** Issue a `QueryV1` against the cohort the seeker is currently registered with. */
62
+ query(treeTier: number): Promise<QueryReplyV1>;
63
+ /** Renew the live seeker registration (hang-out keep-alive via TTL renewal). */
64
+ renew(): Promise<void>;
65
+ /** Withdraw the seeker registration before escalating (polite `RenewV1` TTL = 0; optional). */
66
+ withdraw(): Promise<void>;
67
+ }
68
+
69
+ /** Construction inputs for {@link SeekerWalkClient}. */
70
+ export interface SeekerWalkClientDeps {
71
+ readonly transport: SeekerWalkTransport;
72
+ /** The matchmaking topic id (used to re-validate each forwarded entry's `registrationSig`). */
73
+ readonly topicId: Uint8Array;
74
+ /** Providers the seeker needs. */
75
+ readonly wantCount: number;
76
+ /** The starting tree tier `d_max`. */
77
+ readonly dMax: number;
78
+ /** Total patience budget (ms); drains across hops + hang-out. */
79
+ readonly patienceMs: number;
80
+ /** Optional capability filter (re-applied seeker-side over the returned set). */
81
+ readonly filter?: CapabilityFilter;
82
+ /** Per-entry signature verifier (db-p2p binds `verifyPeerSig`). */
83
+ readonly verifyEntry: EntrySigVerifier;
84
+ /** Hang-out decision config. Default {@link DEFAULT_HANG_OUT_CONFIG}. */
85
+ readonly config?: HangOutConfig;
86
+ /** Assumed competing-seeker mean `wantCount`. Default {@link DEFAULT_MEAN_WANT_COUNT}. */
87
+ readonly meanWantCount?: number;
88
+ /** Starting `filterAcceptRatio` (refined per walk). Default {@link FILTER_ACCEPT_RATIO_INITIAL}. */
89
+ readonly filterAcceptRatioInitial?: number;
90
+ /** Wall clock (unix ms); injectable for tests. Default `Date.now`. */
91
+ readonly clock?: () => number;
92
+ /** Sleep for the requery cadence; injectable for tests. Default a real timer. */
93
+ readonly sleep?: (ms: number) => Promise<void>;
94
+ }
95
+
96
+ /** The result of a completed walk. */
97
+ export interface SeekerWalkResult {
98
+ /** Matched, re-validated, deduped providers (up to whatever accumulated; may be `< wantCount`). */
99
+ readonly providers: ProviderEntryV1[];
100
+ /** Whether `wantCount` was met. */
101
+ readonly metWantCount: boolean;
102
+ /** The tier the walk terminated at. */
103
+ readonly terminalTier: number;
104
+ /** Total register hops issued (probes + escalations + descends). */
105
+ readonly hops: number;
106
+ /**
107
+ * Max `topicTraffic.childCohortCount` observed across every `Accepted` reply this walk saw. `> 0`
108
+ * means the topic has promoted (it is hot), so the single-cohort sample is unrepresentative — the
109
+ * public seeker session / voting `QuorumDiscovery` binding uses this to decide whether to escalate to
110
+ * the multi-cohort sweep (`docs/matchmaking.md` §Multi-cohort sweep).
111
+ */
112
+ readonly maxChildCohortCount: number;
113
+ }
114
+
115
+ /** Outcome of evaluating one `Accepted` tier. */
116
+ type AcceptedOutcome = "done" | "escalate" | "terminal";
117
+
118
+ /** Drives the seeker hang-out-vs-continue walk for one matchmaking topic. See the module header. */
119
+ export class SeekerWalkClient {
120
+ private readonly transport: SeekerWalkTransport;
121
+ private readonly topicId: Uint8Array;
122
+ private readonly wantCount: number;
123
+ private readonly dMax: number;
124
+ private readonly patienceMs: number;
125
+ private readonly filter?: CapabilityFilter;
126
+ private readonly verifyEntry: EntrySigVerifier;
127
+ private readonly config: HangOutConfig;
128
+ private readonly meanWantCount: number;
129
+ private readonly filterAcceptRatioInitial: number;
130
+ private readonly clock: () => number;
131
+ private readonly sleep: (ms: number) => Promise<void>;
132
+
133
+ /** Matched providers, deduped by `participantId` (a provider seen via two queries counts once). */
134
+ private readonly matched = new Map<string, ProviderEntryV1>();
135
+ private ratioState: FilterAcceptRatioState = newFilterAcceptRatioState();
136
+ private deadline = 0;
137
+ private hops = 0;
138
+ /** Max `childCohortCount` seen across `Accepted` replies — the hot-topic / sweep-escalation signal. */
139
+ private maxChildCohortCount = 0;
140
+
141
+ constructor(deps: SeekerWalkClientDeps) {
142
+ this.transport = deps.transport;
143
+ this.topicId = deps.topicId;
144
+ this.wantCount = deps.wantCount;
145
+ this.dMax = deps.dMax;
146
+ this.patienceMs = deps.patienceMs;
147
+ this.filter = deps.filter;
148
+ this.verifyEntry = deps.verifyEntry;
149
+ this.config = deps.config ?? DEFAULT_HANG_OUT_CONFIG;
150
+ this.meanWantCount = deps.meanWantCount ?? DEFAULT_MEAN_WANT_COUNT;
151
+ this.filterAcceptRatioInitial = deps.filterAcceptRatioInitial ?? FILTER_ACCEPT_RATIO_INITIAL;
152
+ this.clock = deps.clock ?? ((): number => Date.now());
153
+ this.sleep = deps.sleep ?? ((ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)));
154
+ }
155
+
156
+ /** Run the walk from `d_max` toward the root; resolves the matched providers + termination info. */
157
+ async run(): Promise<SeekerWalkResult> {
158
+ this.deadline = this.clock() + this.patienceMs;
159
+ let d = this.dMax;
160
+
161
+ while (true) {
162
+ this.hops++;
163
+ const reply = await this.transport.register(d);
164
+ switch (reply.result) {
165
+ case "no_state": {
166
+ if (d <= 0) {
167
+ return this.finish(d);
168
+ }
169
+ d -= 1;
170
+ continue;
171
+ }
172
+ case "unwilling_member":
173
+ case "unwilling_cohort": {
174
+ // Substrate-level refusal — never received Accepted, so hang-out is not entered (case 3).
175
+ return this.finish(d);
176
+ }
177
+ case "promoted": {
178
+ const target = reply.targetTier ?? d + 1;
179
+ if (target <= d) {
180
+ return this.finish(d);
181
+ }
182
+ d = target;
183
+ continue;
184
+ }
185
+ case "accepted": {
186
+ const outcome = await this.handleAccepted(d, reply.topicTraffic);
187
+ if (outcome === "done" || outcome === "terminal") {
188
+ return this.finish(d);
189
+ }
190
+ await this.transport.withdraw();
191
+ d -= 1;
192
+ continue;
193
+ }
194
+ }
195
+ }
196
+ }
197
+
198
+ /** Remaining patience (ms); the wall-clock deadline drains uniformly across hops + hang-out. */
199
+ private remaining(): number {
200
+ return Math.max(0, this.deadline - this.clock());
201
+ }
202
+
203
+ /** Filter, re-validate (`registrationSig`), and dedupe a query reply's providers into {@link matched}. */
204
+ private collect(reply: QueryReplyV1): void {
205
+ const returned = reply.providers ?? [];
206
+ let matchedThisQuery = 0;
207
+ for (const entry of returned) {
208
+ if (!matchesFilter(entry, this.filter)) {
209
+ continue;
210
+ }
211
+ if (!verifyProviderEntry(this.topicId, entry, this.verifyEntry)) {
212
+ continue;
213
+ }
214
+ matchedThisQuery++;
215
+ this.matched.set(entry.participantId, entry);
216
+ }
217
+ this.ratioState = observeYield(this.ratioState, matchedThisQuery, returned.length);
218
+ }
219
+
220
+ /** Evaluate one `Accepted` tier: immediate query, then {@link decide} → done / hangOut / escalate. */
221
+ private async handleAccepted(d: number, traffic: SeekerProbeReply["topicTraffic"]): Promise<AcceptedOutcome> {
222
+ // Record the hottest tier seen as soon as this Accepted reply's traffic is available — *before* the
223
+ // immediate-match/done short-circuit below, so the single-cohort-vs-sweep decision (public session /
224
+ // voting QuorumDiscovery binding) still sees a hot topic even when one cohort's query already met
225
+ // wantCount. Folding it only on the `decide` path would drop the signal for a small quorum that a
226
+ // single hot cohort satisfies, leaving the assembler with a prefix-biased sample it should sweep.
227
+ if (traffic !== undefined) {
228
+ this.maxChildCohortCount = Math.max(this.maxChildCohortCount, traffic.childCohortCount);
229
+ }
230
+
231
+ // Immediate-match check runs in every case — also satisfies edge case 2 (a stale arrivalsPerMin=0
232
+ // cohort that actually holds enough providers resolves here rather than escalating spuriously).
233
+ this.collect(await this.transport.query(d));
234
+ if (this.matched.size >= this.wantCount) {
235
+ return "done";
236
+ }
237
+
238
+ // Edge case 1: a reply without topicTraffic is treated as zero-rate — walk one tier toward the
239
+ // root without hanging out (no estimation against absent inputs).
240
+ if (traffic === undefined) {
241
+ return d <= 0 ? "terminal" : "escalate";
242
+ }
243
+
244
+ const decision = decide(
245
+ {
246
+ currentMatches: this.matched.size,
247
+ directParticipants: traffic.directParticipants,
248
+ arrivalsPerMin: traffic.arrivalsPerMin,
249
+ queriesPerMin: traffic.queriesPerMin,
250
+ childCohortCount: traffic.childCohortCount,
251
+ wantCount: this.wantCount,
252
+ patienceMsRemaining: this.remaining(),
253
+ filterAcceptRatio: filterAcceptRatio(this.ratioState, this.filterAcceptRatioInitial),
254
+ meanWantCount: this.meanWantCount,
255
+ },
256
+ this.config,
257
+ );
258
+
259
+ if (decision.action === "hangOut") {
260
+ await this.hangOut(d, decision.requeryIntervalMs);
261
+ if (this.matched.size >= this.wantCount) {
262
+ return "done";
263
+ }
264
+ return d <= 0 ? "terminal" : "escalate";
265
+ }
266
+
267
+ // escalate — but at the root there is nowhere to walk: hang out the remaining patience, then end.
268
+ if (d <= 0) {
269
+ await this.hangOut(d, this.config.requeryIntervalMs);
270
+ return "terminal";
271
+ }
272
+ return "escalate";
273
+ }
274
+
275
+ /** Keep the registration alive and re-query on the poll cadence until `wantCount` met or patience drains. */
276
+ private async hangOut(d: number, requeryIntervalMs: number): Promise<void> {
277
+ while (this.remaining() > 0 && this.matched.size < this.wantCount) {
278
+ await this.sleep(Math.min(requeryIntervalMs, this.remaining()));
279
+ await this.transport.renew();
280
+ this.collect(await this.transport.query(d));
281
+ }
282
+ }
283
+
284
+ private finish(terminalTier: number): SeekerWalkResult {
285
+ return {
286
+ providers: [...this.matched.values()],
287
+ metWantCount: this.matched.size >= this.wantCount,
288
+ terminalTier,
289
+ hops: this.hops,
290
+ maxChildCohortCount: this.maxChildCohortCount,
291
+ };
292
+ }
293
+ }