@optimystic/db-p2p 0.14.1 → 0.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (275) hide show
  1. package/README.md +616 -609
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  3. package/dist/src/cluster/block-transfer-service.js +4 -3
  4. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  5. package/dist/src/cluster/block-transfer.d.ts +52 -3
  6. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  7. package/dist/src/cluster/block-transfer.js +129 -6
  8. package/dist/src/cluster/block-transfer.js.map +1 -1
  9. package/dist/src/cluster/client.d.ts.map +1 -1
  10. package/dist/src/cluster/client.js +3 -1
  11. package/dist/src/cluster/client.js.map +1 -1
  12. package/dist/src/cluster/cluster-repo.d.ts +182 -11
  13. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  14. package/dist/src/cluster/cluster-repo.js +429 -69
  15. package/dist/src/cluster/cluster-repo.js.map +1 -1
  16. package/dist/src/cluster/peer-key-binding.d.ts +25 -0
  17. package/dist/src/cluster/peer-key-binding.d.ts.map +1 -0
  18. package/dist/src/cluster/peer-key-binding.js +39 -0
  19. package/dist/src/cluster/peer-key-binding.js.map +1 -0
  20. package/dist/src/cluster/quorum-restore.d.ts +72 -0
  21. package/dist/src/cluster/quorum-restore.d.ts.map +1 -0
  22. package/dist/src/cluster/quorum-restore.js +0 -0
  23. package/dist/src/cluster/quorum-restore.js.map +1 -0
  24. package/dist/src/cluster/rebalance-monitor.d.ts +23 -1
  25. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  26. package/dist/src/cluster/rebalance-monitor.js +17 -2
  27. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  28. package/dist/src/cluster/service.d.ts.map +1 -1
  29. package/dist/src/cluster/service.js +7 -1
  30. package/dist/src/cluster/service.js.map +1 -1
  31. package/dist/src/cluster/spread-on-churn.d.ts +7 -0
  32. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  33. package/dist/src/cluster/spread-on-churn.js +4 -1
  34. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  35. package/dist/src/cluster/supermajority-coupling.d.ts +22 -0
  36. package/dist/src/cluster/supermajority-coupling.d.ts.map +1 -0
  37. package/dist/src/cluster/supermajority-coupling.js +27 -0
  38. package/dist/src/cluster/supermajority-coupling.js.map +1 -0
  39. package/dist/src/cohort-topic/cohort-gossip-driver.d.ts +46 -4
  40. package/dist/src/cohort-topic/cohort-gossip-driver.d.ts.map +1 -1
  41. package/dist/src/cohort-topic/cohort-gossip-driver.js +62 -7
  42. package/dist/src/cohort-topic/cohort-gossip-driver.js.map +1 -1
  43. package/dist/src/cohort-topic/cohort-gossip-transport.d.ts +7 -0
  44. package/dist/src/cohort-topic/cohort-gossip-transport.d.ts.map +1 -1
  45. package/dist/src/cohort-topic/cohort-gossip-transport.js +9 -0
  46. package/dist/src/cohort-topic/cohort-gossip-transport.js.map +1 -1
  47. package/dist/src/cohort-topic/host.d.ts +197 -27
  48. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  49. package/dist/src/cohort-topic/host.js +643 -87
  50. package/dist/src/cohort-topic/host.js.map +1 -1
  51. package/dist/src/dispute/arbitrator-selection.d.ts +68 -7
  52. package/dist/src/dispute/arbitrator-selection.d.ts.map +1 -1
  53. package/dist/src/dispute/arbitrator-selection.js +105 -16
  54. package/dist/src/dispute/arbitrator-selection.js.map +1 -1
  55. package/dist/src/dispute/cascade.d.ts +7 -0
  56. package/dist/src/dispute/cascade.d.ts.map +1 -1
  57. package/dist/src/dispute/cascade.js +1 -1
  58. package/dist/src/dispute/cascade.js.map +1 -1
  59. package/dist/src/dispute/client.d.ts.map +1 -1
  60. package/dist/src/dispute/client.js +3 -2
  61. package/dist/src/dispute/client.js.map +1 -1
  62. package/dist/src/dispute/dispute-service.d.ts +32 -3
  63. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  64. package/dist/src/dispute/dispute-service.js +82 -9
  65. package/dist/src/dispute/dispute-service.js.map +1 -1
  66. package/dist/src/dispute/index.d.ts +1 -1
  67. package/dist/src/dispute/index.d.ts.map +1 -1
  68. package/dist/src/dispute/index.js +1 -1
  69. package/dist/src/dispute/index.js.map +1 -1
  70. package/dist/src/dispute/invalidation.d.ts +25 -0
  71. package/dist/src/dispute/invalidation.d.ts.map +1 -1
  72. package/dist/src/dispute/invalidation.js +14 -2
  73. package/dist/src/dispute/invalidation.js.map +1 -1
  74. package/dist/src/dispute/service.d.ts.map +1 -1
  75. package/dist/src/dispute/service.js +7 -1
  76. package/dist/src/dispute/service.js.map +1 -1
  77. package/dist/src/index.d.ts +4 -1
  78. package/dist/src/index.d.ts.map +1 -1
  79. package/dist/src/index.js +4 -1
  80. package/dist/src/index.js.map +1 -1
  81. package/dist/src/it-utility.d.ts +1 -1
  82. package/dist/src/it-utility.d.ts.map +1 -1
  83. package/dist/src/it-utility.js +4 -13
  84. package/dist/src/it-utility.js.map +1 -1
  85. package/dist/src/libp2p-key-network.d.ts +98 -1
  86. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  87. package/dist/src/libp2p-key-network.js +272 -17
  88. package/dist/src/libp2p-key-network.js.map +1 -1
  89. package/dist/src/libp2p-node-base.d.ts +12 -0
  90. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  91. package/dist/src/libp2p-node-base.js +441 -89
  92. package/dist/src/libp2p-node-base.js.map +1 -1
  93. package/dist/src/matchmaking/query-transport.d.ts +2 -0
  94. package/dist/src/matchmaking/query-transport.d.ts.map +1 -1
  95. package/dist/src/matchmaking/query-transport.js +6 -0
  96. package/dist/src/matchmaking/query-transport.js.map +1 -1
  97. package/dist/src/matchmaking/traffic-validation.js +1 -0
  98. package/dist/src/matchmaking/traffic-validation.js.map +1 -1
  99. package/dist/src/network/network-manager-service.d.ts +2 -2
  100. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  101. package/dist/src/network/network-manager-service.js +8 -4
  102. package/dist/src/network/network-manager-service.js.map +1 -1
  103. package/dist/src/owned-block-seed.d.ts +27 -0
  104. package/dist/src/owned-block-seed.d.ts.map +1 -0
  105. package/dist/src/owned-block-seed.js +37 -0
  106. package/dist/src/owned-block-seed.js.map +1 -0
  107. package/dist/src/protocol-client.d.ts +1 -0
  108. package/dist/src/protocol-client.d.ts.map +1 -1
  109. package/dist/src/protocol-client.js +9 -2
  110. package/dist/src/protocol-client.js.map +1 -1
  111. package/dist/src/protocol-limits.d.ts +32 -0
  112. package/dist/src/protocol-limits.d.ts.map +1 -0
  113. package/dist/src/protocol-limits.js +32 -0
  114. package/dist/src/protocol-limits.js.map +1 -0
  115. package/dist/src/repo/client.d.ts.map +1 -1
  116. package/dist/src/repo/client.js +3 -0
  117. package/dist/src/repo/client.js.map +1 -1
  118. package/dist/src/repo/cluster-coordinator.d.ts +41 -4
  119. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  120. package/dist/src/repo/cluster-coordinator.js +128 -80
  121. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  122. package/dist/src/repo/coordinator-repo.d.ts +38 -1
  123. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  124. package/dist/src/repo/coordinator-repo.js +92 -13
  125. package/dist/src/repo/coordinator-repo.js.map +1 -1
  126. package/dist/src/repo/service.d.ts.map +1 -1
  127. package/dist/src/repo/service.js +7 -1
  128. package/dist/src/repo/service.js.map +1 -1
  129. package/dist/src/reputation/peer-reputation.js +1 -1
  130. package/dist/src/reputation/peer-reputation.js.map +1 -1
  131. package/dist/src/reputation/types.d.ts +3 -1
  132. package/dist/src/reputation/types.d.ts.map +1 -1
  133. package/dist/src/reputation/types.js +3 -0
  134. package/dist/src/reputation/types.js.map +1 -1
  135. package/dist/src/rn.d.ts +5 -1
  136. package/dist/src/rn.d.ts.map +1 -1
  137. package/dist/src/rn.js +7 -1
  138. package/dist/src/rn.js.map +1 -1
  139. package/dist/src/storage/arachnode-fret-adapter.d.ts +30 -1
  140. package/dist/src/storage/arachnode-fret-adapter.d.ts.map +1 -1
  141. package/dist/src/storage/arachnode-fret-adapter.js +15 -2
  142. package/dist/src/storage/arachnode-fret-adapter.js.map +1 -1
  143. package/dist/src/storage/arachnode-partition.d.ts +49 -0
  144. package/dist/src/storage/arachnode-partition.d.ts.map +1 -0
  145. package/dist/src/storage/arachnode-partition.js +61 -0
  146. package/dist/src/storage/arachnode-partition.js.map +1 -0
  147. package/dist/src/storage/block-storage.d.ts +40 -1
  148. package/dist/src/storage/block-storage.d.ts.map +1 -1
  149. package/dist/src/storage/block-storage.js +150 -57
  150. package/dist/src/storage/block-storage.js.map +1 -1
  151. package/dist/src/storage/i-block-storage.d.ts +8 -0
  152. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  153. package/dist/src/storage/i-raw-storage.d.ts +13 -0
  154. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  155. package/dist/src/storage/kv-raw-storage.d.ts +45 -0
  156. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -0
  157. package/dist/src/storage/kv-raw-storage.js +113 -0
  158. package/dist/src/storage/kv-raw-storage.js.map +1 -0
  159. package/dist/src/storage/memory-storage.d.ts +20 -58
  160. package/dist/src/storage/memory-storage.d.ts.map +1 -1
  161. package/dist/src/storage/memory-storage.js +22 -148
  162. package/dist/src/storage/memory-storage.js.map +1 -1
  163. package/dist/src/storage/memory-store-driver.d.ts +39 -0
  164. package/dist/src/storage/memory-store-driver.d.ts.map +1 -0
  165. package/dist/src/storage/memory-store-driver.js +129 -0
  166. package/dist/src/storage/memory-store-driver.js.map +1 -0
  167. package/dist/src/storage/raw-store-codec.d.ts +10 -0
  168. package/dist/src/storage/raw-store-codec.d.ts.map +1 -0
  169. package/dist/src/storage/raw-store-codec.js +36 -0
  170. package/dist/src/storage/raw-store-codec.js.map +1 -0
  171. package/dist/src/storage/raw-store-driver.d.ts +69 -0
  172. package/dist/src/storage/raw-store-driver.d.ts.map +1 -0
  173. package/dist/src/storage/raw-store-driver.js +2 -0
  174. package/dist/src/storage/raw-store-driver.js.map +1 -0
  175. package/dist/src/storage/restoration-coordinator-v2.d.ts.map +1 -1
  176. package/dist/src/storage/restoration-coordinator-v2.js +1 -1
  177. package/dist/src/storage/restoration-coordinator-v2.js.map +1 -1
  178. package/dist/src/storage/restoration-coordinator.d.ts +73 -0
  179. package/dist/src/storage/restoration-coordinator.d.ts.map +1 -0
  180. package/dist/src/storage/restoration-coordinator.js +181 -0
  181. package/dist/src/storage/restoration-coordinator.js.map +1 -0
  182. package/dist/src/storage/ring-selector.d.ts +92 -5
  183. package/dist/src/storage/ring-selector.d.ts.map +1 -1
  184. package/dist/src/storage/ring-selector.js +153 -51
  185. package/dist/src/storage/ring-selector.js.map +1 -1
  186. package/dist/src/storage/ring-shift-coordinator.d.ts +128 -0
  187. package/dist/src/storage/ring-shift-coordinator.d.ts.map +1 -0
  188. package/dist/src/storage/ring-shift-coordinator.js +196 -0
  189. package/dist/src/storage/ring-shift-coordinator.js.map +1 -0
  190. package/dist/src/storage/storage-monitor.d.ts +28 -0
  191. package/dist/src/storage/storage-monitor.d.ts.map +1 -1
  192. package/dist/src/storage/storage-monitor.js +42 -2
  193. package/dist/src/storage/storage-monitor.js.map +1 -1
  194. package/dist/src/storage/storage-repo.d.ts +19 -0
  195. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  196. package/dist/src/storage/storage-repo.js +172 -22
  197. package/dist/src/storage/storage-repo.js.map +1 -1
  198. package/dist/src/sync/client.d.ts.map +1 -1
  199. package/dist/src/sync/client.js +5 -1
  200. package/dist/src/sync/client.js.map +1 -1
  201. package/dist/src/sync/service.d.ts.map +1 -1
  202. package/dist/src/sync/service.js +2 -1
  203. package/dist/src/sync/service.js.map +1 -1
  204. package/dist/src/testing/cohort-topic-mesh-harness.d.ts +28 -4
  205. package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -1
  206. package/dist/src/testing/cohort-topic-mesh-harness.js +51 -10
  207. package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -1
  208. package/dist/src/testing/index.d.ts +1 -0
  209. package/dist/src/testing/index.d.ts.map +1 -1
  210. package/dist/src/testing/index.js +1 -0
  211. package/dist/src/testing/index.js.map +1 -1
  212. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  213. package/dist/src/testing/mesh-harness.js +15 -7
  214. package/dist/src/testing/mesh-harness.js.map +1 -1
  215. package/dist/src/testing/raw-storage-conformance.d.ts +24 -0
  216. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -0
  217. package/dist/src/testing/raw-storage-conformance.js +313 -0
  218. package/dist/src/testing/raw-storage-conformance.js.map +1 -0
  219. package/package.json +4 -4
  220. package/src/cluster/block-transfer-service.ts +4 -3
  221. package/src/cluster/block-transfer.ts +424 -265
  222. package/src/cluster/client.ts +3 -1
  223. package/src/cluster/cluster-repo.ts +481 -71
  224. package/src/cluster/peer-key-binding.ts +37 -0
  225. package/src/cluster/quorum-restore.ts +0 -0
  226. package/src/cluster/rebalance-monitor.ts +34 -3
  227. package/src/cluster/service.ts +7 -1
  228. package/src/cluster/spread-on-churn.ts +11 -1
  229. package/src/cluster/supermajority-coupling.ts +37 -0
  230. package/src/cohort-topic/cohort-gossip-driver.ts +96 -9
  231. package/src/cohort-topic/cohort-gossip-transport.ts +10 -0
  232. package/src/cohort-topic/host.ts +831 -105
  233. package/src/dispute/arbitrator-selection.ts +138 -28
  234. package/src/dispute/cascade.ts +8 -1
  235. package/src/dispute/client.ts +3 -2
  236. package/src/dispute/dispute-service.ts +94 -9
  237. package/src/dispute/index.ts +7 -1
  238. package/src/dispute/invalidation.ts +40 -2
  239. package/src/dispute/service.ts +7 -1
  240. package/src/index.ts +46 -43
  241. package/src/it-utility.ts +5 -13
  242. package/src/libp2p-key-network.ts +281 -18
  243. package/src/libp2p-node-base.ts +511 -81
  244. package/src/matchmaking/query-transport.ts +9 -0
  245. package/src/network/network-manager-service.ts +8 -2
  246. package/src/owned-block-seed.ts +40 -0
  247. package/src/protocol-client.ts +10 -3
  248. package/src/protocol-limits.ts +33 -0
  249. package/src/repo/client.ts +3 -0
  250. package/src/repo/cluster-coordinator.ts +145 -82
  251. package/src/repo/coordinator-repo.ts +96 -16
  252. package/src/repo/service.ts +7 -1
  253. package/src/reputation/types.ts +3 -0
  254. package/src/rn.ts +7 -1
  255. package/src/storage/arachnode-fret-adapter.ts +33 -2
  256. package/src/storage/arachnode-partition.ts +74 -0
  257. package/src/storage/block-storage.ts +169 -61
  258. package/src/storage/i-block-storage.ts +9 -0
  259. package/src/storage/i-raw-storage.ts +14 -0
  260. package/src/storage/kv-raw-storage.ts +135 -0
  261. package/src/storage/memory-storage.ts +25 -173
  262. package/src/storage/memory-store-driver.ts +157 -0
  263. package/src/storage/raw-store-codec.ts +42 -0
  264. package/src/storage/raw-store-driver.ts +80 -0
  265. package/src/storage/{restoration-coordinator-v2.ts → restoration-coordinator.ts} +43 -37
  266. package/src/storage/ring-selector.ts +317 -176
  267. package/src/storage/ring-shift-coordinator.ts +271 -0
  268. package/src/storage/storage-monitor.ts +70 -3
  269. package/src/storage/storage-repo.ts +710 -558
  270. package/src/sync/client.ts +5 -1
  271. package/src/sync/service.ts +2 -1
  272. package/src/testing/cohort-topic-mesh-harness.ts +61 -11
  273. package/src/testing/index.ts +1 -0
  274. package/src/testing/mesh-harness.ts +14 -6
  275. package/src/testing/raw-storage-conformance.ts +397 -0
