@optimystic/db-core 0.14.0 → 0.16.2

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 (301) hide show
  1. package/dist/src/blocks/block-store.d.ts +18 -1
  2. package/dist/src/blocks/block-store.d.ts.map +1 -1
  3. package/dist/src/blocks/helpers.d.ts +2 -2
  4. package/dist/src/blocks/helpers.d.ts.map +1 -1
  5. package/dist/src/blocks/helpers.js +2 -2
  6. package/dist/src/blocks/helpers.js.map +1 -1
  7. package/dist/src/btree/btree.d.ts +27 -6
  8. package/dist/src/btree/btree.d.ts.map +1 -1
  9. package/dist/src/btree/btree.js +104 -61
  10. package/dist/src/btree/btree.js.map +1 -1
  11. package/dist/src/chain/chain.d.ts.map +1 -1
  12. package/dist/src/chain/chain.js +11 -1
  13. package/dist/src/chain/chain.js.map +1 -1
  14. package/dist/src/cluster/index.d.ts +1 -0
  15. package/dist/src/cluster/index.d.ts.map +1 -1
  16. package/dist/src/cluster/index.js +1 -0
  17. package/dist/src/cluster/index.js.map +1 -1
  18. package/dist/src/cluster/membership.d.ts +49 -0
  19. package/dist/src/cluster/membership.d.ts.map +1 -0
  20. package/dist/src/cluster/membership.js +75 -0
  21. package/dist/src/cluster/membership.js.map +1 -0
  22. package/dist/src/cluster/structs.d.ts +50 -1
  23. package/dist/src/cluster/structs.d.ts.map +1 -1
  24. package/dist/src/cluster/structs.js +9 -1
  25. package/dist/src/cluster/structs.js.map +1 -1
  26. package/dist/src/cohort-topic/addressing.d.ts +8 -2
  27. package/dist/src/cohort-topic/addressing.d.ts.map +1 -1
  28. package/dist/src/cohort-topic/addressing.js +11 -3
  29. package/dist/src/cohort-topic/addressing.js.map +1 -1
  30. package/dist/src/cohort-topic/antidos/bootstrap-evidence.d.ts +13 -7
  31. package/dist/src/cohort-topic/antidos/bootstrap-evidence.d.ts.map +1 -1
  32. package/dist/src/cohort-topic/antidos/bootstrap-evidence.js +17 -6
  33. package/dist/src/cohort-topic/antidos/bootstrap-evidence.js.map +1 -1
  34. package/dist/src/cohort-topic/antidos/replay-guard.d.ts +17 -0
  35. package/dist/src/cohort-topic/antidos/replay-guard.d.ts.map +1 -1
  36. package/dist/src/cohort-topic/antidos/replay-guard.js +33 -0
  37. package/dist/src/cohort-topic/antidos/replay-guard.js.map +1 -1
  38. package/dist/src/cohort-topic/antidos/topic-budget.d.ts +7 -0
  39. package/dist/src/cohort-topic/antidos/topic-budget.d.ts.map +1 -1
  40. package/dist/src/cohort-topic/antidos/topic-budget.js +8 -2
  41. package/dist/src/cohort-topic/antidos/topic-budget.js.map +1 -1
  42. package/dist/src/cohort-topic/coldstart.d.ts +34 -14
  43. package/dist/src/cohort-topic/coldstart.d.ts.map +1 -1
  44. package/dist/src/cohort-topic/coldstart.js +25 -11
  45. package/dist/src/cohort-topic/coldstart.js.map +1 -1
  46. package/dist/src/cohort-topic/gossip/bus.d.ts +12 -1
  47. package/dist/src/cohort-topic/gossip/bus.d.ts.map +1 -1
  48. package/dist/src/cohort-topic/gossip/bus.js +27 -1
  49. package/dist/src/cohort-topic/gossip/bus.js.map +1 -1
  50. package/dist/src/cohort-topic/gossip/view.d.ts +8 -0
  51. package/dist/src/cohort-topic/gossip/view.d.ts.map +1 -1
  52. package/dist/src/cohort-topic/gossip/view.js +17 -1
  53. package/dist/src/cohort-topic/gossip/view.js.map +1 -1
  54. package/dist/src/cohort-topic/member-engine.d.ts +10 -3
  55. package/dist/src/cohort-topic/member-engine.d.ts.map +1 -1
  56. package/dist/src/cohort-topic/member-engine.js +29 -14
  57. package/dist/src/cohort-topic/member-engine.js.map +1 -1
  58. package/dist/src/cohort-topic/membership/publisher.d.ts +14 -7
  59. package/dist/src/cohort-topic/membership/publisher.d.ts.map +1 -1
  60. package/dist/src/cohort-topic/membership/publisher.js +15 -27
  61. package/dist/src/cohort-topic/membership/publisher.js.map +1 -1
  62. package/dist/src/cohort-topic/membership/verifier.d.ts +35 -0
  63. package/dist/src/cohort-topic/membership/verifier.d.ts.map +1 -1
  64. package/dist/src/cohort-topic/membership/verifier.js +88 -3
  65. package/dist/src/cohort-topic/membership/verifier.js.map +1 -1
  66. package/dist/src/cohort-topic/promotion.d.ts +7 -0
  67. package/dist/src/cohort-topic/promotion.d.ts.map +1 -1
  68. package/dist/src/cohort-topic/promotion.js +16 -2
  69. package/dist/src/cohort-topic/promotion.js.map +1 -1
  70. package/dist/src/cohort-topic/registration/renewal.d.ts +18 -0
  71. package/dist/src/cohort-topic/registration/renewal.d.ts.map +1 -1
  72. package/dist/src/cohort-topic/registration/renewal.js +54 -0
  73. package/dist/src/cohort-topic/registration/renewal.js.map +1 -1
  74. package/dist/src/cohort-topic/registration/types.d.ts +12 -0
  75. package/dist/src/cohort-topic/registration/types.d.ts.map +1 -1
  76. package/dist/src/cohort-topic/registration/types.js +14 -0
  77. package/dist/src/cohort-topic/registration/types.js.map +1 -1
  78. package/dist/src/cohort-topic/service.d.ts +4 -3
  79. package/dist/src/cohort-topic/service.d.ts.map +1 -1
  80. package/dist/src/cohort-topic/service.js +38 -12
  81. package/dist/src/cohort-topic/service.js.map +1 -1
  82. package/dist/src/cohort-topic/sig/payloads.d.ts +23 -7
  83. package/dist/src/cohort-topic/sig/payloads.d.ts.map +1 -1
  84. package/dist/src/cohort-topic/sig/payloads.js +22 -6
  85. package/dist/src/cohort-topic/sig/payloads.js.map +1 -1
  86. package/dist/src/cohort-topic/traffic.d.ts +6 -0
  87. package/dist/src/cohort-topic/traffic.d.ts.map +1 -1
  88. package/dist/src/cohort-topic/traffic.js +16 -1
  89. package/dist/src/cohort-topic/traffic.js.map +1 -1
  90. package/dist/src/cohort-topic/walk.d.ts +22 -7
  91. package/dist/src/cohort-topic/walk.d.ts.map +1 -1
  92. package/dist/src/cohort-topic/walk.js +78 -18
  93. package/dist/src/cohort-topic/walk.js.map +1 -1
  94. package/dist/src/cohort-topic/wire/codec.d.ts +3 -1
  95. package/dist/src/cohort-topic/wire/codec.d.ts.map +1 -1
  96. package/dist/src/cohort-topic/wire/codec.js +7 -1
  97. package/dist/src/cohort-topic/wire/codec.js.map +1 -1
  98. package/dist/src/cohort-topic/wire/index.d.ts +1 -1
  99. package/dist/src/cohort-topic/wire/index.d.ts.map +1 -1
  100. package/dist/src/cohort-topic/wire/index.js +1 -1
  101. package/dist/src/cohort-topic/wire/index.js.map +1 -1
  102. package/dist/src/cohort-topic/wire/payloads.d.ts +5 -4
  103. package/dist/src/cohort-topic/wire/payloads.d.ts.map +1 -1
  104. package/dist/src/cohort-topic/wire/payloads.js +14 -5
  105. package/dist/src/cohort-topic/wire/payloads.js.map +1 -1
  106. package/dist/src/cohort-topic/wire/primitives.d.ts +65 -0
  107. package/dist/src/cohort-topic/wire/primitives.d.ts.map +1 -0
  108. package/dist/src/cohort-topic/wire/primitives.js +173 -0
  109. package/dist/src/cohort-topic/wire/primitives.js.map +1 -0
  110. package/dist/src/cohort-topic/wire/types.d.ts +132 -5
  111. package/dist/src/cohort-topic/wire/types.d.ts.map +1 -1
  112. package/dist/src/cohort-topic/wire/validate.d.ts +15 -5
  113. package/dist/src/cohort-topic/wire/validate.d.ts.map +1 -1
  114. package/dist/src/cohort-topic/wire/validate.js +146 -123
  115. package/dist/src/cohort-topic/wire/validate.js.map +1 -1
  116. package/dist/src/collection/collection.d.ts +23 -12
  117. package/dist/src/collection/collection.d.ts.map +1 -1
  118. package/dist/src/collection/collection.js +111 -35
  119. package/dist/src/collection/collection.js.map +1 -1
  120. package/dist/src/collection/struct.d.ts +37 -2
  121. package/dist/src/collection/struct.d.ts.map +1 -1
  122. package/dist/src/collection/struct.js +16 -1
  123. package/dist/src/collection/struct.js.map +1 -1
  124. package/dist/src/collections/tree/tree.d.ts +18 -1
  125. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  126. package/dist/src/collections/tree/tree.js +36 -6
  127. package/dist/src/collections/tree/tree.js.map +1 -1
  128. package/dist/src/index.d.ts +1 -0
  129. package/dist/src/index.d.ts.map +1 -1
  130. package/dist/src/index.js +1 -0
  131. package/dist/src/index.js.map +1 -1
  132. package/dist/src/log/log.d.ts +26 -3
  133. package/dist/src/log/log.d.ts.map +1 -1
  134. package/dist/src/log/log.js +34 -7
  135. package/dist/src/log/log.js.map +1 -1
  136. package/dist/src/log/struct.d.ts +5 -4
  137. package/dist/src/log/struct.d.ts.map +1 -1
  138. package/dist/src/log/struct.js.map +1 -1
  139. package/dist/src/matchmaking/wire.d.ts +2 -1
  140. package/dist/src/matchmaking/wire.d.ts.map +1 -1
  141. package/dist/src/matchmaking/wire.js +13 -92
  142. package/dist/src/matchmaking/wire.js.map +1 -1
  143. package/dist/src/network/i-key-network.d.ts +9 -0
  144. package/dist/src/network/i-key-network.d.ts.map +1 -1
  145. package/dist/src/network/i-repo.d.ts +8 -0
  146. package/dist/src/network/i-repo.d.ts.map +1 -1
  147. package/dist/src/network/struct.d.ts +10 -0
  148. package/dist/src/network/struct.d.ts.map +1 -1
  149. package/dist/src/reactivity/push-state.d.ts.map +1 -1
  150. package/dist/src/reactivity/push-state.js +4 -33
  151. package/dist/src/reactivity/push-state.js.map +1 -1
  152. package/dist/src/reactivity/wire-validate.d.ts +7 -22
  153. package/dist/src/reactivity/wire-validate.d.ts.map +1 -1
  154. package/dist/src/reactivity/wire-validate.js +7 -73
  155. package/dist/src/reactivity/wire-validate.js.map +1 -1
  156. package/dist/src/reactivity/wire.js +3 -3
  157. package/dist/src/reactivity/wire.js.map +1 -1
  158. package/dist/src/testing/async-wait.d.ts +23 -0
  159. package/dist/src/testing/async-wait.d.ts.map +1 -0
  160. package/dist/src/testing/async-wait.js +42 -0
  161. package/dist/src/testing/async-wait.js.map +1 -0
  162. package/dist/src/testing/index.d.ts +3 -0
  163. package/dist/src/testing/index.d.ts.map +1 -0
  164. package/dist/src/testing/index.js +3 -0
  165. package/dist/src/testing/index.js.map +1 -0
  166. package/dist/src/testing/test-transactor.d.ts +44 -0
  167. package/dist/src/testing/test-transactor.d.ts.map +1 -0
  168. package/dist/src/testing/test-transactor.js +423 -0
  169. package/dist/src/testing/test-transactor.js.map +1 -0
  170. package/dist/src/transaction/actions-engine.d.ts +6 -3
  171. package/dist/src/transaction/actions-engine.d.ts.map +1 -1
  172. package/dist/src/transaction/actions-engine.js +8 -7
  173. package/dist/src/transaction/actions-engine.js.map +1 -1
  174. package/dist/src/transaction/coordinator.d.ts +43 -22
  175. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  176. package/dist/src/transaction/coordinator.js +443 -178
  177. package/dist/src/transaction/coordinator.js.map +1 -1
  178. package/dist/src/transaction/errors.d.ts +82 -0
  179. package/dist/src/transaction/errors.d.ts.map +1 -0
  180. package/dist/src/transaction/errors.js +93 -0
  181. package/dist/src/transaction/errors.js.map +1 -0
  182. package/dist/src/transaction/index.d.ts +6 -3
  183. package/dist/src/transaction/index.d.ts.map +1 -1
  184. package/dist/src/transaction/index.js +4 -2
  185. package/dist/src/transaction/index.js.map +1 -1
  186. package/dist/src/transaction/operations-hash.d.ts +119 -0
  187. package/dist/src/transaction/operations-hash.d.ts.map +1 -0
  188. package/dist/src/transaction/operations-hash.js +180 -0
  189. package/dist/src/transaction/operations-hash.js.map +1 -0
  190. package/dist/src/transaction/read-dependency-collector.d.ts +43 -0
  191. package/dist/src/transaction/read-dependency-collector.d.ts.map +1 -0
  192. package/dist/src/transaction/read-dependency-collector.js +69 -0
  193. package/dist/src/transaction/read-dependency-collector.js.map +1 -0
  194. package/dist/src/transaction/session.d.ts +12 -3
  195. package/dist/src/transaction/session.d.ts.map +1 -1
  196. package/dist/src/transaction/session.js +35 -9
  197. package/dist/src/transaction/session.js.map +1 -1
  198. package/dist/src/transaction/transaction.d.ts +162 -1
  199. package/dist/src/transaction/transaction.d.ts.map +1 -1
  200. package/dist/src/transaction/transaction.js +85 -3
  201. package/dist/src/transaction/transaction.js.map +1 -1
  202. package/dist/src/transaction/validator.d.ts +3 -11
  203. package/dist/src/transaction/validator.d.ts.map +1 -1
  204. package/dist/src/transaction/validator.js +69 -28
  205. package/dist/src/transaction/validator.js.map +1 -1
  206. package/dist/src/transactor/network-transactor.d.ts +35 -0
  207. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  208. package/dist/src/transactor/network-transactor.js +146 -40
  209. package/dist/src/transactor/network-transactor.js.map +1 -1
  210. package/dist/src/transactor/transactor-source.d.ts +19 -5
  211. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  212. package/dist/src/transactor/transactor-source.js +43 -11
  213. package/dist/src/transactor/transactor-source.js.map +1 -1
  214. package/dist/src/transform/atomic-proxy.d.ts +35 -5
  215. package/dist/src/transform/atomic-proxy.d.ts.map +1 -1
  216. package/dist/src/transform/atomic-proxy.js +39 -7
  217. package/dist/src/transform/atomic-proxy.js.map +1 -1
  218. package/dist/src/transform/cache-source.d.ts +36 -5
  219. package/dist/src/transform/cache-source.d.ts.map +1 -1
  220. package/dist/src/transform/cache-source.js +84 -8
  221. package/dist/src/transform/cache-source.js.map +1 -1
  222. package/dist/src/transform/helpers.d.ts.map +1 -1
  223. package/dist/src/transform/helpers.js +8 -2
  224. package/dist/src/transform/helpers.js.map +1 -1
  225. package/dist/src/transform/tracker.d.ts +17 -2
  226. package/dist/src/transform/tracker.d.ts.map +1 -1
  227. package/dist/src/transform/tracker.js +66 -11
  228. package/dist/src/transform/tracker.js.map +1 -1
  229. package/dist/src/utility/backoff.d.ts +48 -0
  230. package/dist/src/utility/backoff.d.ts.map +1 -0
  231. package/dist/src/utility/backoff.js +60 -0
  232. package/dist/src/utility/backoff.js.map +1 -0
  233. package/dist/src/utility/pending.d.ts +1 -0
  234. package/dist/src/utility/pending.d.ts.map +1 -1
  235. package/dist/src/utility/pending.js +3 -1
  236. package/dist/src/utility/pending.js.map +1 -1
  237. package/package.json +4 -4
  238. package/src/blocks/block-store.ts +19 -1
  239. package/src/blocks/helpers.ts +3 -3
  240. package/src/btree/btree.ts +108 -63
  241. package/src/chain/chain.ts +11 -1
  242. package/src/cluster/index.ts +1 -0
  243. package/src/cluster/membership.ts +85 -0
  244. package/src/cluster/structs.ts +51 -1
  245. package/src/cohort-topic/addressing.ts +12 -4
  246. package/src/cohort-topic/antidos/bootstrap-evidence.ts +21 -9
  247. package/src/cohort-topic/antidos/replay-guard.ts +37 -0
  248. package/src/cohort-topic/antidos/topic-budget.ts +17 -2
  249. package/src/cohort-topic/coldstart.ts +48 -15
  250. package/src/cohort-topic/gossip/bus.ts +39 -2
  251. package/src/cohort-topic/gossip/view.ts +26 -1
  252. package/src/cohort-topic/member-engine.ts +36 -15
  253. package/src/cohort-topic/membership/publisher.ts +25 -33
  254. package/src/cohort-topic/membership/verifier.ts +119 -3
  255. package/src/cohort-topic/promotion.ts +23 -2
  256. package/src/cohort-topic/registration/renewal.ts +73 -1
  257. package/src/cohort-topic/registration/types.ts +15 -0
  258. package/src/cohort-topic/service.ts +41 -15
  259. package/src/cohort-topic/sig/payloads.ts +29 -9
  260. package/src/cohort-topic/traffic.ts +23 -1
  261. package/src/cohort-topic/walk.ts +95 -24
  262. package/src/cohort-topic/wire/codec.ts +12 -0
  263. package/src/cohort-topic/wire/index.ts +2 -0
  264. package/src/cohort-topic/wire/payloads.ts +14 -5
  265. package/src/cohort-topic/wire/primitives.ts +188 -0
  266. package/src/cohort-topic/wire/types.ts +136 -4
  267. package/src/cohort-topic/wire/validate.ts +168 -134
  268. package/src/collection/collection.ts +123 -40
  269. package/src/collection/struct.ts +44 -2
  270. package/src/collections/tree/tree.ts +42 -5
  271. package/src/index.ts +1 -0
  272. package/src/log/log.ts +37 -9
  273. package/src/log/struct.ts +5 -4
  274. package/src/matchmaking/wire.ts +25 -102
  275. package/src/network/i-key-network.ts +10 -0
  276. package/src/network/i-repo.ts +8 -0
  277. package/src/network/struct.ts +10 -0
  278. package/src/reactivity/push-state.ts +4 -37
  279. package/src/reactivity/wire-validate.ts +7 -81
  280. package/src/reactivity/wire.ts +3 -3
  281. package/src/testing/async-wait.ts +65 -0
  282. package/src/testing/index.ts +2 -0
  283. package/src/testing/test-transactor.ts +468 -0
  284. package/src/transaction/actions-engine.ts +8 -6
  285. package/src/transaction/coordinator.ts +498 -229
  286. package/src/transaction/errors.ts +91 -0
  287. package/src/transaction/index.ts +23 -3
  288. package/src/transaction/operations-hash.ts +196 -0
  289. package/src/transaction/read-dependency-collector.ts +78 -0
  290. package/src/transaction/session.ts +37 -9
  291. package/src/transaction/transaction.ts +211 -4
  292. package/src/transaction/validator.ts +73 -48
  293. package/src/transactor/network-transactor.ts +164 -49
  294. package/src/transactor/transactor-source.ts +47 -12
  295. package/src/transform/atomic-proxy.ts +92 -49
  296. package/src/transform/cache-source.ts +87 -9
  297. package/src/transform/helpers.ts +8 -2
  298. package/src/transform/tracker.ts +69 -11
  299. package/src/utility/backoff.ts +95 -0
  300. package/src/utility/pending.ts +3 -1
  301. package/src/transaction/context.ts +0 -103
