@optimystic/db-p2p 0.24.2 → 0.25.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 (249) hide show
  1. package/{readme.md → README.md} +688 -668
  2. package/dist/src/cluster/block-transfer-service.d.ts +133 -10
  3. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  4. package/dist/src/cluster/block-transfer-service.js +155 -9
  5. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  6. package/dist/src/cluster/block-transfer.d.ts +67 -7
  7. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  8. package/dist/src/cluster/block-transfer.js +127 -24
  9. package/dist/src/cluster/block-transfer.js.map +1 -1
  10. package/dist/src/cluster/certified-claims.d.ts +170 -0
  11. package/dist/src/cluster/certified-claims.d.ts.map +1 -0
  12. package/dist/src/cluster/certified-claims.js +200 -0
  13. package/dist/src/cluster/certified-claims.js.map +1 -0
  14. package/dist/src/cluster/client-signature-verifier.d.ts +23 -0
  15. package/dist/src/cluster/client-signature-verifier.d.ts.map +1 -0
  16. package/dist/src/cluster/client-signature-verifier.js +33 -0
  17. package/dist/src/cluster/client-signature-verifier.js.map +1 -0
  18. package/dist/src/cluster/cluster-policy.d.ts +9 -3
  19. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  20. package/dist/src/cluster/cluster-policy.js +111 -27
  21. package/dist/src/cluster/cluster-policy.js.map +1 -1
  22. package/dist/src/cluster/cluster-repo.d.ts +140 -11
  23. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  24. package/dist/src/cluster/cluster-repo.js +485 -29
  25. package/dist/src/cluster/cluster-repo.js.map +1 -1
  26. package/dist/src/cluster/commit-proof.d.ts +134 -0
  27. package/dist/src/cluster/commit-proof.d.ts.map +1 -0
  28. package/dist/src/cluster/commit-proof.js +263 -0
  29. package/dist/src/cluster/commit-proof.js.map +1 -0
  30. package/dist/src/cluster/quorum-restore.d.ts +95 -8
  31. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  32. package/dist/src/cluster/quorum-restore.js +113 -14
  33. package/dist/src/cluster/quorum-restore.js.map +1 -1
  34. package/dist/src/cluster/rebalance-monitor.d.ts +84 -0
  35. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  36. package/dist/src/cluster/rebalance-monitor.js +207 -5
  37. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  38. package/dist/src/cluster/reconcile-block.d.ts +27 -7
  39. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  40. package/dist/src/cluster/reconcile-block.js +179 -38
  41. package/dist/src/cluster/reconcile-block.js.map +1 -1
  42. package/dist/src/cluster/service.d.ts +13 -4
  43. package/dist/src/cluster/service.d.ts.map +1 -1
  44. package/dist/src/cluster/service.js +17 -18
  45. package/dist/src/cluster/service.js.map +1 -1
  46. package/dist/src/cluster/spread-on-churn.d.ts +8 -2
  47. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  48. package/dist/src/cluster/spread-on-churn.js +7 -7
  49. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  50. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  51. package/dist/src/cohort-topic/host.js +8 -7
  52. package/dist/src/cohort-topic/host.js.map +1 -1
  53. package/dist/src/cohort-topic/stream-util.d.ts +7 -6
  54. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  55. package/dist/src/cohort-topic/stream-util.js +14 -41
  56. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  57. package/dist/src/dispute/cascade.d.ts +7 -8
  58. package/dist/src/dispute/cascade.d.ts.map +1 -1
  59. package/dist/src/dispute/cascade.js +24 -7
  60. package/dist/src/dispute/cascade.js.map +1 -1
  61. package/dist/src/dispute/dispute-service.d.ts +5 -0
  62. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  63. package/dist/src/dispute/dispute-service.js +13 -3
  64. package/dist/src/dispute/dispute-service.js.map +1 -1
  65. package/dist/src/dispute/invalidation.d.ts +33 -15
  66. package/dist/src/dispute/invalidation.d.ts.map +1 -1
  67. package/dist/src/dispute/invalidation.js +127 -34
  68. package/dist/src/dispute/invalidation.js.map +1 -1
  69. package/dist/src/dispute/service.d.ts.map +1 -1
  70. package/dist/src/dispute/service.js +2 -1
  71. package/dist/src/dispute/service.js.map +1 -1
  72. package/dist/src/index.d.ts +7 -0
  73. package/dist/src/index.d.ts.map +1 -1
  74. package/dist/src/index.js +7 -0
  75. package/dist/src/index.js.map +1 -1
  76. package/dist/src/libp2p-key-network.d.ts +14 -10
  77. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  78. package/dist/src/libp2p-key-network.js +28 -58
  79. package/dist/src/libp2p-key-network.js.map +1 -1
  80. package/dist/src/libp2p-node-base.d.ts +38 -7
  81. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  82. package/dist/src/libp2p-node-base.js +148 -63
  83. package/dist/src/libp2p-node-base.js.map +1 -1
  84. package/dist/src/network/open-protocol-stream.d.ts +56 -0
  85. package/dist/src/network/open-protocol-stream.d.ts.map +1 -0
  86. package/dist/src/network/open-protocol-stream.js +60 -0
  87. package/dist/src/network/open-protocol-stream.js.map +1 -0
  88. package/dist/src/network/register-protocol-handler.d.ts +66 -0
  89. package/dist/src/network/register-protocol-handler.d.ts.map +1 -0
  90. package/dist/src/network/register-protocol-handler.js +51 -0
  91. package/dist/src/network/register-protocol-handler.js.map +1 -0
  92. package/dist/src/peer-address-book.d.ts +60 -2
  93. package/dist/src/peer-address-book.d.ts.map +1 -1
  94. package/dist/src/peer-address-book.js +89 -1
  95. package/dist/src/peer-address-book.js.map +1 -1
  96. package/dist/src/pend-validation.d.ts +65 -0
  97. package/dist/src/pend-validation.d.ts.map +1 -0
  98. package/dist/src/pend-validation.js +69 -0
  99. package/dist/src/pend-validation.js.map +1 -0
  100. package/dist/src/reactivity/notify-transport.d.ts.map +1 -1
  101. package/dist/src/reactivity/notify-transport.js +2 -1
  102. package/dist/src/reactivity/notify-transport.js.map +1 -1
  103. package/dist/src/reactivity/push-state-gossip.d.ts.map +1 -1
  104. package/dist/src/reactivity/push-state-gossip.js +2 -1
  105. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  106. package/dist/src/repo/cluster-coordinator.d.ts +32 -6
  107. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  108. package/dist/src/repo/cluster-coordinator.js +44 -10
  109. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  110. package/dist/src/repo/coordinator-repo.d.ts +181 -25
  111. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  112. package/dist/src/repo/coordinator-repo.js +650 -62
  113. package/dist/src/repo/coordinator-repo.js.map +1 -1
  114. package/dist/src/repo/served-repo-proxy.d.ts +30 -0
  115. package/dist/src/repo/served-repo-proxy.d.ts.map +1 -0
  116. package/dist/src/repo/served-repo-proxy.js +50 -0
  117. package/dist/src/repo/served-repo-proxy.js.map +1 -0
  118. package/dist/src/repo/service.d.ts +16 -1
  119. package/dist/src/repo/service.d.ts.map +1 -1
  120. package/dist/src/repo/service.js +18 -16
  121. package/dist/src/repo/service.js.map +1 -1
  122. package/dist/src/reputation/types.d.ts +3 -1
  123. package/dist/src/reputation/types.d.ts.map +1 -1
  124. package/dist/src/reputation/types.js +3 -1
  125. package/dist/src/reputation/types.js.map +1 -1
  126. package/dist/src/rn.d.ts +24 -2
  127. package/dist/src/rn.d.ts.map +1 -1
  128. package/dist/src/rn.js +33 -4
  129. package/dist/src/rn.js.map +1 -1
  130. package/dist/src/storage/block-archive.d.ts +153 -0
  131. package/dist/src/storage/block-archive.d.ts.map +1 -0
  132. package/dist/src/storage/block-archive.js +194 -0
  133. package/dist/src/storage/block-archive.js.map +1 -0
  134. package/dist/src/storage/block-latch.d.ts +92 -0
  135. package/dist/src/storage/block-latch.d.ts.map +1 -0
  136. package/dist/src/storage/block-latch.js +132 -0
  137. package/dist/src/storage/block-latch.js.map +1 -0
  138. package/dist/src/storage/block-storage.d.ts +169 -12
  139. package/dist/src/storage/block-storage.d.ts.map +1 -1
  140. package/dist/src/storage/block-storage.js +415 -152
  141. package/dist/src/storage/block-storage.js.map +1 -1
  142. package/dist/src/storage/cached-raw-storage.d.ts +5 -1
  143. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
  144. package/dist/src/storage/cached-raw-storage.js +16 -1
  145. package/dist/src/storage/cached-raw-storage.js.map +1 -1
  146. package/dist/src/storage/cached-store-driver.d.ts +14 -4
  147. package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
  148. package/dist/src/storage/cached-store-driver.js +39 -5
  149. package/dist/src/storage/cached-store-driver.js.map +1 -1
  150. package/dist/src/storage/i-block-storage.d.ts +70 -19
  151. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  152. package/dist/src/storage/i-block-storage.js +21 -1
  153. package/dist/src/storage/i-block-storage.js.map +1 -1
  154. package/dist/src/storage/i-raw-storage.d.ts +38 -0
  155. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  156. package/dist/src/storage/kv-raw-storage.d.ts +14 -8
  157. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
  158. package/dist/src/storage/kv-raw-storage.js +29 -9
  159. package/dist/src/storage/kv-raw-storage.js.map +1 -1
  160. package/dist/src/storage/memory-store-driver.d.ts +4 -1
  161. package/dist/src/storage/memory-store-driver.d.ts.map +1 -1
  162. package/dist/src/storage/memory-store-driver.js +22 -2
  163. package/dist/src/storage/memory-store-driver.js.map +1 -1
  164. package/dist/src/storage/raw-store-codec.js +2 -2
  165. package/dist/src/storage/raw-store-driver.d.ts +44 -3
  166. package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
  167. package/dist/src/storage/shared-cache-pool.d.ts +60 -3
  168. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
  169. package/dist/src/storage/shared-cache-pool.js +68 -4
  170. package/dist/src/storage/shared-cache-pool.js.map +1 -1
  171. package/dist/src/storage/storage-repo.d.ts +204 -26
  172. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  173. package/dist/src/storage/storage-repo.js +422 -119
  174. package/dist/src/storage/storage-repo.js.map +1 -1
  175. package/dist/src/storage/store-identity.d.ts +21 -0
  176. package/dist/src/storage/store-identity.d.ts.map +1 -0
  177. package/dist/src/storage/store-identity.js +33 -0
  178. package/dist/src/storage/store-identity.js.map +1 -0
  179. package/dist/src/storage/struct.d.ts +15 -0
  180. package/dist/src/storage/struct.d.ts.map +1 -1
  181. package/dist/src/storage/with-read-cache.d.ts +92 -0
  182. package/dist/src/storage/with-read-cache.d.ts.map +1 -0
  183. package/dist/src/storage/with-read-cache.js +132 -0
  184. package/dist/src/storage/with-read-cache.js.map +1 -0
  185. package/dist/src/sync/service.d.ts +13 -3
  186. package/dist/src/sync/service.d.ts.map +1 -1
  187. package/dist/src/sync/service.js +7 -30
  188. package/dist/src/sync/service.js.map +1 -1
  189. package/dist/src/testing/mesh-harness.d.ts +75 -2
  190. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  191. package/dist/src/testing/mesh-harness.js +193 -81
  192. package/dist/src/testing/mesh-harness.js.map +1 -1
  193. package/dist/src/testing/raw-storage-conformance.d.ts +8 -0
  194. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  195. package/dist/src/testing/raw-storage-conformance.js +97 -2
  196. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  197. package/package.json +5 -6
  198. package/src/cluster/block-transfer-service.ts +528 -312
  199. package/src/cluster/block-transfer.ts +547 -424
  200. package/src/cluster/certified-claims.ts +319 -0
  201. package/src/cluster/client-signature-verifier.ts +34 -0
  202. package/src/cluster/cluster-policy.ts +294 -203
  203. package/src/cluster/cluster-repo.ts +2501 -2034
  204. package/src/cluster/commit-proof.ts +344 -0
  205. package/src/cluster/quorum-restore.ts +348 -223
  206. package/src/cluster/rebalance-monitor.ts +539 -254
  207. package/src/cluster/reconcile-block.ts +404 -203
  208. package/src/cluster/service.ts +23 -20
  209. package/src/cluster/spread-on-churn.ts +352 -346
  210. package/src/cohort-topic/host.ts +8 -7
  211. package/src/cohort-topic/stream-util.ts +14 -43
  212. package/src/dispute/cascade.ts +31 -15
  213. package/src/dispute/dispute-service.ts +706 -696
  214. package/src/dispute/invalidation.ts +162 -52
  215. package/src/dispute/service.ts +126 -125
  216. package/src/index.ts +7 -0
  217. package/src/libp2p-key-network.ts +1204 -1235
  218. package/src/libp2p-node-base.ts +185 -70
  219. package/src/network/open-protocol-stream.ts +87 -0
  220. package/src/network/register-protocol-handler.ts +85 -0
  221. package/src/peer-address-book.ts +96 -2
  222. package/src/pend-validation.ts +89 -0
  223. package/src/reactivity/notify-transport.ts +2 -1
  224. package/src/reactivity/push-state-gossip.ts +2 -1
  225. package/src/repo/cluster-coordinator.ts +65 -11
  226. package/src/repo/coordinator-repo.ts +729 -71
  227. package/src/repo/served-repo-proxy.ts +55 -0
  228. package/src/repo/service.ts +25 -16
  229. package/src/reputation/types.ts +3 -1
  230. package/src/rn.ts +34 -4
  231. package/src/storage/block-archive.ts +242 -0
  232. package/src/storage/block-latch.ts +144 -0
  233. package/src/storage/block-storage.ts +787 -498
  234. package/src/storage/cached-raw-storage.ts +21 -1
  235. package/src/storage/cached-store-driver.ts +898 -859
  236. package/src/storage/i-block-storage.ts +172 -116
  237. package/src/storage/i-raw-storage.ts +40 -0
  238. package/src/storage/kv-raw-storage.ts +34 -9
  239. package/src/storage/memory-store-driver.ts +26 -2
  240. package/src/storage/raw-store-codec.ts +42 -42
  241. package/src/storage/raw-store-driver.ts +45 -3
  242. package/src/storage/shared-cache-pool.ts +516 -452
  243. package/src/storage/storage-repo.ts +1421 -1014
  244. package/src/storage/store-identity.ts +41 -0
  245. package/src/storage/struct.ts +52 -34
  246. package/src/storage/with-read-cache.ts +182 -0
  247. package/src/sync/service.ts +178 -198
  248. package/src/testing/mesh-harness.ts +524 -344
  249. package/src/testing/raw-storage-conformance.ts +114 -2