@@ -112,6 +112,8 @@ export interface MatchmakingQueryServeDeps {
112
112
  readonly gate?: (from: PeerId, topicId: Uint8Array) => boolean;
113
113
  /** Per-frame ceiling for encode/decode; default {@link DEFAULT_STREAM_MAX_BYTES}. */
114
114
  readonly maxBytes?: number;
115
+ /** Wall clock (unix ms) stamped on the query-accounting bump; default {@link Date.now}. Injectable for tests. */
116
+ readonly clock?: () => number;
115
117
  }
116
118
 
117
119
  /**
@@ -151,6 +153,13 @@ export function createMatchmakingQueryHandler(
151
153
  return undefined;
152
154
  }
153
155
 
156
+ // Query accounting (matchmaking-query-accounting-seam): a served query bumps `queriesPerMin` for the
157
+ // topic on the served coord's TrafficCounters; surfaces in a later reply's snapshot (lags one round).
158
+ // Sits AFTER the gate + no-engine guards (a dropped / unserved query never inflates the barometer) and
159
+ // BEFORE the reply build (a query that resolved a serving engine is counted on serve, even if signing
160
+ // later fails transiently). A decode failure threw before `topicId` resolved, so it never counts.
161
+ engine.recordQuery(topicId, (deps.clock ?? Date.now)());
162
+
154
163
  // Build the reply from a single synchronous read (records + traffic + epoch) so a concurrent gossip
155
164
  // round cannot tear the snapshot between read and sign. `handleMatchmakingQuery` forwards each record's
156
165
  // fields verbatim through the pure `evaluateQuery` (capability filter + limit truncation + entry
@@ -72,6 +72,7 @@ export class NetworkManagerService implements Startable {
72
72
  initRebalanceMonitor(
73
73
  partitionDetector: PartitionDetector,
74
74
  fretAdapter: ArachnodeFretAdapter,
75
+ trackedBlocks: Set<string>,
75
76
  config?: RebalanceMonitorConfig
76
77
  ): RebalanceMonitor {
77
78
  const libp2p = this.getLibp2p()
@@ -79,8 +80,10 @@ export class NetworkManagerService implements Startable {
79
80
  if (!libp2p || !fret) {
80
81
  throw new Error('Cannot init RebalanceMonitor: libp2p or FRET not available')
81
82
  }
83
+ // trackedBlocks is the shared owned-block set: the node passes the SAME Set instance into
84
+ // both monitor inits so spread and rebalance act on one source of truth and never drift.
82
85
  this.rebalanceMonitor = new RebalanceMonitor(
83
- { libp2p, fret, partitionDetector, fretAdapter },
86
+ { libp2p, fret, partitionDetector, fretAdapter, trackedBlocks },
84
87
  config
85
88
  )
86
89
  return this.rebalanceMonitor
@@ -102,6 +105,7 @@ export class NetworkManagerService implements Startable {
102
105
  peerNetwork: SpreadOnChurnDeps['peerNetwork'],
103
106
  clusterSize: number,
104
107
  protocolPrefix: string,
108
+ trackedBlocks: Set<string>,
105
109
  config?: Partial<SpreadOnChurnConfig>
106
110
  ): SpreadOnChurnMonitor {
107
111
  const libp2p = this.getLibp2p()
@@ -113,8 +117,10 @@ export class NetworkManagerService implements Startable {
113
117
  // BlockTransferClient with it, and the receiving node registers the block-transfer
114
118
  // handler under /optimystic/<networkName>. A missing/empty prefix dials the wrong
115
119
  // protocol and every churn push fails to connect.
120
+ // trackedBlocks is the shared owned-block set: the node passes the SAME Set instance into
121
+ // both monitor inits so spread and rebalance act on one source of truth and never drift.
116
122
  this.spreadOnChurnMonitor = new SpreadOnChurnMonitor(
117
- { libp2p, fret, partitionDetector, repo, peerNetwork, clusterSize, protocolPrefix },
123
+ { libp2p, fret, partitionDetector, repo, peerNetwork, clusterSize, protocolPrefix, trackedBlocks },
118
124
  config
119
125
  )
120
126
  return this.spreadOnChurnMonitor
@@ -0,0 +1,40 @@
1
+ import type { IRawStorage } from './storage/i-raw-storage.js';
2
+
3
+ /**
4
+ * Seed the resilience monitors' shared owned-block set from blocks already
5
+ * durable in raw storage from a previous process run.
6
+ *
7
+ * The live owned-block feed (`storageRepo.onAnyCollectionChange`, wired in
8
+ * `createLibp2pNodeBase`) only fires on NEW commits and RECEIVED replicas, so a
9
+ * block that was already durable on disk from a previous run is not tracked
10
+ * after a restart until it happens to be committed or replicated again — a
11
+ * freshly restarted node would under-protect exactly the data it already holds.
12
+ * This scan closes that gap by enumerating the metadata store (one id per block
13
+ * with a committed revision or persisted replica — the same population the live
14
+ * feed tracks) and adding each id to `ownedBlocks`.
15
+ *
16
+ * Called AFTER the live feed is already subscribed, so a block committed/replicated
17
+ * mid-scan is independently caught by the feed; `Set.add` is idempotent, so the
18
+ * overlap is harmless. `isStopping()` is checked each iteration so a scan over a
19
+ * huge store aborts promptly when the node is stopping (the `for await` then calls
20
+ * the iterator's `return()` to release the backend cursor). A cooperative yield
21
+ * every `yieldEvery` ids keeps a tight add-loop from monopolizing an event-loop tick.
22
+ *
23
+ * Backends that omit `listBlockIds` (or an in-memory backend with nothing durable
24
+ * across a restart) yield no seed — the monitors still populate over time via the
25
+ * live feed.
26
+ */
27
+ export async function seedOwnedBlocksFromStorage(
28
+ rawStorage: Pick<IRawStorage, 'listBlockIds'>,
29
+ ownedBlocks: Set<string>,
30
+ isStopping: () => boolean,
31
+ yieldEvery = 1000,
32
+ ): Promise<void> {
33
+ if (typeof rawStorage.listBlockIds !== 'function') return;
34
+ let n = 0;
35
+ for await (const blockId of rawStorage.listBlockIds()) {
36
+ if (isStopping()) break;
37
+ ownedBlocks.add(blockId);
38
+ if (++n % yieldEvery === 0) await new Promise((resolve) => setTimeout(resolve, 0));
39
+ }
40
+ }
@@ -4,6 +4,7 @@ import type { Stream as Libp2pStream } from '@libp2p/interface';
4
4
  import type { PeerId, IPeerNetwork } from '@optimystic/db-core';