@@ -1,22 +1,38 @@
1
- import type { ITransactor, BlockId, CollectionId, Transforms, PendRequest, CommitRequest, ActionId, IBlock, BlockOperations } from "../index.js";
1
+ import type { ITransactor, BlockId, CollectionId, Transforms, PendRequest, CommitRequest, ActionId } from "../index.js";
2
2
  import type { Transaction, ExecutionResult, ITransactionEngine, CollectionActions, ReadDependency } from "./transaction.js";
3
3
  import type { PeerId } from "../network/types.js";
4
4
  import type { Collection } from "../collection/collection.js";
5
- import { TransactionContext } from "./context.js";
6
- import { ActionsEngine } from "./actions-engine.js";
7
- import { createActionsStatements, createTransactionStamp, createTransactionId, isTransactionExpired } from "./transaction.js";
8
- import { Log, blockIdsForTransforms, hashString } from "../index.js";
5
+ import type { SyncOptions } from "../collection/index.js";
6
+ import { isTransactionExpired, clampPriority } from "./transaction.js";
7
+ import { Log, blockIdsForTransforms } from "../index.js";
8
+ import { collectOperations, hashOperations } from "./operations-hash.js";
9
+ import { CoordinatorPartialCommitError, CoordinatorStaleLossError } from "./errors.js";
10
+ import { jitteredBackoffMs, abortableDelay, makeAbortError } from "../utility/backoff.js";
9
11
  import { createLogger } from "../logger.js";