@@ -1,11 +1,12 @@
1
1
  import { peerIdFromString } from '@libp2p/peer-id';
2
2
  import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string';
3
3
  import type {
4
- ActionId, BlockId, IBlock, Log,
4
+ ActionId, ActionRev, BlockId, IBlock, Log,
5
5
  DisputeResolutionProof, ArbitrationVoteProof, RevertedBlock,
6
6
  } from '@optimystic/db-core';
7
7
  import { applyTransform, hashString } from '@optimystic/db-core';
8
8
  import type { IBlockStorage } from '../storage/i-block-storage.js';
9
+ import { acquireBlockWriteLatches } from '../storage/block-latch.js';
9
10
  import type { DisputeResolution, ArbitrationVote } from './types.js';
10
11
  import { createLogger } from '../logger.js';
11
12
 
@@ -478,16 +479,6 @@ export type InvalidationContext = {
478
479
  readonly log: Log<unknown>;
479
480
  /** Resolves a block's storage so the compensating revision can be written. */
480
481
  readonly createBlockStorage: (blockId: BlockId) => IBlockStorage;
481
- /**
482
- * Runs `fn` while holding the same per-block commit latch `StorageRepo.commit` and
483
- * `saveReplicatedBlock` hold, so the compensating `saveReplica`/`saveDeletion` read-modify-write of
484
- * `meta.latest` is mutually exclusive with a concurrent local commit on that block. Without it, an
485
- * invalidation advancing `latest` outside that latch is invisible to commit's staleness guard, so a
486
- * commit that read a stale `latest` can clobber it back down — a lost-update / non-monotonic
487
- * regression. Optional: when omitted (unit tests / non-`StorageRepo` hosts) the write runs unlatched,
488
- * preserving today's behavior. The host supplies `StorageRepo`'s `withBlockCommitLatch`.
489
- */
490
- readonly withBlockCommitLatch?: <T>(blockId: BlockId, fn: () => Promise<T>) => Promise<T>;
491
482
  };
