@optimystic/db-p2p 1.0.0-beta.3 → 1.0.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 +126 -6
  17. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  18. package/dist/src/cluster/cluster-repo.js +378 -50
  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 +77 -5
  112. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  113. package/dist/src/repo/cluster-coordinator.js +131 -20
  114. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  115. package/dist/src/repo/coordinator-repo.d.ts +238 -122
  116. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  117. package/dist/src/repo/coordinator-repo.js +625 -472
  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 +422 -56
  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 +145 -19
  217. package/src/repo/coordinator-repo.ts +652 -505
  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,224 @@
1
+ import { LruMap, type ActionId, type BlockId } from "@optimystic/db-core";
2
+
3
+ /**
4
+ * What one block's pending-conflict refusals have added up to, for ONE unchanged set of holders.
5
+ *
6
+ * A block is reserved by an unresolved pending action for the span between that action's pend and
7
+ * its commit or cancel, and while the reservation stands every OTHER writer's pend for the block is
8
+ * refused. That is the healthy optimistic-concurrency loss. The unhealthy case has the identical
9
+ * per-refusal shape and differs only in repetition: the same holder refusing DISTINCT later actions
10
+ * without end, because the holder is never going to commit or cancel (see
11
+ * {@link StuckReservationTracker}).
12
+ *
13
+ * Counted per (block, holders) rather than per block: a holder that changes is the healthy cycle —
14
+ * the previous reservation resolved and another writer took the block — so a new holder starts a new
15
+ * count and gets its own chance to speak.
16
+ */
17
+ interface StuckReservationWatch {
18
+ /** The rival action ids the refusals in this episode named, sorted so the comparison is stable. */
19
+ holders: readonly ActionId[];
20
+ /**
21
+ * Distinct action ids these holders have refused. Distinct ACTIONS, not refusals: one writer
22
+ * retrying is one writer, because a sync reuses a single action id across all of its retry
23
+ * attempts (`Collection.syncInternal` mints the id once and `syncAttempts` reuses it for every
24
+ * attempt of that cycle). Emptied at the
25
+ * moment the episode is reported — the count is in the line, and nothing reads the ids again —
26
+ * so the set is bounded by {@link STUCK_RESERVATION_DISTINCT_ACTIONS}.
27
+ */
28
+ refused: Set<ActionId>;
29
+ /** True once this episode has been named; suppresses every later refusal against these holders. */
30
+ reported: boolean;
31
+ }
32
+
33
+ /**
34
+ * How many DISTINCT later actions one unchanged holder must refuse on a block before the refusals are
35
+ * named as a stuck reservation rather than as an ordinary lost race.
36
+ *
37
+ * **Why a count of distinct actions and not something else.** Elapsed time answers the wrong question
38
+ * — a slow writer is not a stuck one, and a holder legitimately keeps its reservation for as long as
39
+ * its own commit takes. A raw refusal count answers the wrong question too: a single writer retrying
40
+ * a lost race produces a run of refusals under ONE action id (see {@link StuckReservationWatch.refused}).
41
+ * What no healthy holder can produce is an unbounded stream of *different* writers all losing to it,
42
+ * because a healthy holder's reservation lasts one pend-to-commit window.
43
+ *
44
+ * **Why 8.** The bound to clear is how many distinct actions can honestly be refused inside one such
45
+ * window. Measured on the in-process mesh, in the healthy-contention arm of
46
+ * `test/stuck-reservation-named.spec.ts`: a holder that pends, is raced by other writers, and then
47
+ * commits refuses **2** distinct actions per episode — the two rivals — and the count resets on every
48
+ * holder change. `concurrent-diary-append-acknowledgement.spec.ts` races three writers at one diary
49
+ * and cannot exceed that either, for the same reason: at most (writers - 1) rivals can lose to one
50
+ * winner. 8 is four times the measured healthy figure, and it is a floor a genuinely stuck block
51
+ * clears trivially (the field instance refused hundreds).
52
+ *
53
+ * **The bound stated exactly.** It is distinct SYNC CYCLES, not distinct writers: one writer that
54
+ * exhausts a sync's retry budget and is re-driven by its caller mints a fresh id for the next cycle,
55
+ * so it can contribute more than one. That does not widen the window much — a cycle only ends in
56
+ * exhaustion after `DefaultMaxAttempts` (10) attempts of backoff, roughly 21s (see the exhaustion
57
+ * NOTE in `Collection.syncAttempts`), so a lone writer needs a holder to keep the block for upwards
58
+ * of two and a half minutes before it reaches 8 by itself, which is not a healthy holder.
59
+ *
60
+ * **What the margin does NOT cover, stated honestly.** A block with more than 8 distinct writers
61
+ * racing it inside a single pend-to-commit round trip could reach 8 with a perfectly healthy holder.
62
+ * That is a diagnostic false positive on a log line and nothing else — this counter never refuses,
63
+ * expires, or deletes anything (see {@link StuckReservationTracker}) — and the remedy if
64
+ * a deployment ever hits it is to raise this number, not to add a control path. Raising it costs
65
+ * detection latency on low-traffic blocks, which need this many distinct write ATTEMPTS before the
66
+ * condition can be named at all.
67
+ */
68
+ export const STUCK_RESERVATION_DISTINCT_ACTIONS = 8;
69
+
70
+ /** Whether two sorted holder lists name the same reservation — i.e. whether a refusal continues an
71
+ * existing episode or starts a new one. Both sides come from the same sort, so this is a plain
72
+ * element-wise comparison; a block normally has exactly one holder, since a member's own pend refuses
73
+ * a second one (`ClusterMember.validatePendOperations`). */
74
+ function sameHolders(a: readonly ActionId[], b: readonly ActionId[]): boolean {
75
+ return a.length === b.length && a.every((id, i) => id === b[i]);
76
+ }
77
+
78
+ /**
79
+ * The stuck-reservation wording: written for an operator reading logs, in the same register as
80
+ * `cohortTooSmallMessage` and `soleHolderMessage` in `coordinator-repo.ts` — what is stuck, what will
81
+ * and will not clear it, and what to do next.
82
+ *
83
+ * The claim is deliberately about the RESERVATION, not about the writer's intent: this node cannot
84
+ * see whether the holding process is alive, only that it has held the block across enough unrelated
85
+ * later actions that no retry is going to win. So the line says what is provable (the block accepts
86
+ * no writes while this record stands, and nothing on the node removes it) and points at the one check
87
+ * that settles the rest.
88
+ */
89
+ export function stuckReservationMessage(holders: readonly ActionId[], refusedActions: number): string {
90
+ const held = holders.join(', ');
91
+ return `This block is WEDGED BEHIND A PENDING WRITE THAT IS NOT COMPLETING, and retrying will never ` +
92
+ `clear it: action(s) ${held} reserved the block and have now refused ${refusedActions} DISTINCT, ` +
93
+ `unrelated later actions. Each of those refusals on its own looks exactly like an ordinary ` +
94
+ `optimistic-concurrency loss, which is normal and healthy — the repetition is what is not. A ` +
95
+ `healthy rival holds a block only for its own pend-to-commit window and then releases it by ` +
96
+ `committing or cancelling; a reservation that keeps refusing NEW writers is holding the block ` +
97
+ `against every writer on every machine, and each of them loses again identically. EXACTLY TWO ` +
98
+ `THINGS CLEAR IT: a cancel for action(s) ${held} on this block (route it through the cohort so ` +
99
+ `every member drops the record), or that same action's own commit landing. Nothing on the node ` +
100
+ `expires it — there is no sweep for abandoned pending records — so until one of those two happens ` +
101
+ `the block takes NO writes while continuing to serve reads and to look healthy in every other ` +
102
+ `respect. The usual cause is a writer that went away between a failed or half-applied commit and ` +
103
+ `the cancel it owed, so check whether whatever ran ${held} still exists before cancelling on its ` +
104
+ `behalf. This line is a diagnosis and nothing more: this node does not expire, refuse, or delete ` +
105
+ `the record on the strength of it.`;
106
+ }
107
+
108
+ /**
109
+ * One episode that just crossed the threshold — what the caller logs, verbatim, under its own tag.
110
+ * The ids an operator needs to grep for and to cancel are kept as data beside the prose so a log
111
+ * search finds the block and the action without parsing English.
112
+ */
113
+ export interface StuckReservationEpisode {
114
+ blockId: BlockId;
115
+ holdingActionIds: readonly ActionId[];
116
+ distinctRefusedActions: number;
117
+ message: string;
118
+ }
119
+
120
+ /**
121
+ * Count pending-conflict refusals against the holder(s) of each block they name, and say ONCE — in
122
+ * words, at the moment it becomes provable — when a block is wedged behind a reservation that is not
123
+ * going to clear.
124
+ *
125
+ * **Why this needs saying at all.** Every individual refusal is indistinguishable from an ordinary
126
+ * lost race, which is a normal and healthy event, so the logs of a permanently wedged block read
127
+ * exactly like the logs of a busy one. Finding the difference otherwise means noticing that the SAME
128
+ * rival action id keeps appearing across unrelated writers for as long as the process lives — a
129
+ * pattern nothing points at, and one that cost a downstream project several tickets and weeks to
130
+ * re-derive from raw traces. The node has the fact in hand at every refusal; this makes it sayable.
131
+ *
132
+ * **The signal, and the two things that are NOT the signal.** The discriminator is repetition
133
+ * against an unchanged holder — see {@link STUCK_RESERVATION_DISTINCT_ACTIONS} for why distinct
134
+ * refused actions is the right counter and for the measured threshold. Two cheaper-looking tests
135
+ * were tried and do not work: the members' in-memory reservation table
136
+ * (`ClusterMember.activeTransactions`) clears the moment a rival's pend reaches consensus, so a
137
+ * perfectly healthy rival inside its pend-to-commit window is absent from it too and absence there
138
+ * says nothing; and "the block already passed this pending record's revision" catches a different
139
+ * orphan class entirely — in the verified instance the wedged block sat at revision 1 while the
140
+ * orphaned record was for revision 2, still nominally promotable. (That second class is now handled
141
+ * at its own site — a record the incoming writer has built on no longer refuses at all, see
142
+ * `isReservationAgainst` — so what reaches this counter is a holder whose change the block has not
143
+ * taken: one whose slot every later writer still wants, or one past the base every later writer
144
+ * declares, such as an abandoned action's record on a block no later write touched.)
145
+ *
146
+ * **Never a control path.** This classifies and reports; it never refuses, expires, or deletes
147
+ * anything. Deciding when a durable pending record may be removed is precisely the hard problem
148
+ * backlog `debt-unpromotable-pending-records-need-a-sweep` exists for — deleting a live reservation
149
+ * is worse than the leak — and a counter accurate enough for a log line is not evidence enough to
150
+ * destroy state.
151
+ *
152
+ * **Two instances, two vantage points.** The coordinator keeps one (`CoordinatorRepo.noteStuckReservation`),
153
+ * fed by cohort-wide `held`-answered refusals whose holder its OWN storage corroborates, and every
154
+ * member keeps one (`ClusterMember.validatePendOperations`), fed by its own `held` votes — so a
155
+ * reservation only remote members hold is named by those members, where the record actually lives,
156
+ * rather than going unnamed at a coordinator that cannot see it.
157
+ *
158
+ * LRU-bounded: an eviction under more than `capacity` conflicted blocks loses an episode's say-once
159
+ * flag, so the line can repeat once for that block — bounded duplication, far cheaper than the
160
+ * unbounded silence it replaces.
161
+ */
162
+ export class StuckReservationTracker {
163
+ private readonly watches: LruMap<string, StuckReservationWatch>;
164
+
165
+ constructor(capacity = 1000) {
166
+ this.watches = new LruMap<string, StuckReservationWatch>(capacity);
167
+ }
168
+
169
+ /**
170
+ * Count one refusal of `refusedActionId` against the rival holder(s) of each block in `rivalsByBlock`.
171
+ * Returns the highest distinct-refusal count any of these blocks has now reached — for a
172
+ * classification line to carry, saturating at the threshold once an episode has been reported,
173
+ * since the ids are dropped at that point — and every episode that crossed the threshold on this
174
+ * refusal, which the caller must log.
175
+ */
176
+ note(rivalsByBlock: ReadonlyMap<BlockId, readonly ActionId[]>, refusedActionId: ActionId): { highest: number; named: StuckReservationEpisode[] } {
177
+ let highest = 0;
178
+ const named: StuckReservationEpisode[] = [];
179
+ for (const [blockId, rivals] of rivalsByBlock) {
180
+ const holders = [...new Set(rivals)].sort();
181
+ const prior = this.watches.get(blockId);
182
+ // A different holder set is a DIFFERENT episode — the block changed hands, which is the
183
+ // healthy cycle — so the count starts over and the new holder gets its own chance to speak.
184
+ const watch: StuckReservationWatch = prior !== undefined && sameHolders(prior.holders, holders)
185
+ ? prior
186
+ : { holders, refused: new Set<ActionId>(), reported: false };
187
+ if (watch !== prior) this.watches.set(blockId, watch);
188
+ if (watch.reported) {
189
+ highest = Math.max(highest, STUCK_RESERVATION_DISTINCT_ACTIONS);
190
+ continue;
191
+ }
192
+ watch.refused.add(refusedActionId);
193
+ highest = Math.max(highest, watch.refused.size);
194
+ if (watch.refused.size < STUCK_RESERVATION_DISTINCT_ACTIONS) continue;
195
+ named.push({
196
+ blockId,
197
+ holdingActionIds: holders,
198
+ distinctRefusedActions: watch.refused.size,
199
+ message: stuckReservationMessage(holders, watch.refused.size)
200
+ });
201
+ watch.reported = true;
202
+ // Said once per episode: from here the flag alone suppresses, and the ids have done their
203
+ // work (their count is in the line above), so drop them rather than growing a set for the
204
+ // unbounded remainder of a permanent condition.
205
+ watch.refused.clear();
206
+ }
207
+ return { highest, named };
208
+ }
209
+
210
+ /**
211
+ * Forget any episode recorded for these blocks, optionally only when `holderActionId` is one of
212
+ * the actions that episode named. Called from the events the message names as the cures — a write
213
+ * the block accepted, a cancel for the holding action — and it is LRU hygiene rather than
214
+ * behaviour: a later wedge is named by a DIFFERENT holder, which {@link note}'s holder comparison
215
+ * already treats as a new episode whether the old entry is still there or not. Kept because a
216
+ * settled episode holding an LRU slot can only evict a live one.
217
+ */
218
+ forget(blockIds: readonly BlockId[], holderActionId?: ActionId): void {
219
+ for (const blockId of blockIds) {
220
+ if (holderActionId !== undefined && !this.watches.peek(blockId)?.holders.includes(holderActionId)) continue;
221
+ this.watches.delete(blockId);
222
+ }
223
+ }
224
+ }