10
12
 
11
13
  const log = createLogger('trx:coordinator');
12
14
 
15
+ /** Default max consecutive clean-stale-loss retries before {@link TransactionCoordinator.commit}
16
+ * gives up. Mirrors the single-collection sync default so the two retry loops share one policy. */
17
+ const DefaultMaxAttempts = 10;
18
+ /** Default base backoff (ms) before the first commit retry. */
19
+ const DefaultBaseBackoffMs = 100;
20
+ /** Default ceiling (ms) on a single commit-retry backoff sleep. */
21
+ const DefaultMaxBackoffMs = 5000;
22
+
13
23
  /**
14
- * Represents an operation on a block within a collection.
24
+ * A pend that failed. `conflict` marks the retryable class — an optimistic-concurrency collision (a
25
+ * committed `missing` action newer than our rev, or a `pending` action on a touched block) that a
26
+ * re-read + re-pend can clear. A bare rejection reason (storage full, policy) is NOT a conflict and
27
+ * is not worth re-driving. Thrown by {@link TransactionCoordinator.pendCollection} so the fan-out in
28
+ * pendPhase can settle it and read the flag off the rejection.
15
29
  */
16
- type Operation =
17
- | { readonly type: 'insert'; readonly collectionId: CollectionId; readonly blockId: BlockId; readonly block: IBlock }
18
- | { readonly type: 'update'; readonly collectionId: CollectionId; readonly blockId: BlockId; readonly operations: BlockOperations }
19
- | { readonly type: 'delete'; readonly collectionId: CollectionId; readonly blockId: BlockId };
30
+ class PendRejectedError extends Error {
31
+ constructor(collectionId: CollectionId, readonly conflict: boolean, reason?: string) {
32
+ super(`Pend failed for collection ${collectionId}: ${reason ?? (conflict ? 'stale conflict' : 'rejected')}`);
33
+ this.name = 'PendRejectedError';
34
+ }
35
+ }
20
36
 