492
483
 
493
484
  export type ApplyInvalidationParams = {
@@ -499,8 +490,19 @@ export type ApplyInvalidationParams = {
499
490
  readonly proof: DisputeResolutionProof;
500
491
  /**
501
492
  * Consensus-assigned revision slot for the compensating revision (collection-global). When
502
- * omitted, computed as one past the highest current tip across the reverted blocks — the value a
503
- * local/single-node apply uses; the consensus path passes the agreed slot.
493
+ * omitted, computed as one past the highest current tip across the reverted blocks — read under
494
+ * the blocks' write latches, so an omitted slot is strictly past every tip and its compensating
495
+ * writes can never be refused by the monotonic guard.
496
+ *
497
+ * An explicit slot at or below any affected block's tip is refused wholesale (`applied: false`,
498
+ * reason `stale-revision`) rather than partially written.
499
+ *
500
+ * NOTE: nothing on the wire supplies this today. `InvalidateRequest`
501
+ * (`db-core/src/network/struct.ts`) carries `invalidatedActionId` / `invalidatedRev` / `blockIds` /
502
+ * `collectionId` / `resolution` and NO compensating-revision field, so the network-facing apply
503
+ * path leaves this undefined and every member computes its own slot locally. The `stale-revision`
504
+ * path is therefore reachable only from tests and from a future caller that does pass an agreed
505
+ * slot; adding that wire field is out of scope here.
504
506
  */
505
507
  readonly rev?: number;
506
508
  /**
@@ -526,11 +528,40 @@ export type ApplyInvalidationParams = {
526
528
 
527
529
  export type ApplyInvalidationResult = {
528
530
  readonly applied: boolean;
529
- readonly reason?: 'already-applied' | 'invalid-certificate';
531
+ /**
532
+ * - `already-applied` — a re-receipt; the log already holds this `(invalidatedActionId, disputeId)`.
533
+ * `reverted` carries the EXISTING entry's blocks, so a caller can reuse them.
534
+ * - `invalid-certificate` — the proof is not a valid challenger-wins certificate for the target.
535
+ * - `stale-revision` — at least one affected block already sits at or past the compensating
536
+ * revision slot, so the compensating write would be refused by the monotonic guard. Nothing was
537
+ * written and nothing was appended (all-or-nothing); the invalidation is safely re-deliverable
538
+ * once the caller supplies a slot past every affected block's tip. Reachable only when the
539
+ * caller supplies an explicit {@link ApplyInvalidationParams.rev} — a slot this function
540
+ * computes itself is strictly past every tip by construction.
541
+ */
542
+ readonly reason?: 'already-applied' | 'invalid-certificate' | 'stale-revision';
530
543
  readonly rev?: number;
531
544
  readonly reverted: ReadonlyArray<RevertedBlock>;
532
545
  };
533
546
 
547
+ /**
548
+ * Boundary invariant for the compensating write: the effective `ActionRev` a forward write returns
549
+ * must be the one we intended. The precheck above already establishes that, so a mismatch means a
550
+ * write path refused for a reason the precheck does not model — an internal contradiction (a write
551
+ * refused while its latch was held and its tip was checked), not a condition to degrade around. It
552
+ * throws rather than returning `applied: false`: `ClusterRepo.applyConsensusInvalidation` tolerates a
553
+ * sink throw, logs it, and rolls back its dedup marker so a re-broadcast retries.
554
+ */
555
+ function assertWriteLanded(blockId: BlockId, intended: ActionRev, effective: ActionRev): void {
556
+ if (effective.rev !== intended.rev || effective.actionId !== intended.actionId) {
557
+ throw new Error(
558
+ `Invalidation compensating write did not land for block ${blockId}: intended ` +
559
+ `{ rev: ${intended.rev}, actionId: ${intended.actionId} }, effective ` +
560
+ `{ rev: ${effective.rev}, actionId: ${effective.actionId} }`
561
+ );
562
+ }
563
+ }
564
+
534
565
  /**
535
566
  * Deterministically applies a single-collection invalidation: the durable reversal primitive every
536
567
  * cluster member runs identically (it carries the `reverted` targets and proof, exactly as the
@@ -540,15 +571,29 @@ export type ApplyInvalidationResult = {
540
571
  * 1. **Dedup** — if the log already holds an invalidation for `(invalidatedActionId, disputeId)`,
541
572
  * this is a re-receipt (rebroadcast / sync / retry): no-op, append nothing.
542
573
  * 2. **Certificate** — reject (append nothing) unless `proof` is a valid challenger-wins certificate.
543
- * 3. **Reverted revisions** — for each block, recompute the as-if-`T_inv`-absent content and write a
544
- * new monotonic revision (a forward compensating transform; prior revisions are retained).
574
+ * 3. **Reverted revisions** — holding EVERY affected block's write latch, recompute each block's
575
+ * as-if-`T_inv`-absent content and write a new monotonic revision (a forward compensating
576
+ * transform; prior revisions are retained). All-or-nothing: if any block already sits at or past
577
+ * the compensating slot, nothing is written and nothing is appended.
545
578
  * 4. **Log entry** — append the {@link InvalidationEntry} carrying the proof and `reverted` targets,
546
579
  * making `committed-invalidated` durable and recoverable on sync.
580
+ *
581
+ * The `reverted` entries an applied result carries therefore describe writes that ACTUALLY landed,
582
+ * at the revision and with the content hash they name — the property `cascade.ts` relies on when it
583
+ * decides a read-dependent's fate by comparing observed content against `restoredContentHash`.
547
584
  */
548
585
  export async function applyInvalidation(ctx: InvalidationContext, params: ApplyInvalidationParams): Promise<ApplyInvalidationResult> {
549
586
  const { invalidatedActionId, invalidatedRev, blockIds, proof } = params;
550
587
 
551
588
  // 1. Idempotent re-receipt — keyed on (invalidatedActionId, disputeId).
589
+ // NOTE: this read is outside the block latches taken in step 3, so two OVERLAPPING applies of the
590
+ // same invalidation would both pass here and both write a compensating revision (the second at a
591
+ // slot one past the first), appending two entries for one invalidation. Not reachable today: the
592
+ // network path serializes through consensus and dedups in memory first
593
+ // (`ClusterRepo.applyConsensusInvalidation`), and the cascade applies its children sequentially.
594
+ // If a caller ever applies the same invalidation concurrently, this dedup has to move inside the
595
+ // critical section — which means keying it on something the block latches actually cover, since
596
+ // `ctx.log` is not latched by them.
552
597
  const existing = await ctx.log.findInvalidation(invalidatedActionId);
553
598
  if (existing && existing.resolution.disputeId === proof.disputeId) {
554
599
  log('apply-skip-duplicate actionId=%s disputeId=%s', invalidatedActionId, proof.disputeId);
@@ -567,48 +612,113 @@ export async function applyInvalidation(ctx: InvalidationContext, params: ApplyI
567
612
  return { applied: false, reason: 'invalid-certificate', reverted: [] };
568
613
  }
569
614
 
570
- // 3. Compute compensating content + the collection-global revision slot.
571
- const computations = await Promise.all(
572
- blockIds.map(async (blockId) => {
573
- const storage = ctx.createBlockStorage(blockId);
574
- return { blockId, storage, computation: await computeRevertedBlock(storage, invalidatedRev) };
575
- })
576
- );
577
- const maxFromRev = computations.reduce((max, c) => Math.max(max, c.computation.fromRev), invalidatedRev);
578
- const rev = params.rev ?? maxFromRev + 1;
579
-
580
- const reverted: RevertedBlock[] = [];
581
- for (const { blockId, storage, computation } of computations) {
582
- // Deterministic compensating-revision actionId identical on every member, so all converge on
583
- // the same (rev, actionId) for both the restore and the tombstone path.
584
- const revertActionId = await hashString(`inv:${invalidatedActionId}:${proof.disputeId}:${blockId}:${rev}`);
585
- // Hold the per-block commit latch around ONLY the compensating write (matching saveReplicatedBlock's
586
- // scope): the monotonic guard inside saveReplica/saveDeletion then runs under the same latch a
587
- // concurrent commit holds, so the two RMW of meta.latest serialize and latest stays monotonic.
588
- // Acquire per block, one at a time — invalidation never holds two block latches, so it cannot
589
- // deadlock against commit's sorted multi-latch acquisition. When no runner is injected, the write
590
- // runs unlatched (today's behavior).
591
- const runLatched = <T>(fn: () => Promise<T>): Promise<T> =>
592
- ctx.withBlockCommitLatch ? ctx.withBlockCommitLatch(blockId, fn) : fn();
593
- if (computation.kind === 'delete') {
594
- // Block-creation reversal: physically remove the created block by writing a forward tombstone
595
- // revision. The `restoredContentHash` is the DELETED_BLOCK_RESTORE sentinel a deleted block
596
- // has no content hash, and the sentinel tells dependents "observed content is gone → invalidate".
597
- await runLatched(() => storage.saveDeletion({ rev, actionId: revertActionId }));
598
- log('apply-delete-restore blockId=%s invalidatedRev=%d rev=%d', blockId, invalidatedRev, rev);
599
- reverted.push({ blockId, fromRev: computation.fromRev, restoredContentHash: DELETED_BLOCK_RESTORE });
600
- continue;
615
+ // 3. Compensating revisions ONE critical section spanning every affected block.
616
+ //
617
+ // Latch discipline: hold ALL affected blocks' write latches across compute → revision slot →
618
+ // precheck writes. `acquireBlockWriteLatches` is the package's single multi-latch entry point
619
+ // (it dedups and acquires in the one global sorted order), so this cannot deadlock against the
620
+ // other multi-latch holder, `StorageRepo.commit`. (The previous code took each block's latch
621
+ // around only its own write, which left the tips it computed from — and the revision slot derived
622
+ // from them read outside any latch: a concurrent commit could land a newer revision in the gap,
623
+ // the monotonic guard in saveReplica/saveDeletion would correctly refuse the compensating write,
624
+ // and the log entry appended in step 4 would then assert a restore that never happened.)
625
+ //
626
+ // Collapsing duplicate ids is why `uniqueBlockIds` drives the writes and `reverted` too: one
627
+ // latched block yields one compensating write and one entry. The certificate target above
628
+ // deliberately still uses the caller's `blockIds` verbatim the arbitrators signed over that list.
629
+ const uniqueBlockIds = Array.from(new Set(blockIds));
630
+ const { latches, release } = await acquireBlockWriteLatches(uniqueBlockIds);
631
+ let reverted: RevertedBlock[];
632
+ let rev: number;
633
+ try {
634
+ // --- Start of critical section ---
635
+
636
+ // Compute compensating content from tips read UNDER the latches. `computeRevertedBlock` reads
637
+ // only through getLatest/listRevisions/getBlock/getTransaction none of which acquire a latch
638
+ // or touch the network (`BlockStorage.getBlock` throws `RevisionNotCoveredError` rather than
639
+ // restoring; the healing re-read lives one layer up in `StorageRepo.get`), so it is safe to
640
+ // call while holding them. Keep it that way: routing it through the healing path, which
641
+ // restores under the block latch, would self-deadlock here.
642
+ //
643
+ // NOTE: this compute now runs inside the critical section, so every commit and pend on these
644
+ // blocks queues behind it, and its cost grows with the number of revisions between T_inv and
645
+ // the tip (`listRevisions` + a transform apply each). Fine at invalidation's rate — a reversal
646
+ // is rare and its blocks are few. If invalidations ever become frequent, or reversals of very
647
+ // old transactions show up delaying commits, compute optimistically outside the latches and
648
+ // re-verify the tips inside (recomputing only the blocks that moved).
649
+ const computations = await Promise.all(
650
+ uniqueBlockIds.map(async (blockId) => {
651
+ const storage = ctx.createBlockStorage(blockId);
652
+ return {
653
+ blockId,
654
+ storage,
655
+ latch: latches.get(blockId)!,
656
+ latest: await storage.getLatest(),
657
+ computation: await computeRevertedBlock(storage, invalidatedRev),
658
+ };
659
+ })
660
+ );
661
+ const maxFromRev = computations.reduce((max, c) => Math.max(max, c.computation.fromRev), invalidatedRev);
662
+ rev = params.rev ?? maxFromRev + 1;
663
+
664
+ // All-or-nothing precheck, mirroring the monotonic guard in `BlockStorage.saveForwardRevision`
665
+ // (`meta.latest.rev >= rev` ⇒ refuse). With the tips read under these same latches, a computed
666
+ // slot is strictly greater than every tip by construction, so this can only trip when the CALLER
667
+ // supplied an explicit `rev` at or below a current tip. Writing nothing is the right outcome:
668
+ // skipping the block or retrying at a higher slot would make one member's durable entry differ
669
+ // from another's for the same invalidation, and step 1 dedups on (invalidatedActionId, disputeId)
670
+ // so an invalidation that appended nothing is simply re-deliverable.
671
+ const stale = computations.find(c => c.latest !== undefined && c.latest.rev >= rev);
672
+ if (stale) {
673
+ log('apply-reject-stale-revision actionId=%s disputeId=%s rev=%d blockId=%s held=%d',
674
+ invalidatedActionId, proof.disputeId, rev, stale.blockId, stale.latest!.rev);
675
+ return { applied: false, reason: 'stale-revision', reverted: [] };
601
676
  }
602
- if (computation.laterActions > 0) {
603
- // Surviving later actions were replayed verbatim; true read-dependents are out of scope here.
604
- log('apply-replayed-later-actions blockId=%s count=%d', blockId, computation.laterActions);
677
+
678
+ reverted = [];
679
+ for (const { blockId, storage, latch, computation } of computations) {
680
+ // Deterministic compensating-revision actionId — identical on every member, so all converge on
681
+ // the same (rev, actionId) for both the restore and the tombstone path.
682
+ const revertActionId = await hashString(`inv:${invalidatedActionId}:${proof.disputeId}:${blockId}:${rev}`);
683
+ const intended: ActionRev = { rev, actionId: revertActionId };
684
+ let effective: ActionRev;
685
+ if (computation.kind === 'delete') {
686
+ // Block-creation reversal: physically remove the created block by writing a forward tombstone
687
+ // revision. The `restoredContentHash` is the DELETED_BLOCK_RESTORE sentinel — a deleted block
688
+ // has no content hash, and the sentinel tells dependents "observed content is gone → invalidate".
689
+ effective = await storage.saveDeletion(intended, latch);
690
+ assertWriteLanded(blockId, intended, effective);
691
+ log('apply-delete-restore blockId=%s invalidatedRev=%d rev=%d', blockId, invalidatedRev, rev);
692
+ reverted.push({ blockId, fromRev: computation.fromRev, restoredContentHash: DELETED_BLOCK_RESTORE });
693
+ continue;
694
+ }
695
+ if (computation.laterActions > 0) {
696
+ // Surviving later actions were replayed verbatim; true read-dependents are out of scope here.
697
+ log('apply-replayed-later-actions blockId=%s count=%d', blockId, computation.laterActions);
698
+ }
699
+ effective = await storage.saveReplica(computation.block, intended, undefined, latch);
700
+ assertWriteLanded(blockId, intended, effective);
701
+ reverted.push({ blockId, fromRev: computation.fromRev, restoredContentHash: computation.restoredContentHash });
605
702
  }
606
- await runLatched(() => storage.saveReplica(computation.block, { rev, actionId: revertActionId }));
607
- reverted.push({ blockId, fromRev: computation.fromRev, restoredContentHash: computation.restoredContentHash });
703
+
704
+ // --- End of critical section ---
705
+ } finally {
706
+ // Every acquired latch is released even if a compute or a write threw.
707
+ release();
608
708
  }
609
709
 
610
710
  // 4. Durable, append-only invalidation entry (the source of truth for committed-invalidated).
711
+ // Appended OUTSIDE the block latches on purpose: `ctx.log` writes through a `BlockStore` that may
712
+ // itself be repo-backed, and `Latches` has no re-entrancy — appending under a block latch could
713
+ // self-deadlock.
611
714
  // `cascadeRoot` is set when this is a cascade step (a reverted read-dependent), undefined for a root.
715
+ //
716
+ // NOTE: the gap between the writes landing and this append is a crash window — a crash inside it
717
+ // leaves compensating revisions with no entry naming them. Deliberate, and the recoverable
718
+ // direction of the invariant this function establishes: an entry never over-claims, it can only
719
+ // under-claim, and re-delivery re-applies (step 1 dedups on the entry, which is not there). The
720
+ // asymmetry only stops being acceptable if a consumer starts treating "revision present, entry
721
+ // absent" as authoritative rather than as a state to re-sync.
612
722
  await ctx.log.addInvalidation(invalidatedActionId, invalidatedRev, proof, reverted, rev, params.cascadeRoot, params.timestamp);
613
723
  log('apply-complete actionId=%s disputeId=%s rev=%d blocks=%d', invalidatedActionId, proof.disputeId, rev, reverted.length);
614
724
 
@@ -1,125 +1,126 @@
1
- import { pipe } from 'it-pipe';
2
- import { decode as lpDecode, encode as lpEncode } from 'it-length-prefixed';
3
- import type { Startable, Logger, Stream, Connection, StreamHandler } from '@libp2p/interface';
4
- import type { Uint8ArrayList } from 'uint8arraylist';
5
- import { MAX_CONTROL_MESSAGE_BYTES } from '../protocol-limits.js';
6
- import type { DisputeMessage } from './types.js';
7
- import type { DisputeService } from './dispute-service.js';
8
-
9
- interface BaseComponents {
10
- logger: { forComponent: (name: string) => Logger };
11
- registrar: {
12
- handle: (protocol: string, handler: StreamHandler, options: any) => Promise<void>;
13
- unhandle: (protocol: string) => Promise<void>;
14
- };
15
- }
16
-
17
- export interface DisputeProtocolServiceComponents extends BaseComponents {
18
- disputeService: DisputeService;
19
- }
20
-
21
- export interface DisputeProtocolServiceInit {
22
- protocol?: string;
23
- protocolPrefix?: string;
24
- maxInboundStreams?: number;
25
- maxOutboundStreams?: number;
26
- }
27
-
28
- export function disputeProtocolService(init: DisputeProtocolServiceInit = {}): (components: DisputeProtocolServiceComponents) => DisputeProtocolService {
29
- return (components: DisputeProtocolServiceComponents) => new DisputeProtocolService(components, init);
30
- }
31
-
32
- /**
33
- * Libp2p service that handles dispute protocol messages.
34
- * Follows the same pattern as ClusterService.
35
- */
36
- export class DisputeProtocolService implements Startable {
37
- private readonly protocol: string;
38
- private readonly maxInboundStreams: number;
39
- private readonly maxOutboundStreams: number;
40
- private readonly log: Logger;
41
- private readonly disputeService: DisputeService;
42
- private readonly components: DisputeProtocolServiceComponents;
43
- private running: boolean;
44
-
45
- constructor(components: DisputeProtocolServiceComponents, init: DisputeProtocolServiceInit = {}) {
46
- this.components = components;
47
- this.protocol = init.protocol ?? (init.protocolPrefix ?? '/db-p2p') + '/dispute/1.0.0';
48
- this.maxInboundStreams = init.maxInboundStreams ?? 16;
49
- this.maxOutboundStreams = init.maxOutboundStreams ?? 32;
50
- this.log = components.logger.forComponent('db-p2p:dispute');
51
- this.disputeService = components.disputeService;
52
- this.running = false;
53
- }
54
-
55
- readonly [Symbol.toStringTag] = '@libp2p/dispute';
56
-
57
- async start(): Promise<void> {
58
- if (this.running) return;
59
-
60
- await this.components.registrar.handle(this.protocol, this.handleIncomingStream.bind(this), {
61
- maxInboundStreams: this.maxInboundStreams,
62
- maxOutboundStreams: this.maxOutboundStreams,
63
- });
64
-
65
- this.running = true;
66
- }
67
-
68
- async stop(): Promise<void> {
69
- if (!this.running) return;
70
- await this.components.registrar.unhandle(this.protocol);
71
- this.running = false;
72
- }
73
-
74
- private handleIncomingStream(stream: Stream, connection: Connection): void {
75
- const peerId = connection.remotePeer;
76
-
77
- const processStream = async function* (this: DisputeProtocolService, source: AsyncIterable<Uint8ArrayList>) {
78
- for await (const msg of source) {
79
- const decoded = new TextDecoder().decode(msg.subarray());
80
- const message = JSON.parse(decoded) as DisputeMessage;
81
-
82
- let response: any;
83
- switch (message.type) {
84
- case 'challenge': {
85
- const vote = await this.disputeService.handleChallenge(message.challenge);
86
- response = { type: 'vote', vote };
87
- break;
88
- }
89
- case 'resolution': {
90
- this.disputeService.handleResolution(message.resolution);
91
- response = { type: 'ack' };
92
- break;
93
- }
94
- default:
95
- throw new Error(`Unknown dispute message type: ${(message as any).type}`);
96
- }
97
-
98
- yield new TextEncoder().encode(JSON.stringify(response));
99
- // One request per stream: every real DisputeClient sends exactly one
100
- // request per dial (see ProtocolClient.processMessage), so complete the
101
- // generator after the first response. A second frame a peer queued is then
102
- // never read or parsed. Mirrors sync/block-transfer.
103
- return;
104
- }
105
- };
106
-
107
- void (async () => {
108
- try {
109
- const responses = pipe(
110
- stream,
111
- (source) => lpDecode(source, { maxDataLength: MAX_CONTROL_MESSAGE_BYTES }),
112
- processStream.bind(this),
113
- (source) => lpEncode(source)
114
- );
115
- for await (const chunk of responses) {
116
- stream.send(chunk);
117
- }
118
- await stream.close();
119
- } catch (err) {
120
- this.log.error('error handling dispute protocol message from %p - %e', peerId, err);
121
- stream.abort(err instanceof Error ? err : new Error(String(err)));
122
- }
123
- })();
124
- }
125
- }
1
+ import { pipe } from 'it-pipe';
2
+ import { decode as lpDecode, encode as lpEncode } from 'it-length-prefixed';
3
+ import type { Startable, Logger, Stream, Connection, StreamHandler } from '@libp2p/interface';
4
+ import type { Uint8ArrayList } from 'uint8arraylist';
5
+ import { MAX_CONTROL_MESSAGE_BYTES } from '../protocol-limits.js';
6
+ import type { DisputeMessage } from './types.js';
7
+ import type { DisputeService } from './dispute-service.js';
8
+ import { registerProtocolHandler } from '../network/register-protocol-handler.js';
9
+
10
+ interface BaseComponents {
11
+ logger: { forComponent: (name: string) => Logger };
12
+ registrar: {
13
+ handle: (protocol: string, handler: StreamHandler, options: any) => Promise<void>;
14
+ unhandle: (protocol: string) => Promise<void>;
15
+ };
16
+ }
17
+
18
+ export interface DisputeProtocolServiceComponents extends BaseComponents {
19
+ disputeService: DisputeService;
20
+ }
21
+
22
+ export interface DisputeProtocolServiceInit {
23
+ protocol?: string;
24
+ protocolPrefix?: string;
25
+ maxInboundStreams?: number;
26
+ maxOutboundStreams?: number;
27
+ }
28
+
29
+ export function disputeProtocolService(init: DisputeProtocolServiceInit = {}): (components: DisputeProtocolServiceComponents) => DisputeProtocolService {
30
+ return (components: DisputeProtocolServiceComponents) => new DisputeProtocolService(components, init);
31
+ }
32
+
33
+ /**
34
+ * Libp2p service that handles dispute protocol messages.
35
+ * Follows the same pattern as ClusterService.
36
+ */
37
+ export class DisputeProtocolService implements Startable {
38
+ private readonly protocol: string;
39
+ private readonly maxInboundStreams: number;
40
+ private readonly maxOutboundStreams: number;
41
+ private readonly log: Logger;
42
+ private readonly disputeService: DisputeService;
43
+ private readonly components: DisputeProtocolServiceComponents;
44
+ private running: boolean;
45
+
46
+ constructor(components: DisputeProtocolServiceComponents, init: DisputeProtocolServiceInit = {}) {
47
+ this.components = components;
48
+ this.protocol = init.protocol ?? (init.protocolPrefix ?? '/db-p2p') + '/dispute/1.0.0';
49
+ this.maxInboundStreams = init.maxInboundStreams ?? 16;
50
+ this.maxOutboundStreams = init.maxOutboundStreams ?? 32;
51
+ this.log = components.logger.forComponent('db-p2p:dispute');
52
+ this.disputeService = components.disputeService;
53
+ this.running = false;
54
+ }
55
+
56
+ readonly [Symbol.toStringTag] = '@libp2p/dispute';
57
+
58
+ async start(): Promise<void> {
59
+ if (this.running) return;
60
+
61
+ await registerProtocolHandler(this.components.registrar, this.protocol, this.handleIncomingStream.bind(this), {
62
+ maxInboundStreams: this.maxInboundStreams,
63
+ maxOutboundStreams: this.maxOutboundStreams,
64
+ });
65
+
66
+ this.running = true;
67
+ }
68
+
69
+ async stop(): Promise<void> {
70
+ if (!this.running) return;
71
+ await this.components.registrar.unhandle(this.protocol);
72
+ this.running = false;
73
+ }
74
+
75
+ private handleIncomingStream(stream: Stream, connection: Connection): void {
76
+ const peerId = connection.remotePeer;
77
+
78
+ const processStream = async function* (this: DisputeProtocolService, source: AsyncIterable<Uint8ArrayList>) {
79
+ for await (const msg of source) {
80
+ const decoded = new TextDecoder().decode(msg.subarray());
81
+ const message = JSON.parse(decoded) as DisputeMessage;
82
+
83
+ let response: any;
84
+ switch (message.type) {
85
+ case 'challenge': {
86
+ const vote = await this.disputeService.handleChallenge(message.challenge);
87
+ response = { type: 'vote', vote };
88
+ break;
89
+ }
90
+ case 'resolution': {
91
+ this.disputeService.handleResolution(message.resolution);
92
+ response = { type: 'ack' };
93
+ break;
94
+ }
95
+ default:
96
+ throw new Error(`Unknown dispute message type: ${(message as any).type}`);
97
+ }
98
+
99
+ yield new TextEncoder().encode(JSON.stringify(response));
100
+ // One request per stream: every real DisputeClient sends exactly one
101
+ // request per dial (see ProtocolClient.processMessage), so complete the
102
+ // generator after the first response. A second frame a peer queued is then
103
+ // never read or parsed. Mirrors sync/block-transfer.
104
+ return;
105
+ }
106
+ };
107
+
108
+ void (async () => {
109
+ try {
110
+ const responses = pipe(
111
+ stream,
112
+ (source) => lpDecode(source, { maxDataLength: MAX_CONTROL_MESSAGE_BYTES }),
113
+ processStream.bind(this),
114
+ (source) => lpEncode(source)
115
+ );
116
+ for await (const chunk of responses) {
117
+ stream.send(chunk);
118
+ }
119
+ await stream.close();
120
+ } catch (err) {
121
+ this.log.error('error handling dispute protocol message from %p - %e', peerId, err);
122
+ stream.abort(err instanceof Error ? err : new Error(String(err)));
123
+ }
124
+ })();
125
+ }
126
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  export * from "./cluster/client.js";
2
+ export * from "./cluster/client-signature-verifier.js";
2
3
  export * from "./cluster/cluster-repo.js";
3
4
  export * from "./cluster/cluster-policy.js";
4
5
  export * from "./cluster/commit-cert.js";
6
+ export * from "./cluster/commit-proof.js";
5
7
  export * from "./cluster/service.js";
6
8
  export * from "./cluster/rebalance-monitor.js";
7
9
  export * from "./cluster/spread-on-churn.js";
@@ -12,18 +14,23 @@ export * from "./protocol-client.js";
12
14
  export * from "./repo/client.js";
13
15
  export * from "./repo/cluster-coordinator.js";
14
16
  export * from "./repo/coordinator-repo.js";
17
+ export * from "./repo/served-repo-proxy.js";
15
18
  export * from "./repo/service.js";
16
19
  export * from "./storage/block-storage.js";
20
+ export * from "./storage/block-latch.js";
21
+ export * from "./storage/store-identity.js";
17
22
  export * from "./storage/raw-store-driver.js";
18
23
  export * from "./storage/kv-raw-storage.js";
19
24
  export * from "./storage/shared-cache-pool.js";
20
25
  export * from "./storage/cached-store-driver.js";
21
26
  export * from "./storage/cached-raw-storage.js";
27
+ export * from "./storage/with-read-cache.js";
22
28
  export * from "./storage/memory-store-driver.js";
23
29
  export * from "./storage/memory-storage.js";
24
30
  export * from "./storage/i-block-storage.js";
25
31
  export * from "./storage/i-raw-storage.js";
26
32
  export * from "./storage/struct.js";
33
+ export * from "./storage/block-archive.js";
27
34
  export * from "./storage/storage-repo.js";
28
35
  export * from "./storage/restoration-coordinator.js";
29
36
  export * from "./storage/ring-selector.js";