5
5
  import { first } from './it-utility.js';
6
6
  import { createLogger } from './logger.js';
7
+ import { MAX_BLOCK_MESSAGE_BYTES } from './protocol-limits.js';
7
8
 
8
9
  const log = createLogger('protocol-client');
9
10
 
@@ -50,7 +51,7 @@ export class ProtocolClient {
50
51
  protected async processMessage<T>(
51
52
  message: unknown,
52
53
  protocol: string,
53
- options?: { signal?: AbortSignal; correlationId?: string; dialTimeoutMs?: number; responseTimeoutMs?: number }
54
+ options?: { signal?: AbortSignal; correlationId?: string; dialTimeoutMs?: number; responseTimeoutMs?: number; maxDataLength?: number }
54
55
  ): Promise<T> {
55
56
  const peer = this.peerId.toString();
56
57
  const cid = options?.correlationId;
@@ -144,11 +145,17 @@ export class ProtocolClient {
144
145
  stream.send(chunk);
145
146
  }
146
147
 
147
- // Read the response from the stream (which is now directly AsyncIterable)
148
+ // Read the response from the stream (which is now directly AsyncIterable).
149
+ // Cap the response frame size so a peer can't flood the client with an
150
+ // oversized reply; each caller passes the cap matching its response shape
151
+ // (control vs block), defaulting to the block cap so no existing caller
152
+ // regresses. An oversized frame rejects at the length-prefix (before
153
+ // allocation) and surfaces through the read's catch, tearing the stream down.
154
+ const maxDataLength = options?.maxDataLength ?? MAX_BLOCK_MESSAGE_BYTES;
148
155
  let firstByte = true;
149
156
  const source = pipe(
150
157
  stream,
151
- lpDecode,
158
+ (source) => lpDecode(source, { maxDataLength }),
152
159
  async function* (source) {
153
160
  for await (const data of source) {
154
161
  if (firstByte) {
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Inbound-message size caps for the db-p2p consensus protocols.
3
+ *
4
+ * Every inbound stream handler frames its wire data with length-prefixed
5
+ * encoding (`it-length-prefixed`) and `JSON.parse`s each frame. Passing one of
6
+ * these constants as the decoder's `maxDataLength` makes an oversized frame
7
+ * reject at the length-prefix (before any allocation against the declared size)
8
+ * instead of buffering up to the library default (4 MiB) of attacker-controlled
9
+ * JSON per frame. Mirrors the existing precedent
10
+ * `DEFAULT_MAX_MESSAGE_BYTES = 1024 * 1024` in
11
+ * `packages/db-core/src/cohort-topic/wire/codec.ts`.
12
+ */
13
+
14
+ /**
15
+ * Max size (bytes) of a single inbound framed message on a *control-plane*
16
+ * protocol — cluster records, dispute votes, sync requests. These carry a peer
17
+ * set + signatures + small metadata, never bulk block data.
18
+ */
19
+ export const MAX_CONTROL_MESSAGE_BYTES = 1 * 1024 * 1024; // 1 MiB
20
+
21
+ /**
22
+ * Max size (bytes) of a single inbound framed message on a *block-carrying*
23
+ * protocol — repo operations (pend/commit transforms + block bodies),
24
+ * block-transfer push payloads (base64-inflated block data, multiple per
25
+ * request), and the block-bearing responses those protocols return.
26
+ *
27
+ * NOTE: the repo enforces no hard per-block byte ceiling today, so this is a
28
+ * heuristic. If a legitimate block/transform can exceed this, the transfer will
29
+ * be rejected — raise this constant (or thread a config override) rather than
30
+ * removing the cap. If the repo later grows a real max-block constant, derive
31
+ * this cap from it.
32
+ */
33
+ export const MAX_BLOCK_MESSAGE_BYTES = 8 * 1024 * 1024; // 8 MiB
@@ -5,6 +5,7 @@ import type {
5
5
  import type { RepoMessage } from "@optimystic/db-core";
6
6
  import { blockIdsForTransforms, blockIdToBytes } from "@optimystic/db-core";
7
7
  import { ProtocolClient } from "../protocol-client.js";
8
+ import { MAX_BLOCK_MESSAGE_BYTES } from "../protocol-limits.js";
8
9
  import { peerIdFromString } from "@libp2p/peer-id";
9
10
 
10
11
  export class RepoClient extends ProtocolClient implements IRepo {
@@ -94,6 +95,8 @@ export class RepoClient extends ProtocolClient implements IRepo {
94
95
  signal: combinedSignal,
95
96
  correlationId,
96
97
  dialTimeoutMs: options?.dialTimeoutMs,
98
+ // A get response carries block data → block cap (not control).
99
+ maxDataLength: MAX_BLOCK_MESSAGE_BYTES,
97
100
  })
98
101
  } finally {
99
102
  clearTimeout(timer)
@@ -1,7 +1,6 @@
1
1
  import { peerIdFromString } from "@libp2p/peer-id";
2
2
  import type { ClusterRecord, IKeyNetwork, RepoMessage, BlockId, ClusterPeers, MessageOptions, ClusterConsensusConfig } from "@optimystic/db-core";
3
- import { base58btc } from "multiformats/bases/base58";
4
- import { sha256 } from "multiformats/hashes/sha2";
3
+ import { CURRENT_MEMBERSHIP_VERSION, computeClusterMessageHash, membershipDigest } from "@optimystic/db-core";
5
4
  import { ClusterClient } from "../cluster/client.js";
6
5
  import { Pending } from "@optimystic/db-core";
7
6
  import type { PeerId } from "@libp2p/interface";
@@ -14,6 +13,34 @@ import type { ITransactionStateStore } from "../cluster/i-transaction-state-stor
14
13
 
15
14
  const log = createLogger('cluster')
16
15
 
16
+ /** Cancel handle for an injected timer; cancels a not-yet-fired timer (safe no-op after fire/cancel). */
17
+ export type TimerCancel = () => void;
18
+
19
+ /**
20
+ * Production timer binding: a one-shot `setTimeout` whose handle is **unref'd** so a pending
21
+ * commit-retry (or the deferred transaction cleanup) never keeps an otherwise-idle process alive.
22
+ * The returned handle clears the timeout (idempotent). Mirrors the reactivity rotation
23
+ * re-registration scheduler's `defaultSetTimer` (see reactivity/rotation-rereg-scheduler.ts).
24
+ */
25
+ function defaultSetTimer(fn: () => void, delayMs: number): TimerCancel {
26
+ const handle = setTimeout(fn, delayMs);
27
+ // An idle retry/cleanup timer must not pin a process (mirror rotation re-registration + push-state gossip).
28
+ (handle as { unref?: () => void }).unref?.();
29
+ return (): void => clearTimeout(handle);
30
+ }
31
+
32
+ /**
33
+ * Optional injection seam for deterministic time. Production leaves both undefined and gets
34
+ * `Date.now` + an unref'd `setTimeout`; tests inject a fake clock + timer queue so scheduled
35
+ * commit-retries fire in virtual (not wall-clock) time.
36
+ */
37
+ export interface ClusterCoordinatorClock {
38
+ /** Clock (Unix ms). Defaults to `Date.now`. */
39
+ now?: () => number;
40
+ /** Schedule a one-shot timer, returning a cancel handle. Defaults to an unref'd `setTimeout`. */
41
+ setTimer?: (fn: () => void, delayMs: number) => TimerCancel;
42
+ }
43
+
17
44
  /**
18
45
  * Manages the state of cluster transactions for a specific block ID
19
46
  */
@@ -21,7 +48,7 @@ interface CommitRetryState {
21
48
  pendingPeers: Set<string>;
22
49
  attempt: number;
23
50
  intervalMs: number;
24
- timer?: NodeJS.Timeout;
51
+ cancel?: TimerCancel;
25
52
  }
26
53
 
27
54
  interface ClusterTransactionState {
@@ -42,6 +69,10 @@ export class ClusterCoordinator {
42
69
  private readonly retryMaxIntervalMs: number;
43
70
  private readonly retryMaxAttempts: number;
44
71
  private readonly commitBroadcastImmediateRetries: number;
72
+ private readonly promiseImmediateRetries: number;
73
+ /** Injected clock/timer seam; production defaults to `Date.now` + unref'd `setTimeout`. */
74
+ private readonly now: () => number;
75
+ private readonly setTimer: (fn: () => void, delayMs: number) => TimerCancel;
45
76
 
46
77
  constructor(
47
78
  private readonly keyNetwork: IKeyNetwork,
@@ -54,31 +85,71 @@ export class ClusterCoordinator {
54
85
  },
55
86
  private readonly fretService?: FretService,
56
87
  private readonly reputation?: IPeerReputation,
57
- private readonly stateStore?: ITransactionStateStore
88
+ private readonly stateStore?: ITransactionStateStore,
89
+ clock?: ClusterCoordinatorClock
58
90
  ) {
59
91
  this.retryInitialIntervalMs = cfg.commitBroadcastRetryInitialMs ?? 250;
60
92
  this.retryBackoffFactor = cfg.commitBroadcastRetryBackoffFactor ?? 2;
61
93
  this.retryMaxIntervalMs = cfg.commitBroadcastRetryMaxIntervalMs ?? 8000;
62
94
  this.retryMaxAttempts = cfg.commitBroadcastRetryMaxAttempts ?? 5;
63
95
  this.commitBroadcastImmediateRetries = cfg.commitBroadcastImmediateRetries ?? 1;
96
+ this.promiseImmediateRetries = cfg.promiseImmediateRetries ?? 1;
97
+ this.now = clock?.now ?? ((): number => Date.now());
98
+ this.setTimer = clock?.setTimer ?? defaultSetTimer;
64
99
  }
65
100
 
66
101
  /**
67
- * Creates a base 58 BTC string hash for a message to uniquely identify a transaction
102
+ * Invoke one cluster member's `update`, retrying transient REMOTE failures up to
103
+ * `immediateRetries` times before surfacing the error. The local cluster is invoked
104
+ * exactly once — a local throw is a real fault (validation / merge / consensus), not a
105
+ * transient transport blip. A remote call rides a libp2p stream that a circuit-relay
106
+ * ("limited") connection can reset once a per-circuit cap or reservation lapses, which
107
+ * surfaces as a StreamResetError; an immediate retry on the (usually still-warm)
108
+ * connection recovers most of those without escalating the peer to a failure. Shared by
109
+ * the promise-collection, commit-collection, and commit-broadcast phases so all three
110
+ * react to a relayed reset the same way.
68
111
  */
69
- /** Deterministic JSON: sorts object keys so hash is order-independent */
70
- private static canonicalJson(value: unknown): string {
71
- return JSON.stringify(value, (_, v) =>
72
- v && typeof v === 'object' && !Array.isArray(v)
73
- ? Object.keys(v).sort().reduce((o: Record<string, unknown>, k) => { o[k] = v[k]; return o; }, {})
74
- : v
75
- );
112
+ private async updateMember(peerIdStr: string, record: ClusterRecord, immediateRetries: number, phase: string): Promise<ClusterRecord> {
113
+ const isLocal = this.localCluster && peerIdStr === this.localCluster.peerId.toString();
114
+ if (isLocal) {
115
+ return await this.localCluster!.update(record);
116
+ }
117
+ const maxAttempts = 1 + Math.max(0, immediateRetries);
118
+ let lastError: unknown;
119
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
120
+ try {
121
+ return await this.createClusterClient(peerIdFromString(peerIdStr)).update(record);
122
+ } catch (err) {
123
+ lastError = err;
124
+ if (attempt < maxAttempts) {
125
+ log('cluster-tx:member-update-retry', {
126
+ messageHash: record.messageHash,
127
+ peerId: peerIdStr,
128
+ phase,
129
+ attempt,
130
+ error: err instanceof Error ? err.message : String(err)
131
+ });
132
+ }
133
+ }
134
+ }
135
+ throw lastError;
76
136
  }
77
137
 
78
- private async createMessageHash(message: RepoMessage): Promise<string> {
79
- const msgBytes = new TextEncoder().encode(ClusterCoordinator.canonicalJson(message));
80
- const hashBytes = await sha256.digest(msgBytes);
81
- return base58btc.encode(hashBytes.digest);
138
+ /**
139
+ * Creates a base58btc string hash uniquely identifying a transaction. For a v2 record the caller
140
+ * threads in the {@link membershipDigest} of the peer set so the responsible membership is bound into
141
+ * the identity (two different peer sets ⇒ two different hashes). Omitting `membershipDigestValue`
142
+ * reproduces the legacy v1 hash byte-for-byte.
143
+ *
144
+ * NOTE: the whole `message` is hashed (canonicalJson), so a transaction's advisory aged priority —
145
+ * which rides inside the pend operation as `pend.transaction.priority` (multi-collection) or
146
+ * `pend.priority` (single-collection) — is automatically covered here and by the derived
147
+ * promise/commit hashes. That is what makes priority integrity-protected in transit: a relaying peer
148
+ * cannot strip or inflate it without invalidating the message hash the members verify. No separate
149
+ * priority-hashing step is needed.
150
+ */
151
+ private async createMessageHash(message: RepoMessage, membershipDigestValue?: string): Promise<string> {
152
+ return computeClusterMessageHash(message, membershipDigestValue);
82
153
  }
83
154
 
84
155
  /**
@@ -97,11 +168,15 @@ export class ClusterCoordinator {
97
168
  }
98
169
  }
99
170
 
100
- private makeRecord(peers: ClusterPeers, messageHash: string, message: RepoMessage): ClusterRecord {
171
+ private makeRecord(peers: ClusterPeers, messageHash: string, message: RepoMessage, membershipDigestValue: string): ClusterRecord {
101
172
  const peerCount = Object.keys(peers ?? {}).length;
102
173
  const record: ClusterRecord = {
103
174
  messageHash,
104
175
  peers,
176
+ // v2: bind the responsible membership into the signed identity. messageHash was computed over
177
+ // this same digest, so a different peer set would have produced a different messageHash.
178
+ membershipVersion: CURRENT_MEMBERSHIP_VERSION,
179
+ membershipDigest: membershipDigestValue,
105
180
  message,
106
181
  coordinatingBlockIds: message.coordinatingBlockIds,
107
182
  promises: {},
@@ -137,11 +212,16 @@ export class ClusterCoordinator {
137
212
  // Get the cluster peers for this block
138
213
  const peers = await this.getClusterForBlock(blockId);
139
214
 
140
- // Create a unique hash for this transaction
141
- const messageHash = await this.createMessageHash(message);
215
+ // Bind the responsible membership into the transaction identity (v2): the digest is folded into
216
+ // the messageHash below, so two different peer sets produce two different messageHashes rather
217
+ // than one hash with a silent internal disagreement about who is responsible.
218
+ const membershipDigestValue = await membershipDigest(peers);
219
+
220
+ // Create a unique hash for this transaction (over message + membership digest)
221
+ const messageHash = await this.createMessageHash(message, membershipDigestValue);
142
222
 
143
223
  // Create a cluster record for this transaction
144
- const record = this.makeRecord(peers, messageHash, message);
224
+ const record = this.makeRecord(peers, messageHash, message, membershipDigestValue);
145
225
  log('cluster-tx:start', {
146
226
  messageHash,
147
227
  blockId,
@@ -161,7 +241,7 @@ export class ClusterCoordinator {
161
241
  messageHash,
162
242
  record,
163
243
  pending,
164
- lastUpdate: Date.now()
244
+ lastUpdate: this.now()
165
245
  };
166
246
  this.transactions.set(messageHash, state);
167
247
  this.persistCoordinatorState(messageHash, record, 'promising');
@@ -192,7 +272,7 @@ export class ClusterCoordinator {
192
272
  // Let the retry completion or abort handle cleanup
193
273
  if (!stored?.retry) {
194
274
  // Wait a bit before cleanup to allow any in-flight responses to arrive
195
- setTimeout(() => {
275
+ this.setTimer(() => {
196
276
  this.transactions.delete(messageHash);
197
277
  this.deleteCoordinatorState(messageHash);
198
278
  log('cluster-tx:transaction-remove', {
@@ -290,6 +370,11 @@ export class ClusterCoordinator {
290
370
  rejectionCount,
291
371
  peerCount
292
372
  });
373
+ // [dispute-subsystem-dormant] Evidence is computed and persisted but initiateDispute() is
374
+ // intentionally NOT called here. Dispute origination stays dormant pending arbitrator-set
375
+ // anchoring — without it a forged synthetic cohort passes resolution.
376
+ // Gate: tickets/backlog/hardening/invalidation-live-wiring-requires-arbitrator-set-anchoring
377
+ // Wiring plan: tickets/backlog/feat-dispute-subsystem-live-activation
293
378
  }
294
379
 
295
380
  this.persistCoordinatorState(promised.record.messageHash, promised.record, 'committing');
@@ -331,13 +416,18 @@ export class ClusterCoordinator {
331
416
  }
332
417
  }
333
418
 
334
- // Fallback: accept small clusters in development/testing scenarios
335
- // In production, FRET should provide validation
336
- log('cluster-tx:small-cluster-accepted-without-validation', {
419
+ // Fallback: with no confident network-size estimate, fail CLOSED by default.
420
+ // An undersized cluster with no way to justify its size is unsafe (a lone/
421
+ // near-lone node could rubber-stamp its own writes), so reject unless the
422
+ // operator has explicitly opted in via allowUnvalidatedSmallCluster (e.g.
423
+ // single-node / local dev knowingly running below the floor).
424
+ const admit = this.cfg.allowUnvalidatedSmallCluster ?? false;
425
+ log('cluster-tx:small-cluster-no-confident-estimate', {
337
426
  localSize,
338
- reason: 'no-confident-network-size-estimate'
427
+ reason: 'no-confident-network-size-estimate',
428
+ admit
339
429
  });
340
- return true;
430
+ return admit;
341
431
  }
342
432
 
343
433
  /**
@@ -353,14 +443,15 @@ export class ClusterCoordinator {
353
443
  }));
354
444
  log('cluster-tx:promise-peers', { messageHash: record.messageHash, peers: peerDetail });
355
445
  }
356
- // For each peer, create a client and request a promise
446
+ // For each peer, create a client and request a promise. A remote promise rides
447
+ // a libp2p stream that a relayed (limited) connection can reset transiently, so
448
+ // each remote request gets `promiseImmediateRetries` in-line re-attempts before
449
+ // it counts as a failure — without this a single relayed reset drops the peer and
450
+ // sinks super-majority (the commit broadcast already has the same guard).
357
451
  const promiseRequests = peerIds.map(peerIdStr => {
358
452
  const isLocal = this.localCluster && peerIdStr === this.localCluster.peerId.toString();
359
453
  log('cluster-tx:promise-request', { messageHash: record.messageHash, peerId: peerIdStr, isLocal });
360
- const promise = isLocal
361
- ? this.localCluster!.update(record)
362
- : this.createClusterClient(peerIdFromString(peerIdStr)).update(record);
363
- return new Pending(promise);
454
+ return new Pending(this.updateMember(peerIdStr, record, this.promiseImmediateRetries, 'promise'));
364
455
  });
365
456
 
366
457
  // Wait for all promises to complete
@@ -455,6 +546,10 @@ export class ClusterCoordinator {
455
546
  const commitPayload = {
456
547
  ...record
457
548
  };
549
+ // No per-peer immediate retry here: a commit-collection failure is recovered
550
+ // downstream by broadcastMergedRecord's in-line retry and the scheduled
551
+ // commit-retry timer. (The promise phase has no such backstop, which is why
552
+ // collectPromises gets the immediate retry instead.)
458
553
  const commitRequests = peerIds.map(peerIdStr => {
459
554
  const isLocal = this.localCluster && peerIdStr === this.localCluster.peerId.toString();
460
555
  log('cluster-tx:commit-request', { messageHash: record.messageHash, peerId: peerIdStr, isLocal });
@@ -560,46 +655,18 @@ export class ClusterCoordinator {
560
655
  * Local cluster is invoked exactly once — local failures are fatal, not transient.
561
656
  */
562
657
  private async broadcastMergedRecord(record: ClusterRecord, peerIds: string[]): Promise<{ failures: string[] }> {
563
- const maxAttempts = 1 + Math.max(0, this.commitBroadcastImmediateRetries);
564
658
  const results = await Promise.all(peerIds.map(async peerIdStr => {
565
- const isLocal = this.localCluster && peerIdStr === this.localCluster.peerId.toString();
566
- if (isLocal) {
567
- try {
568
- await this.localCluster!.update(record);
569
- return { peerId: peerIdStr, success: true as const };
570
- } catch (err) {
571
- log('cluster-tx:consensus-broadcast-error', {
572
- messageHash: record.messageHash,
573
- peerId: peerIdStr,
574
- error: (err as Error).message
575
- });
576
- return { peerId: peerIdStr, success: false as const };
577
- }
578
- }
579
- let lastError: unknown;
580
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
581
- try {
582
- await this.createClusterClient(peerIdFromString(peerIdStr)).update(record);
583
- return { peerId: peerIdStr, success: true as const };
584
- } catch (err) {
585
- lastError = err;
586
- if (attempt < maxAttempts) {
587
- log('cluster-tx:consensus-broadcast-retry', {
588
- messageHash: record.messageHash,
589
- peerId: peerIdStr,
590
- attempt,
591
- error: (err as Error).message
592
- });
593
- }
594
- }
659
+ try {
660
+ await this.updateMember(peerIdStr, record, this.commitBroadcastImmediateRetries, 'commit-broadcast');
661
+ return { peerId: peerIdStr, success: true as const };
662
+ } catch (err) {
663
+ log('cluster-tx:consensus-broadcast-error', {
664
+ messageHash: record.messageHash,
665
+ peerId: peerIdStr,
666
+ error: err instanceof Error ? err.message : String(err)
667
+ });
668
+ return { peerId: peerIdStr, success: false as const };
595
669
  }
596
- log('cluster-tx:consensus-broadcast-error', {
597
- messageHash: record.messageHash,
598
- peerId: peerIdStr,
599
- attempts: maxAttempts,
600
- error: lastError instanceof Error ? lastError.message : String(lastError)
601
- });
602
- return { peerId: peerIdStr, success: false as const };
603
670
  }));
604
671
  const failures = results.filter(r => !r.success).map(r => r.peerId);
605
672
  return { failures };
@@ -612,7 +679,7 @@ export class ClusterCoordinator {
612
679
  return;
613
680
  }
614
681
  state.record = { ...record };
615
- state.lastUpdate = Date.now();
682
+ state.lastUpdate = this.now();
616
683
  log('cluster-tx:transaction-update', {
617
684
  messageHash: record.messageHash,
618
685
  stage,
@@ -638,17 +705,15 @@ export class ClusterCoordinator {
638
705
  }
639
706
  const pendingPeers = new Set(missingPeers);
640
707
  const baseInterval = existing ? Math.min(existing.intervalMs * this.retryBackoffFactor, this.retryMaxIntervalMs) : this.retryInitialIntervalMs;
641
- if (existing?.timer) {
642
- clearTimeout(existing.timer);
643
- }
644
- const timer = setTimeout(() => {
708
+ existing?.cancel?.();
709
+ const cancel = this.setTimer(() => {
645
710
  void this.retryCommits(messageHash);
646
711
  }, baseInterval);
647
712
  state.retry = {
648
713
  pendingPeers,
649
714
  attempt: nextAttempt,
650
715
  intervalMs: baseInterval,
651
- timer
716
+ cancel
652
717
  };
653
718
  this.persistCoordinatorState(messageHash, state.record, 'broadcasting', {
654
719
  pendingPeers: Array.from(pendingPeers),
@@ -713,12 +778,10 @@ export class ClusterCoordinator {
713
778
  if (!state?.retry) {
714
779
  return;
715
780
  }
716
- if (state.retry.timer) {
717
- clearTimeout(state.retry.timer);
718
- }
781
+ state.retry.cancel?.();
719
782
  state.retry = undefined;
720
783
  // Clean up the transaction after retry is complete
721
- setTimeout(() => {
784
+ this.setTimer(() => {
722
785
  this.transactions.delete(messageHash);
723
786
  this.deleteCoordinatorState(messageHash);
724
787
  log('cluster-tx:transaction-remove', {
@@ -739,7 +802,7 @@ export class ClusterCoordinator {
739
802
  this.stateStore.saveCoordinatorState(messageHash, {
740
803
  messageHash,
741
804
  record,
742
- lastUpdate: Date.now(),
805
+ lastUpdate: this.now(),
743
806
  phase,
744
807
  retryState
745
808
  }).catch(err => log('cluster-tx:persist-error', { messageHash, error: (err as Error).message }));
@@ -762,7 +825,7 @@ export class ClusterCoordinator {
762
825
  for (const state of states) {
763
826
  const { messageHash } = state;
764
827
  // Expired — clean up
765
- if (state.record.message.expiration && state.record.message.expiration < Date.now()) {
828
+ if (state.record.message.expiration && state.record.message.expiration < this.now()) {
766
829
  log('cluster-tx:recovery-expired', { messageHash });
767
830
  await this.stateStore.deleteCoordinatorState(messageHash);
768
831
  continue;