21
37
  /**
22
38
  * Coordinates multi-collection transactions.
@@ -94,19 +110,105 @@ export class TransactionCoordinator {
94
110
  }
95
111
 
96
112
  /**
97
- * Commit a transaction: materialise a log entry from each collection's staged
113
+ * Commit a transaction with a bounded, jittered backoff retry around a CLEAN stale loss.
114
+ *
115
+ * The single-attempt work lives in {@link commitOnce}; this wrapper re-drives it when the attempt
116
+ * fails as a clean optimistic-concurrency loss ({@link CoordinatorStaleLossError} — nothing
117
+ * durably committed, every tracker restored to its pre-append state). Before each re-attempt it
118
+ * re-reads each collection to fresh revisions (so the retry pends against current state rather
119
+ * than immediately re-failing stale), then backs off with the same jitter policy as
120
+ * {@link Collection.sync}. Retry is bounded by `maxAttempts` and an optional wall-clock
121
+ * `deadlineMs`, and honours an abort `signal`.
122
+ *
123
+ * A {@link CoordinatorPartialCommitError} (a partial landing — some collection durably committed)
124
+ * is NOT retryable and escapes immediately: blindly retrying would re-log already-durable actions.
125
+ * Any other failure (expired transaction, unavailable transactor, unreachable cluster) also
126
+ * propagates without retry — only genuine clean stale losses are re-driven.
127
+ *
128
+ * Defaults are safe out of the box: a caller that passes no options gets bounded, jittered retry.
129
+ *
130
+ * @param transaction - The transaction to commit
131
+ * @param options - Retry knobs; shares the {@link SyncOptions} vocabulary with `Collection.sync`.
132
+ */
133
+ async commit(transaction: Transaction, options?: SyncOptions): Promise<void> {
134
+ const maxAttempts = options?.maxAttempts ?? DefaultMaxAttempts;
135
+ const baseBackoffMs = options?.baseBackoffMs ?? DefaultBaseBackoffMs;
136
+ const maxBackoffMs = options?.maxBackoffMs ?? DefaultMaxBackoffMs;
137
+ const deadlineMs = options?.deadlineMs;
138
+ const signal = options?.signal;
139
+ const startedAt = Date.now();
140
+
141
+ // Count of consecutive clean stale losses. There is no forward-progress notion here (a
142
+ // single commit either lands or it does not), so this simply bounds how many times we
143
+ // re-drive a losing transaction before surfacing a terminal error.
144
+ let staleLosses = 0;
145
+ let lastLoss: CoordinatorStaleLossError | undefined;
146
+ for (;;) {
147
+ if (signal?.aborted) {
148
+ throw makeAbortError(signal);
149
+ }
150
+ // Progress-agnostic ceiling: once we've taken at least one loss, give up if the
151
+ // wall-clock deadline passed (independent of the attempt cap).
152
+ if (deadlineMs !== undefined && lastLoss && Date.now() - startedAt >= deadlineMs) {
153
+ throw lastLoss;
154
+ }
155
+
156
+ // Age the transaction's advisory priority by the number of losses taken so far, so a
157
+ // repeatedly-losing transaction out-ranks fresh (priority-0) rivals in the cluster's
158
+ // resolveRace. Fairness-only and capped at MaxPriority; excluded from the tx id / client
159
+ // signature, so bumping it here does not churn identity. Left untouched on the first
160
+ // attempt (staleLosses == 0) so the initial pend serializes exactly as before.
161
+ if (staleLosses > 0) {
162
+ transaction.priority = clampPriority(staleLosses);
163
+ }
164
+
165
+ try {
166
+ await this.commitOnce(transaction);
167
+ return;
168
+ } catch (err) {
169
+ // Only a CLEAN stale loss is retryable. A partial landing, an expired transaction, an
170
+ // unavailable transactor, etc. all propagate unchanged.
171
+ if (!(err instanceof CoordinatorStaleLossError)) {
172
+ throw err;
173
+ }
174
+ lastLoss = err;
175
+ staleLosses++;
176
+ if (staleLosses >= maxAttempts) {
177
+ throw err;
178
+ }
179
+ const delay = jitteredBackoffMs(staleLosses - 1, { baseMs: baseBackoffMs, capMs: maxBackoffMs }, options?.rand);
180
+ await abortableDelay(delay, signal);
181
+ // Re-read fresh state before re-attempting so the next commit pends against current
182
+ // revisions (mirrors how Collection.sync calls updateInternal() before retrying).
183
+ // NOTE: refreshes EVERY registered collection, not only the participants of this
184
+ // transaction. Harmless (a non-participant's update() just fetches latest) and the
185
+ // registered set is small today; if a coordinator ever holds many collections and this
186
+ // shows up as retry latency, narrow it to the transaction's participating collections.
187
+ for (const collection of this.collections.values()) {
188
+ await collection.update();
189
+ }
190
+ }
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Commit a transaction (single attempt): materialise a log entry from each collection's staged
98
196
  * pending actions, then orchestrate the distributed consensus (GATHER/PEND/COMMIT).
99
197
  *
100
- * Called by TransactionSession.commit() after all statements have executed. The
198
+ * Called by {@link commit} (which wraps it in the backoff+jitter retry loop). The
101
199
  * staged mutations already live in each collection's tracker — applied either via
102
200
  * applyActions() (engine-driven path) or directly via Collection.act()/Tree.stage
103
201
  * (the vtab's deferred-DML path) — but in BOTH cases without a log entry yet, so
104
202
  * this method appends that entry here (see the inline note below) before pending,
105
203
  * and folds the committed transforms back into each collection's read cache.
106
204
  *
205
+ * On a clean stale loss (nothing durable, every tracker restored) it throws
206
+ * {@link CoordinatorStaleLossError} so the caller can retry; on a partial landing it throws
207
+ * {@link CoordinatorPartialCommitError} (not retryable).
208
+ *
107
209
  * @param transaction - The transaction to commit
108
210
  */
109
- async commit(transaction: Transaction): Promise<void> {
211
+ private async commitOnce(transaction: Transaction): Promise<void> {
110
212
  if (isTransactionExpired(transaction.stamp)) {
111
213
  throw new Error(`Transaction expired at ${transaction.stamp.expiration}`);
112
214
  }
@@ -144,44 +246,117 @@ export class TransactionCoordinator {
144
246
  const collectionTransforms = new Map<CollectionId, Transforms>();
145
247
  const criticalBlocks = new Map<CollectionId, BlockId>();
146
248
 
249
+ // Snapshot EVERY participating collection's staged state (transforms + pending
250
+ // queue) BEFORE the append loop mutates any tracker. The loop appends log
251
+ // entries sequentially, so a failure on the Nth collection must also undo the
252
+ // 0..N-1 collections that already appended — and coordinateTransaction can fail
253
+ // after ALL of them appended. On any throw below we restore every snapshot, so a
254
+ // failed commit leaves each tracker exactly as it was: a retry re-appends cleanly
255
+ // (no duplicate log entry) and a directly-staged tree's rollback (which no-ops
256
+ // when the stamp was never tracked via applyActions) has nothing poisoned to undo.
257
+ const preCommitSnapshots = new Map<CollectionId, ReturnType<Collection<any>['snapshotPending']>>();
147
258
  for (const { collectionId, collection } of collectionData) {
148
- const applyResult = await this.applyActionsToCollection(
149
- { collectionId, actions: collection.getPendingActions() },
150
- transaction,
151
- allCollectionIds
152
- );
153
- if (!applyResult.success) {
154
- throw new Error(`Transaction commit failed: ${applyResult.error}`);
259
+ preCommitSnapshots.set(collectionId, collection.snapshotPending());
260
+ }
261
+
262
+ let coordResult: {
263
+ success: boolean;
264
+ error?: string;
265
+ committedCollections?: Set<CollectionId>;
266
+ failedCollections?: Set<CollectionId>;
267
+ staleLoss?: boolean;
268
+ };
269
+ try {
270
+ for (const { collectionId, collection } of collectionData) {
271
+ const applyResult = await this.applyActionsToCollection(
272
+ { collectionId, actions: collection.getPendingActions() },
273
+ transaction,
274
+ allCollectionIds
275
+ );
276
+ if (!applyResult.success) {
277
+ throw new Error(`Transaction commit failed: ${applyResult.error}`);
278
+ }
279
+ collectionTransforms.set(collectionId, applyResult.transforms!);
280
+ criticalBlocks.set(collectionId, applyResult.logTailBlockId!);
155
281
  }
156
- collectionTransforms.set(collectionId, applyResult.transforms!);
157
- criticalBlocks.set(collectionId, applyResult.logTailBlockId!);
158
- }
159
-
160
- // Compute hash of ALL operations across ALL collections (post-log-append).
161
- // Validators re-execute the transaction and compare their computed hash.
162
- const allOperations = Array.from(collectionTransforms.entries()).flatMap(([collectionId, transforms]) => [
163
- ...Object.entries(transforms.inserts ?? {}).map(([blockId, block]) =>
164
- ({ type: 'insert' as const, collectionId, blockId, block })
165
- ),
166
- ...Object.entries(transforms.updates ?? {}).map(([blockId, operations]) =>
167
- ({ type: 'update' as const, collectionId, blockId, operations })
168
- ),
169
- ...(transforms.deletes ?? []).map(blockId =>
170
- ({ type: 'delete' as const, collectionId, blockId })
171
- )
172
- ]);
173
282
 
174
- const operationsHash = await this.hashOperations(allOperations);
283
+ // Compute hash of ALL operations across ALL collections (post-log-append).
284
+ // Validators re-execute the transaction and compare their computed hash.
285
+ // The shared operations-hash module canonicalises (sort + canonical JSON) so
286
+ // this order-independent fingerprint matches what a validator recomputes.
287
+ const operationsHash = await hashOperations(collectOperations(collectionTransforms));
175
288
 
176
- // Execute consensus phases (GATHER, PEND, COMMIT)
177
- const coordResult = await this.coordinateTransaction(
178
- transaction,
179
- operationsHash,
180
- collectionTransforms,
181
- criticalBlocks
182
- );
289
+ // Execute consensus phases (GATHER, PEND, COMMIT)
290
+ coordResult = await this.coordinateTransaction(
291
+ transaction,
292
+ operationsHash,
293
+ collectionTransforms,
294
+ criticalBlocks
295
+ );
296
+ } catch (err) {
297
+ // A throw here means the failure happened BEFORE any collection could
298
+ // durably commit (a log-append failure, or coordinateTransaction rejecting
299
+ // unexpectedly). Nothing landed on the cluster, so roll every tracker back
300
+ // to its pre-append snapshot — a genuinely clean rollback that leaves each
301
+ // tracker pristine for retry (see txn-failed-commit-leaves-staged-log-entry).
302
+ for (const { collectionId, collection } of collectionData) {
303
+ collection.restorePending(preCommitSnapshots.get(collectionId)!);
304
+ }
305
+ throw err;
306
+ }
183
307
 
184
308
  if (!coordResult.success) {
309
+ const committed = coordResult.committedCollections ?? new Set<CollectionId>();
310
+ if (committed.size > 0) {
311
+ // PARTIAL COMMIT: at least one collection durably committed via consensus
312
+ // while another failed permanently. A uniform pre-append restore would
313
+ // corrupt the committed half — re-staging its already-durable actions as
314
+ // still-pending, so tracker memory would disagree with cluster storage.
315
+ // Split the local handling instead:
316
+ for (const { collectionId, collection } of collectionData) {
317
+ if (committed.has(collectionId)) {
318
+ // Committed → the success-path local treatment (see below): fold the
319
+ // committed transforms into the read cache BEFORE resetting the tracker,
320
+ // then drop the now-durable pending actions so a retry cannot re-log them.
321
+ // NOTE: no-double-apply on retry depends on clearPendingActions() running for
322
+ // EVERY committed collection here before any re-drive of commit(). If a committed
323
+ // collection kept its pending queue, a subsequent commit() would re-append and
324
+ // re-log its already-durable actions — a duplicate log entry on the winner. The
325
+ // no-double-apply-on-retry test in transaction.spec.ts locks this.
326
+ const rev = collection.recordCommitted(transaction.id);
327
+ collection.applyCommittedToCache(collectionTransforms.get(collectionId)!, rev);
328
+ collection.tracker.reset();
329
+ collection.clearPendingActions();
330
+ } else {
331
+ // Failed / never-committed → restore the pre-append snapshot so a retry
332
+ // re-appends cleanly (no duplicate log entry).
333
+ collection.restorePending(preCommitSnapshots.get(collectionId)!);
334
+ }
335
+ }
336
+ // The transaction half-landed, so it is neither cleanly retryable nor
337
+ // cleanly abortable: drop its stamp tracking (the success path does the
338
+ // same at the end) and surface the structured signal for reconciliation.
339
+ this.stampData.delete(transaction.stamp.id);
340
+ throw new CoordinatorPartialCommitError(
341
+ [...committed],
342
+ [...(coordResult.failedCollections ?? new Set<CollectionId>())],
343
+ coordResult.error
344
+ );
345
+ }
346
+
347
+ // EMPTY committed set: PEND failed, or the whole commit failed cleanly with
348
+ // nothing durable. Restore every tracker so each is pristine for retry.
349
+ for (const { collectionId, collection } of collectionData) {
350
+ collection.restorePending(preCommitSnapshots.get(collectionId)!);
351
+ }
352
+ // Distinguish a genuine optimistic-concurrency conflict (a stale loss / pending
353
+ // contention — retryable after a re-read) from a hard failure (unavailable transactor,
354
+ // storage rejection, expired). Only the former is worth re-driving; the retry wrapper in
355
+ // commit() catches CoordinatorStaleLossError and re-attempts, while a plain Error escapes
356
+ // immediately (preserving the historical fail-fast behaviour for hard failures).
357
+ if (coordResult.staleLoss) {
358
+ throw new CoordinatorStaleLossError([...(coordResult.failedCollections ?? new Set(allCollectionIds))], coordResult.error);
359
+ }
185
360
  throw new Error(`Transaction commit failed: ${coordResult.error}`);
186
361
  }
187
362
 
@@ -193,15 +368,8 @@ export class TransactionCoordinator {
193
368
  // serves the new revision instead of the stale cached one. Clearing
194
369
  // pending keeps a subsequent commit from re-logging these actions.
195
370
  for (const { collectionId, collection } of collectionData) {
196
- const newRev = (collection['source'].actionContext?.rev ?? 0) + 1;
197
- collection['source'].actionContext = {
198
- committed: [
199
- ...(collection['source'].actionContext?.committed ?? []),
200
- { actionId: transaction.id, rev: newRev }
201
- ],
202
- rev: newRev,
203
- };
204
- collection.applyCommittedToCache(collectionTransforms.get(collectionId)!);
371
+ const rev = collection.recordCommitted(transaction.id);
372
+ collection.applyCommittedToCache(collectionTransforms.get(collectionId)!, rev);
205
373
  collection.tracker.reset();
206
374
  collection.clearPendingActions();
207
375
  }
@@ -318,64 +486,10 @@ export class TransactionCoordinator {
318
486
  }
319
487
  }
320
488
 
321
- /**
322
- * Compute hash of all operations in a transaction.
323
- * This hash is used for validation - validators re-execute the transaction
324
- * and compare their computed operations hash with this one.
325
- */
326
- private async hashOperations(operations: readonly Operation[]): Promise<string> {
327
- const operationsData = JSON.stringify(operations);
328
- return `ops:${await hashString(operationsData)}`;
329
- }
330
-
331
- /**
332
- * Commit a transaction context.
333
- *
334
- * @deprecated Use TransactionSession instead of TransactionContext
335
- * This is called by TransactionContext.commit().
336
- *
337
- * @param context - The transaction context to commit
338
- * @returns Execution result with actions and results
339
- */
340
- async commitTransaction(context: TransactionContext): Promise<ExecutionResult> {
341
- const collectionActions = Array.from(context.getCollectionActions().entries()).map(
342
- ([collectionId, actions]) => ({ collectionId, actions })
343
- );
344
-
345
- if (collectionActions.length === 0) {
346
- return { success: true }; // Nothing to commit
347
- }
348
-
349
- // Create transaction statements
350
- const statements = createActionsStatements(collectionActions);
351
- const reads = context.getReads();
352
-
353
- // Create stamp from context
354
- const stamp = await createTransactionStamp(
355
- 'local', // TODO: Get from context or coordinator
356
- Date.now(),
357
- '', // TODO: Get from engine
358
- context.engine
359
- );
360
-
361
- const transaction: Transaction = {
362
- stamp,
363
- statements,
364
- reads,
365
- id: await createTransactionId(stamp.id, statements, reads)
366
- };
367
-
368
- const engine = new ActionsEngine(this);
369
-
370
- // Execute through standard path
371
- return await this.execute(transaction, engine);
372
- }
373
-
374
489
  /**
375
490
  * Execute a fully-formed transaction.
376
491
  *
377
- * This can be called directly with a complete transaction (e.g., from Quereus),
378
- * or indirectly via commitTransaction().
492
+ * This is called with a complete transaction (e.g., from Quereus).
379
493
  *
380
494
  * @param transaction - The transaction to execute
381
495
  * @param engine - The engine to use for executing the transaction
@@ -405,7 +519,41 @@ export class TransactionCoordinator {
405
519
  return { success: true }; // Nothing to do
406
520
  }
407
521
 
408
- // 2. Apply actions to collections and collect transforms
522
+ // 1b. Stage the returned actions into the collection trackers.
523
+ //
524
+ // Reaching here means the engine RETURNED non-empty actions — i.e. the pure-
525
+ // translator model (see the ITransactionEngine contract): it translated the
526
+ // statements but did NOT apply them. So THIS path owns application — we stage the
527
+ // actions here via applyActions() (which also snapshots/tracks the stamp for
528
+ // rollback) BEFORE the loop below reads each tracker's transforms to materialise
529
+ // the log entry. (Previously ActionsEngine applied as a side effect and this
530
+ // method merely re-read the already-staged trackers; that side effect is gone, so
531
+ // the application must happen explicitly here. A side-effecting engine that
532
+ // applied internally would instead return EMPTY actions and short-circuit at the
533
+ // guard above.)
534
+ //
535
+ // applyActions() throws if a referenced collection is not registered — the same
536
+ // "Collection not found" the engine's side-effecting apply used to surface. Convert
537
+ // it back into a failure result so execute() keeps its return contract.
538
+ try {
539
+ await this.applyActions(result.actions, transaction.stamp.id);
540
+ } catch (error) {
541
+ const engineMs = Date.now() - tEngine;
542
+ log('execute:done trxId=%s engine=%dms apply-failed=true total=%dms', trxId, engineMs, Date.now() - t0);
543
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
544
+ }
545
+
546
+ // 2. Build a log entry per collection from the now-staged tracker transforms.
547
+ //
548
+ // NOTE: like commit(), this loop appends a log entry into each collection's
549
+ // tracker and these failure returns do NOT restore that state — so a partially
550
+ // applied engine transaction leaves appended-but-uncommitted entries in the
551
+ // trackers. This is deliberately NOT snapshot/restore-wrapped the way commit()
552
+ // is, because execute()'s asymmetry makes it lower risk: it is not the retryable
553
+ // session.commit() entry point (a failed execute() is not re-driven through the
554
+ // same loop), and its actions were tracked via applyActions() so rollback(stampId)
555
+ // CAN unwind them (unlike commit()'s directly-staged path). If execute() ever
556
+ // becomes retryable, mirror the commit() snapshot/restore fix here.
409
557
  const tApply = Date.now();
410
558
  const collectionTransforms = new Map<CollectionId, Transforms>();
411
559
  const criticalBlocks = new Map<CollectionId, BlockId>();
@@ -428,19 +576,8 @@ export class TransactionCoordinator {
428
576
  actionResults.set(collectionActions.collectionId, applyResult.results!);
429
577
  }
430
578
 
431
- // 3. Compute operations hash for validation
432
- const allOperations = Array.from(collectionTransforms.entries()).flatMap(([collectionId, transforms]) => [
433
- ...Object.entries(transforms.inserts ?? {}).map(([blockId, block]) =>
434
- ({ type: 'insert' as const, collectionId, blockId, block })
435
- ),
436
- ...Object.entries(transforms.updates ?? {}).map(([blockId, operations]) =>
437
- ({ type: 'update' as const, collectionId, blockId, operations })
438
- ),
439
- ...(transforms.deletes ?? []).map(blockId =>
440
- ({ type: 'delete' as const, collectionId, blockId })
441
- )
442
- ]);
443
- const operationsHash = await this.hashOperations(allOperations);
579
+ // 3. Compute operations hash for validation (order-independent; see commit()).
580
+ const operationsHash = await hashOperations(collectOperations(collectionTransforms));
444
581
 
445
582
  const applyMs = Date.now() - tApply;
446
583
 
@@ -456,22 +593,34 @@ export class TransactionCoordinator {
456
593
  const coordMs = Date.now() - tCoord;
457
594
  if (!coordResult.success) {
458
595
  log('execute:done trxId=%s engine=%dms apply=%dms coordinate=%dms success=false total=%dms', trxId, engineMs, applyMs, coordMs, Date.now() - t0);
459
- return coordResult;
596
+ // Stop lying to the caller about a partial commit: if some collections durably
597
+ // committed, surface that set. execute() is not snapshot/restore-wrapped (see the
598
+ // note above), but the committed subset must still get the success-path local
599
+ // treatment (recordCommitted + tracker.reset, as on the success path below) so its
600
+ // trackers aren't left mis-tracking already-durable state.
601
+ const committed = coordResult.committedCollections ?? new Set<CollectionId>();
602
+ if (committed.size > 0) {
603
+ for (const collectionActions of result.actions) {
604
+ const collection = this.collections.get(collectionActions.collectionId);
605
+ if (collection && committed.has(collectionActions.collectionId)) {
606
+ collection.recordCommitted(transaction.id);
607
+ collection.tracker.reset();
608
+ }
609
+ }
610
+ }
611
+ return {
612
+ success: false,
613
+ error: coordResult.error,
614
+ committedCollections: committed.size > 0 ? [...committed] : undefined,
615
+ failedCollections: coordResult.failedCollections ? [...coordResult.failedCollections] : undefined,
616
+ };
460
617
  }
461
618
 
462
619
  // 5. Update actionContext and reset trackers after successful commit
463
620
  for (const collectionActions of result.actions) {
464
621
  const collection = this.collections.get(collectionActions.collectionId);
465
622
  if (collection) {
466
- const newRev = (collection['source'].actionContext?.rev ?? 0) + 1;
467
- const actionId = transaction.id;
468
- collection['source'].actionContext = {
469
- committed: [
470
- ...(collection['source'].actionContext?.committed ?? []),
471
- { actionId, rev: newRev }
472
- ],
473
- rev: newRev,
474
- };
623
+ collection.recordCommitted(transaction.id);
475
624
  collection.tracker.reset();
476
625
  }
477
626
  }
@@ -513,7 +662,7 @@ export class TransactionCoordinator {
513
662
  }
514
663
 
515
664
  // At this point, actions have already been executed through collection.act()
516
- // when they were added to the TransactionContext. The collection's tracker
665
+ // (via the engine or the vtab's staging path). The collection's tracker
517
666
  // already has the transforms, and the actions are in the pending buffer.
518
667
 
519
668
  // Get transforms from the collection's tracker
@@ -530,7 +679,7 @@ export class TransactionCoordinator {
530
679
 
531
680
  // Generate action ID from transaction ID
532
681
  const actionId = transaction.id;
533
- const newRev = (collection['source'].actionContext?.rev ?? 0) + 1;
682
+ const newRev = collection.getNextRev();
534
683
 
535
684
  // Add actions to log (this updates the tracker with log block changes).
536
685
  // Persist the transaction's read set on the entry so a later invalidation cascade can
@@ -569,7 +718,15 @@ export class TransactionCoordinator {
569
718
  operationsHash: string,
570
719
  collectionTransforms: Map<CollectionId, Transforms>,
571
720
  criticalBlocks: Map<CollectionId, BlockId>
572
- ): Promise<{ success: boolean; error?: string }> {
721
+ ): Promise<{
722
+ success: boolean;
723
+ error?: string;
724
+ committedCollections?: Set<CollectionId>;
725
+ failedCollections?: Set<CollectionId>;
726
+ /** True when the failure was a clean optimistic-concurrency conflict (stale loss / pending
727
+ * contention) with nothing durable — i.e. safe to re-drive after a re-read. */
728
+ staleLoss?: boolean;
729
+ }> {
573
730
  const trxId = transaction.id;
574
731
  const t0 = Date.now();
575
732
 
@@ -609,7 +766,16 @@ export class TransactionCoordinator {
609
766
  commitResult.committedCollections
610
767
  );
611
768
  log('trx:phases trxId=%s gather=%dms pend=%dms commit=%dms (failed) total=%dms', trxId, gatherMs, pendMs, commitMs, Date.now() - t0);
612
- return { success: false, error: commitResult.error };
769
+ // Surface the committed/failed partition so commit()/execute() can report which
770
+ // collections durably landed. A non-empty committedCollections is a PARTIAL commit:
771
+ // those collections cannot be rolled back and the caller must reconcile.
772
+ return {
773
+ success: false,
774
+ error: commitResult.error,
775
+ committedCollections: commitResult.committedCollections,
776
+ failedCollections: commitResult.failedCollections,
777
+ staleLoss: commitResult.staleLoss,
778
+ };
613
779
  }
614
780
 
615
781
  // 4. PROPAGATE and CHECKPOINT phases are handled by clusters automatically
@@ -647,16 +813,19 @@ export class TransactionCoordinator {
647
813
  );
648
814
  const results = await Promise.all(nomineePromises);
649
815
 
650
- // Merge all nominees into a single set
651
- const supercluster = results.reduce(
816
+ // Merge all nominees into a single set, deduped by peer identity. Each
817
+ // queryClusterNominees builds a fresh PeerId object per call (peerIdFromString),
818
+ // so a Set keyed by object reference would keep the same physical peer twice when
819
+ // it nominates for two critical clusters. Key by toString() to collapse duplicates.
820
+ const byId = results.reduce(
652
821
  (acc, result) => {
653
- result.nominees.forEach(nominee => acc.add(nominee));
822
+ result.nominees.forEach(nominee => acc.set(nominee.toString(), nominee));
654
823
  return acc;
655
824
  },
656
- new Set<PeerId>()
825
+ new Map<string, PeerId>()
657
826
  );
658
827
 
659
- return supercluster;
828
+ return new Set(byId.values());
660
829
  }
661
830
 
662
831
  /**
@@ -672,56 +841,104 @@ export class TransactionCoordinator {
672
841
  operationsHash: string,
673
842
  collectionTransforms: ReadonlyMap<CollectionId, Transforms>,
674
843
  superclusterNominees: ReadonlySet<PeerId> | null
675
- ): Promise<{ success: boolean; error?: string; pendedBlockIds?: Map<CollectionId, BlockId[]> }> {
844
+ ): Promise<{ success: boolean; error?: string; pendedBlockIds?: Map<CollectionId, BlockId[]>; staleLoss?: boolean }> {
676
845
  if (collectionTransforms.size === 0) {
677
846
  return { success: false, error: 'No transforms to pend' };
678
847
  }
679
848
 
680
- const pendedBlockIds = new Map<CollectionId, BlockId[]>();
681
849
  const actionId = transaction.id as ActionId;
682
850
  const nominees = superclusterNominees ? Array.from(superclusterNominees) : undefined;
683
851
 
684
- // Pend each collection's transforms
685
- for (const [collectionId, transforms] of collectionTransforms.entries()) {
686
- const collection = this.collections.get(collectionId);
687
- if (!collection) {
688
- return { success: false, error: `Collection not found: ${collectionId}` };
689
- }
690
-
691
- // Get revision from the collection's source
692
- const rev = (collection['source'].actionContext?.rev ?? 0) + 1;
693
-
694
- // Create pend request with transaction and operations hash for validation
695
- const pendRequest: PendRequest = {
696
- actionId,
697
- rev,
698
- transforms,
699
- policy: 'r', // Return policy: fail but return pending actions
700
- transaction,
701
- operationsHash,
702
- superclusterNominees: nominees
703
- };
852
+ // Fan out the independent per-collection pends concurrently. Each settles to a
853
+ // { collectionId, blockIds } on success, or rejects with the per-collection reason.
854
+ // NOTE: unbounded fan-out — one concurrent coordinator round-trip per collection.
855
+ // Transactions touch few collections today; if one ever spans very many, bound this
856
+ // with a concurrency limiter so peak in-flight round-trips stays sane. Same for commitPhase.
857
+ const outcomes = await Promise.allSettled(
858
+ Array.from(collectionTransforms.entries()).map(([collectionId, transforms]) =>
859
+ this.pendCollection(transaction, operationsHash, collectionId, transforms, actionId, nominees)
860
+ )
861
+ );
704
862
 
705
- // Pend the transaction
706
- const pendResult = await this.transactor.pend(pendRequest);
707
- if (!pendResult.success) {
708
- // Cancel any already-pended collections before returning
709
- for (const [, pendedBlockIdList] of pendedBlockIds.entries()) {
710
- await this.transactor.cancel({ actionId, blockIds: pendedBlockIdList });
863
+ // Partition settled results: every collection that DID pend (keyed with its block
864
+ // ids), plus the first failure reason if any collection failed.
865
+ const pendedBlockIds = new Map<CollectionId, BlockId[]>();
866
+ let failure: string | undefined;
867
+ // Classify across ALL failures (mirroring commitPhase, and independent of iteration order):
868
+ // the pend is a retryable clean stale loss only if at least one failure was a conflicting pend
869
+ // (PendRejectedError.conflict) AND none was a hard failure. A single hard failure (storage/
870
+ // policy rejection, or a thrown/unavailable transactor) will not clear on a re-read, so
871
+ // re-driving it would just burn the retry budget — fail fast instead.
872
+ let anyConflict = false;
873
+ let anyHard = false;
874
+ for (const outcome of outcomes) {
875
+ if (outcome.status === 'fulfilled') {
876
+ pendedBlockIds.set(outcome.value.collectionId, outcome.value.blockIds);
877
+ } else {
878
+ if (failure === undefined) {
879
+ failure = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
711
880
  }
712
- return {
713
- success: false,
714
- error: `Pend failed for collection ${collectionId}: ${pendResult.reason}`
715
- };
881
+ if (outcome.reason instanceof PendRejectedError && outcome.reason.conflict) anyConflict = true;
882
+ else anyHard = true;
716
883
  }
884
+ }
717
885
 
718
- // Store the pended block IDs for commit phase
719
- pendedBlockIds.set(collectionId, pendResult.blockIds);
886
+ if (failure !== undefined) {
887
+ // Any failure aborts the whole pend. With concurrency several collections may
888
+ // have pended in parallel, so cancel EVERY successfully-pended collection — not
889
+ // only those started before the failure. Cancels are best-effort (cancelPhase
890
+ // swallows their errors) so they cannot mask the original pend failure.
891
+ await this.cancelPhase(actionId, pendedBlockIds);
892
+ return { success: false, error: failure, staleLoss: anyConflict && !anyHard };
720
893
  }
721
894
 
722
895
  return { success: true, pendedBlockIds };
723
896
  }
724
897
 
898
+ /**
899
+ * Pend a single collection's transforms. Resolves with the collection id and its
900
+ * pended block ids on success; throws with a per-collection reason on failure so the
901
+ * fan-out in {@link pendPhase} can settle it as a rejection.
902
+ */
903
+ private async pendCollection(
904
+ transaction: Transaction,
905
+ operationsHash: string,
906
+ collectionId: CollectionId,
907
+ transforms: Transforms,
908
+ actionId: ActionId,
909
+ nominees: PeerId[] | undefined
910
+ ): Promise<{ collectionId: CollectionId; blockIds: BlockId[] }> {
911
+ const collection = this.collections.get(collectionId);
912
+ if (!collection) {
913
+ throw new Error(`Collection not found: ${collectionId}`);
914
+ }
915
+
916
+ // Get revision from the collection's source
917
+ const rev = collection.getNextRev();
918
+
919
+ // Create pend request with transaction and operations hash for validation
920
+ const pendRequest: PendRequest = {
921
+ actionId,
922
+ rev,
923
+ transforms,
924
+ policy: 'r', // Return policy: fail but return pending actions
925
+ transaction,
926
+ operationsHash,
927
+ superclusterNominees: nominees
928
+ };
929
+
930
+ const pendResult = await this.transactor.pend(pendRequest);
931
+ if (!pendResult.success) {
932
+ // A committed `missing` action or a `pending` action on a touched block is an
933
+ // optimistic-concurrency conflict — retryable after a re-read. A bare `reason` is a hard
934
+ // rejection (storage/policy) that re-driving won't fix.
935
+ const conflict = Boolean(pendResult.missing?.length || pendResult.pending?.length);
936
+ throw new PendRejectedError(collectionId, conflict, pendResult.reason);
937
+ }
938
+
939
+ return { collectionId, blockIds: pendResult.blockIds };
940
+ }
941
+
725
942
  /**
726
943
  * COMMIT phase: Commit to all critical blocks with retry for transient failures.
727
944
  *
@@ -738,69 +955,114 @@ export class TransactionCoordinator {
738
955
  error?: string;
739
956
  committedCollections: Set<CollectionId>;
740
957
  failedCollections: Set<CollectionId>;
958
+ staleLoss?: boolean;
741
959
  }> {
960
+ // Fan out the independent per-collection commit-with-retry concurrently, then
961
+ // aggregate the committed/failed partition from the settled results.
962
+ const outcomes = await Promise.allSettled(
963
+ Array.from(pendedBlockIds.entries()).map(([collectionId, blockIds]) =>
964
+ this.commitCollection(actionId, criticalBlockIds, collectionId, blockIds)
965
+ )
966
+ );
967
+
742
968
  const committedCollections = new Set<CollectionId>();
743
969
  const failedCollections = new Set<CollectionId>();
744
-
745
- // Commit each collection's transaction with retry
746
- for (const [collectionId, blockIds] of pendedBlockIds.entries()) {
747
- const collection = this.collections.get(collectionId);
748
- if (!collection) {
749
- failedCollections.add(collectionId);
750
- return {
751
- success: false,
752
- error: `Collection not found: ${collectionId}`,
753
- committedCollections,
754
- failedCollections
755
- };
970
+ const errors: string[] = [];
971
+ // Classify failures: a returned stale loss (someone committed a newer rev) is retryable after
972
+ // a re-read; a thrown/transient-exhausted or structural failure is not. staleLoss holds only
973
+ // if EVERY failure was a stale loss — a single hard failure makes the whole attempt not worth
974
+ // re-driving.
975
+ let anyStale = false;
976
+ let anyHard = false;
977
+ for (const outcome of outcomes) {
978
+ if (outcome.status === 'fulfilled') {
979
+ const { collectionId, committed, error, stale } = outcome.value;
980
+ if (committed) {
981
+ committedCollections.add(collectionId);
982
+ } else {
983
+ failedCollections.add(collectionId);
984
+ if (error) errors.push(error);
985
+ if (stale) anyStale = true; else anyHard = true;
986
+ }
987
+ } else {
988
+ // commitCollection resolves rather than rejects, but treat any unexpected
989
+ // rejection as a (hard) failure so the partitioned sets stay honest.
990
+ errors.push(outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason));
991
+ anyHard = true;
756
992
  }
993
+ }
757
994
 
758
- // Get revision
759
- const rev = (collection['source'].actionContext?.rev ?? 0) + 1;
995
+ if (failedCollections.size > 0 || errors.length > 0) {
996
+ return {
997
+ success: false,
998
+ error: errors.join('; ') || 'Commit failed',
999
+ committedCollections,
1000
+ failedCollections,
1001
+ staleLoss: anyStale && !anyHard,
1002
+ };
1003
+ }
760
1004
 
761
- // Find the critical block (log tail) for this collection
762
- const logTailBlockId = criticalBlockIds.find(blockId =>
763
- blockIds.includes(blockId)
764
- );
1005
+ return { success: true, committedCollections, failedCollections };
1006
+ }
765
1007
 
766
- if (!logTailBlockId) {
767
- failedCollections.add(collectionId);
768
- return {
769
- success: false,
770
- error: `Log tail block not found for collection ${collectionId}`,
771
- committedCollections,
772
- failedCollections
773
- };
774
- }
1008
+ /**
1009
+ * Commit a single collection's pended blocks, retrying transient failures up to three
1010
+ * times (forward recovery). Always resolves — success is carried in the returned
1011
+ * `committed` flag — so the fan-out in {@link commitPhase} can aggregate every result.
1012
+ */
1013
+ private async commitCollection(
1014
+ actionId: ActionId,
1015
+ criticalBlockIds: BlockId[],
1016
+ collectionId: CollectionId,
1017
+ blockIds: BlockId[]
1018
+ ): Promise<{ collectionId: CollectionId; committed: boolean; error?: string; stale?: boolean }> {
1019
+ const collection = this.collections.get(collectionId);
1020
+ if (!collection) {
1021
+ return { collectionId, committed: false, error: `Collection not found: ${collectionId}` };
1022
+ }
775
1023
 
776
- // Create commit request
777
- const commitRequest: CommitRequest = {
778
- actionId,
779
- blockIds,
780
- tailId: logTailBlockId,
781
- rev
782
- };
1024
+ // Get revision
1025
+ const rev = collection.getNextRev();
1026
+
1027
+ // Find the critical block (log tail) for this collection
1028
+ const logTailBlockId = criticalBlockIds.find(blockId => blockIds.includes(blockId));
1029
+ if (!logTailBlockId) {
1030
+ return { collectionId, committed: false, error: `Log tail block not found for collection ${collectionId}` };
1031
+ }
1032
+
1033
+ // Create commit request
1034
+ const commitRequest: CommitRequest = {
1035
+ actionId,
1036
+ blockIds,
1037
+ tailId: logTailBlockId,
1038
+ rev
1039
+ };
783
1040
 
784
- // Retry up to 3 attempts for transient failures
785
- let committed = false;
786
- for (let attempt = 0; attempt < 3 && !committed; attempt++) {
1041
+ // Retry ONLY transient/thrown failures (unreachable peers, timeout) — forward recovery.
1042
+ // A returned { success:false } is a permanent stale loss (someone committed a newer rev);
1043
+ // the identical request can never win, so return immediately without retrying. Either way
1044
+ // cancelPhase (run by coordinateTransaction on commitPhase failure) releases the pend
1045
+ // exactly once — commit itself no longer self-cancels.
1046
+ let lastTransientError: string | undefined;
1047
+ for (let attempt = 0; attempt < 3; attempt++) {
1048
+ try {
787
1049
  const commitResult = await this.transactor.commit(commitRequest);
788
1050
  if (commitResult.success) {
789
- committed = true;
790
- committedCollections.add(collectionId);
791
- } else if (attempt === 2) {
792
- failedCollections.add(collectionId);
793
- return {
794
- success: false,
795
- error: `Commit failed for collection ${collectionId} after 3 attempts`,
796
- committedCollections,
797
- failedCollections
798
- };
1051
+ return { collectionId, committed: true };
799
1052
  }
1053
+ // Permanent stale failure: do not retry here. It IS a clean stale loss, though, so
1054
+ // mark it retryable at the coordinator level (after a re-read advances the rev).
1055
+ return {
1056
+ collectionId,
1057
+ committed: false,
1058
+ stale: true,
1059
+ error: commitResult.reason ?? `Stale commit for collection ${collectionId}`
1060
+ };
1061
+ } catch (e) {
1062
+ lastTransientError = e instanceof Error ? e.message : String(e);
800
1063
  }
801
1064
  }
802
-
803
- return { success: true, committedCollections, failedCollections };
1065
+ return { collectionId, committed: false, error: `Commit failed for collection ${collectionId} after 3 attempts: ${lastTransientError}` };
804
1066
  }
805
1067
 
806
1068
  /**
@@ -814,10 +1076,17 @@ export class TransactionCoordinator {
814
1076
  pendedBlockIds: Map<CollectionId, BlockId[]>,
815
1077
  excludeCollections?: Set<CollectionId>
816
1078
  ): Promise<void> {
817
- for (const [collectionId, blockIds] of pendedBlockIds.entries()) {
818
- if (excludeCollections?.has(collectionId)) continue;
819
- await this.transactor.cancel({ actionId, blockIds });
820
- }
1079
+ // Fan out the per-collection cancels concurrently. Each is best-effort: a cancel
1080
+ // fault is logged and swallowed so it cannot mask the pend/commit failure that
1081
+ // triggered this sweep, and so one failed cancel does not abort the others.
1082
+ const cancels = Array.from(pendedBlockIds.entries())
1083
+ .filter(([collectionId]) => !excludeCollections?.has(collectionId))
1084
+ .map(([collectionId, blockIds]) =>
1085
+ this.transactor.cancel({ actionId, blockIds }).catch(err => {
1086
+ log('cancelPhase: best-effort cancel failed collection=%s: %o', collectionId, err);
1087
+ })
1088
+ );
1089
+ await Promise.all(cancels);
821
1090
  }
822
1091
 
823
1092
  }