@optimystic/db-core 0.14.1 → 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,8 +1,32 @@
1
- import { ActionsEngine } from "./actions-engine.js";
2
- import { createActionsStatements, createTransactionStamp, createTransactionId, isTransactionExpired } from "./transaction.js";
3
- import { Log, blockIdsForTransforms, hashString } from "../index.js";
1
+ import { isTransactionExpired, clampPriority } from "./transaction.js";
2
+ import { Log, blockIdsForTransforms } from "../index.js";
3
+ import { collectOperations, hashOperations } from "./operations-hash.js";
4
+ import { CoordinatorPartialCommitError, CoordinatorStaleLossError } from "./errors.js";
5
+ import { jitteredBackoffMs, abortableDelay, makeAbortError } from "../utility/backoff.js";
4
6
  import { createLogger } from "../logger.js";
5
7
  const log = createLogger('trx:coordinator');
8
+ /** Default max consecutive clean-stale-loss retries before {@link TransactionCoordinator.commit}
9
+ * gives up. Mirrors the single-collection sync default so the two retry loops share one policy. */
10
+ const DefaultMaxAttempts = 10;
11
+ /** Default base backoff (ms) before the first commit retry. */
12
+ const DefaultBaseBackoffMs = 100;
13
+ /** Default ceiling (ms) on a single commit-retry backoff sleep. */
14
+ const DefaultMaxBackoffMs = 5000;
15
+ /**
16
+ * A pend that failed. `conflict` marks the retryable class — an optimistic-concurrency collision (a
17
+ * committed `missing` action newer than our rev, or a `pending` action on a touched block) that a
18
+ * re-read + re-pend can clear. A bare rejection reason (storage full, policy) is NOT a conflict and
19
+ * is not worth re-driving. Thrown by {@link TransactionCoordinator.pendCollection} so the fan-out in
20
+ * pendPhase can settle it and read the flag off the rejection.
21
+ */
22
+ class PendRejectedError extends Error {
23
+ conflict;
24
+ constructor(collectionId, conflict, reason) {
25
+ super(`Pend failed for collection ${collectionId}: ${reason ?? (conflict ? 'stale conflict' : 'rejected')}`);
26
+ this.conflict = conflict;
27
+ this.name = 'PendRejectedError';
28
+ }
29
+ }
6
30
  /**
7
31
  * Coordinates multi-collection transactions.
8
32
  *
@@ -65,19 +89,102 @@ export class TransactionCoordinator {
65
89
  }
66
90
  }
67
91
  /**
68
- * Commit a transaction: materialise a log entry from each collection's staged
92
+ * Commit a transaction with a bounded, jittered backoff retry around a CLEAN stale loss.
93
+ *
94
+ * The single-attempt work lives in {@link commitOnce}; this wrapper re-drives it when the attempt
95
+ * fails as a clean optimistic-concurrency loss ({@link CoordinatorStaleLossError} — nothing
96
+ * durably committed, every tracker restored to its pre-append state). Before each re-attempt it
97
+ * re-reads each collection to fresh revisions (so the retry pends against current state rather
98
+ * than immediately re-failing stale), then backs off with the same jitter policy as
99
+ * {@link Collection.sync}. Retry is bounded by `maxAttempts` and an optional wall-clock
100
+ * `deadlineMs`, and honours an abort `signal`.
101
+ *
102
+ * A {@link CoordinatorPartialCommitError} (a partial landing — some collection durably committed)
103
+ * is NOT retryable and escapes immediately: blindly retrying would re-log already-durable actions.
104
+ * Any other failure (expired transaction, unavailable transactor, unreachable cluster) also
105
+ * propagates without retry — only genuine clean stale losses are re-driven.
106
+ *
107
+ * Defaults are safe out of the box: a caller that passes no options gets bounded, jittered retry.
108
+ *
109
+ * @param transaction - The transaction to commit
110
+ * @param options - Retry knobs; shares the {@link SyncOptions} vocabulary with `Collection.sync`.
111
+ */
112
+ async commit(transaction, options) {
113
+ const maxAttempts = options?.maxAttempts ?? DefaultMaxAttempts;
114
+ const baseBackoffMs = options?.baseBackoffMs ?? DefaultBaseBackoffMs;
115
+ const maxBackoffMs = options?.maxBackoffMs ?? DefaultMaxBackoffMs;
116
+ const deadlineMs = options?.deadlineMs;
117
+ const signal = options?.signal;
118
+ const startedAt = Date.now();
119
+ // Count of consecutive clean stale losses. There is no forward-progress notion here (a
120
+ // single commit either lands or it does not), so this simply bounds how many times we
121
+ // re-drive a losing transaction before surfacing a terminal error.
122
+ let staleLosses = 0;
123
+ let lastLoss;
124
+ for (;;) {
125
+ if (signal?.aborted) {
126
+ throw makeAbortError(signal);
127
+ }
128
+ // Progress-agnostic ceiling: once we've taken at least one loss, give up if the
129
+ // wall-clock deadline passed (independent of the attempt cap).
130
+ if (deadlineMs !== undefined && lastLoss && Date.now() - startedAt >= deadlineMs) {
131
+ throw lastLoss;
132
+ }
133
+ // Age the transaction's advisory priority by the number of losses taken so far, so a
134
+ // repeatedly-losing transaction out-ranks fresh (priority-0) rivals in the cluster's
135
+ // resolveRace. Fairness-only and capped at MaxPriority; excluded from the tx id / client
136
+ // signature, so bumping it here does not churn identity. Left untouched on the first
137
+ // attempt (staleLosses == 0) so the initial pend serializes exactly as before.
138
+ if (staleLosses > 0) {
139
+ transaction.priority = clampPriority(staleLosses);
140
+ }
141
+ try {
142
+ await this.commitOnce(transaction);
143
+ return;
144
+ }
145
+ catch (err) {
146
+ // Only a CLEAN stale loss is retryable. A partial landing, an expired transaction, an
147
+ // unavailable transactor, etc. all propagate unchanged.
148
+ if (!(err instanceof CoordinatorStaleLossError)) {
149
+ throw err;
150
+ }
151
+ lastLoss = err;
152
+ staleLosses++;
153
+ if (staleLosses >= maxAttempts) {
154
+ throw err;
155
+ }
156
+ const delay = jitteredBackoffMs(staleLosses - 1, { baseMs: baseBackoffMs, capMs: maxBackoffMs }, options?.rand);
157
+ await abortableDelay(delay, signal);
158
+ // Re-read fresh state before re-attempting so the next commit pends against current
159
+ // revisions (mirrors how Collection.sync calls updateInternal() before retrying).
160
+ // NOTE: refreshes EVERY registered collection, not only the participants of this
161
+ // transaction. Harmless (a non-participant's update() just fetches latest) and the
162
+ // registered set is small today; if a coordinator ever holds many collections and this
163
+ // shows up as retry latency, narrow it to the transaction's participating collections.
164
+ for (const collection of this.collections.values()) {
165
+ await collection.update();
166
+ }
167
+ }
168
+ }
169
+ }
170
+ /**
171
+ * Commit a transaction (single attempt): materialise a log entry from each collection's staged
69
172
  * pending actions, then orchestrate the distributed consensus (GATHER/PEND/COMMIT).
70
173
  *
71
- * Called by TransactionSession.commit() after all statements have executed. The
174
+ * Called by {@link commit} (which wraps it in the backoff+jitter retry loop). The
72
175
  * staged mutations already live in each collection's tracker — applied either via
73
176
  * applyActions() (engine-driven path) or directly via Collection.act()/Tree.stage
74
177
  * (the vtab's deferred-DML path) — but in BOTH cases without a log entry yet, so
75
178
  * this method appends that entry here (see the inline note below) before pending,
76
179
  * and folds the committed transforms back into each collection's read cache.
77
180
  *
181
+ * On a clean stale loss (nothing durable, every tracker restored) it throws
182
+ * {@link CoordinatorStaleLossError} so the caller can retry; on a partial landing it throws
183
+ * {@link CoordinatorPartialCommitError} (not retryable).
184
+ *
78
185
  * @param transaction - The transaction to commit
79
186
  */
80
- async commit(transaction) {
187
+ async commitOnce(transaction) {
81
188
  if (isTransactionExpired(transaction.stamp)) {
82
189
  throw new Error(`Transaction expired at ${transaction.stamp.expiration}`);
83
190
  }
@@ -109,25 +216,95 @@ export class TransactionCoordinator {
109
216
  const allCollectionIds = collectionData.map(({ collectionId }) => collectionId);
110
217
  const collectionTransforms = new Map();
111
218
  const criticalBlocks = new Map();
219
+ // Snapshot EVERY participating collection's staged state (transforms + pending
220
+ // queue) BEFORE the append loop mutates any tracker. The loop appends log
221
+ // entries sequentially, so a failure on the Nth collection must also undo the
222
+ // 0..N-1 collections that already appended — and coordinateTransaction can fail
223
+ // after ALL of them appended. On any throw below we restore every snapshot, so a
224
+ // failed commit leaves each tracker exactly as it was: a retry re-appends cleanly
225
+ // (no duplicate log entry) and a directly-staged tree's rollback (which no-ops
226
+ // when the stamp was never tracked via applyActions) has nothing poisoned to undo.
227
+ const preCommitSnapshots = new Map();
112
228
  for (const { collectionId, collection } of collectionData) {
113
- const applyResult = await this.applyActionsToCollection({ collectionId, actions: collection.getPendingActions() }, transaction, allCollectionIds);
114
- if (!applyResult.success) {
115
- throw new Error(`Transaction commit failed: ${applyResult.error}`);
229
+ preCommitSnapshots.set(collectionId, collection.snapshotPending());
230
+ }
231
+ let coordResult;
232
+ try {
233
+ for (const { collectionId, collection } of collectionData) {
234
+ const applyResult = await this.applyActionsToCollection({ collectionId, actions: collection.getPendingActions() }, transaction, allCollectionIds);
235
+ if (!applyResult.success) {
236
+ throw new Error(`Transaction commit failed: ${applyResult.error}`);
237
+ }
238
+ collectionTransforms.set(collectionId, applyResult.transforms);
239
+ criticalBlocks.set(collectionId, applyResult.logTailBlockId);
116
240
  }
117
- collectionTransforms.set(collectionId, applyResult.transforms);
118
- criticalBlocks.set(collectionId, applyResult.logTailBlockId);
119
- }
120
- // Compute hash of ALL operations across ALL collections (post-log-append).
121
- // Validators re-execute the transaction and compare their computed hash.
122
- const allOperations = Array.from(collectionTransforms.entries()).flatMap(([collectionId, transforms]) => [
123
- ...Object.entries(transforms.inserts ?? {}).map(([blockId, block]) => ({ type: 'insert', collectionId, blockId, block })),
124
- ...Object.entries(transforms.updates ?? {}).map(([blockId, operations]) => ({ type: 'update', collectionId, blockId, operations })),
125
- ...(transforms.deletes ?? []).map(blockId => ({ type: 'delete', collectionId, blockId }))
126
- ]);
127
- const operationsHash = await this.hashOperations(allOperations);
128
- // Execute consensus phases (GATHER, PEND, COMMIT)
129
- const coordResult = await this.coordinateTransaction(transaction, operationsHash, collectionTransforms, criticalBlocks);
241
+ // Compute hash of ALL operations across ALL collections (post-log-append).
242
+ // Validators re-execute the transaction and compare their computed hash.
243
+ // The shared operations-hash module canonicalises (sort + canonical JSON) so
244
+ // this order-independent fingerprint matches what a validator recomputes.
245
+ const operationsHash = await hashOperations(collectOperations(collectionTransforms));
246
+ // Execute consensus phases (GATHER, PEND, COMMIT)
247
+ coordResult = await this.coordinateTransaction(transaction, operationsHash, collectionTransforms, criticalBlocks);
248
+ }
249
+ catch (err) {
250
+ // A throw here means the failure happened BEFORE any collection could
251
+ // durably commit (a log-append failure, or coordinateTransaction rejecting
252
+ // unexpectedly). Nothing landed on the cluster, so roll every tracker back
253
+ // to its pre-append snapshot a genuinely clean rollback that leaves each
254
+ // tracker pristine for retry (see txn-failed-commit-leaves-staged-log-entry).
255
+ for (const { collectionId, collection } of collectionData) {
256
+ collection.restorePending(preCommitSnapshots.get(collectionId));
257
+ }
258
+ throw err;
259
+ }
130
260
  if (!coordResult.success) {
261
+ const committed = coordResult.committedCollections ?? new Set();
262
+ if (committed.size > 0) {
263
+ // PARTIAL COMMIT: at least one collection durably committed via consensus
264
+ // while another failed permanently. A uniform pre-append restore would
265
+ // corrupt the committed half — re-staging its already-durable actions as
266
+ // still-pending, so tracker memory would disagree with cluster storage.
267
+ // Split the local handling instead:
268
+ for (const { collectionId, collection } of collectionData) {
269
+ if (committed.has(collectionId)) {
270
+ // Committed → the success-path local treatment (see below): fold the
271
+ // committed transforms into the read cache BEFORE resetting the tracker,
272
+ // then drop the now-durable pending actions so a retry cannot re-log them.
273
+ // NOTE: no-double-apply on retry depends on clearPendingActions() running for
274
+ // EVERY committed collection here before any re-drive of commit(). If a committed
275
+ // collection kept its pending queue, a subsequent commit() would re-append and
276
+ // re-log its already-durable actions — a duplicate log entry on the winner. The
277
+ // no-double-apply-on-retry test in transaction.spec.ts locks this.
278
+ const rev = collection.recordCommitted(transaction.id);
279
+ collection.applyCommittedToCache(collectionTransforms.get(collectionId), rev);
280
+ collection.tracker.reset();
281
+ collection.clearPendingActions();
282
+ }
283
+ else {
284
+ // Failed / never-committed → restore the pre-append snapshot so a retry
285
+ // re-appends cleanly (no duplicate log entry).
286
+ collection.restorePending(preCommitSnapshots.get(collectionId));
287
+ }
288
+ }
289
+ // The transaction half-landed, so it is neither cleanly retryable nor
290
+ // cleanly abortable: drop its stamp tracking (the success path does the
291
+ // same at the end) and surface the structured signal for reconciliation.
292
+ this.stampData.delete(transaction.stamp.id);
293
+ throw new CoordinatorPartialCommitError([...committed], [...(coordResult.failedCollections ?? new Set())], coordResult.error);
294
+ }
295
+ // EMPTY committed set: PEND failed, or the whole commit failed cleanly with
296
+ // nothing durable. Restore every tracker so each is pristine for retry.
297
+ for (const { collectionId, collection } of collectionData) {
298
+ collection.restorePending(preCommitSnapshots.get(collectionId));
299
+ }
300
+ // Distinguish a genuine optimistic-concurrency conflict (a stale loss / pending
301
+ // contention — retryable after a re-read) from a hard failure (unavailable transactor,
302
+ // storage rejection, expired). Only the former is worth re-driving; the retry wrapper in
303
+ // commit() catches CoordinatorStaleLossError and re-attempts, while a plain Error escapes
304
+ // immediately (preserving the historical fail-fast behaviour for hard failures).
305
+ if (coordResult.staleLoss) {
306
+ throw new CoordinatorStaleLossError([...(coordResult.failedCollections ?? new Set(allCollectionIds))], coordResult.error);
307
+ }
131
308
  throw new Error(`Transaction commit failed: ${coordResult.error}`);
132
309
  }
133
310
  // Advance actionContext, fold the committed transforms into each
@@ -138,15 +315,8 @@ export class TransactionCoordinator {
138
315
  // serves the new revision instead of the stale cached one. Clearing
139
316
  // pending keeps a subsequent commit from re-logging these actions.
140
317
  for (const { collectionId, collection } of collectionData) {
141
- const newRev = (collection['source'].actionContext?.rev ?? 0) + 1;
142
- collection['source'].actionContext = {
143
- committed: [
144
- ...(collection['source'].actionContext?.committed ?? []),
145
- { actionId: transaction.id, rev: newRev }
146
- ],
147
- rev: newRev,
148
- };
149
- collection.applyCommittedToCache(collectionTransforms.get(collectionId));
318
+ const rev = collection.recordCommitted(transaction.id);
319
+ collection.applyCommittedToCache(collectionTransforms.get(collectionId), rev);
150
320
  collection.tracker.reset();
151
321
  collection.clearPendingActions();
152
322
  }
@@ -250,51 +420,10 @@ export class TransactionCoordinator {
250
420
  collection.clearReadDependencies();
251
421
  }
252
422
  }
253
- /**
254
- * Compute hash of all operations in a transaction.
255
- * This hash is used for validation - validators re-execute the transaction
256
- * and compare their computed operations hash with this one.
257
- */
258
- async hashOperations(operations) {
259
- const operationsData = JSON.stringify(operations);
260
- return `ops:${await hashString(operationsData)}`;
261
- }
262
- /**
263
- * Commit a transaction context.
264
- *
265
- * @deprecated Use TransactionSession instead of TransactionContext
266
- * This is called by TransactionContext.commit().
267
- *
268
- * @param context - The transaction context to commit
269
- * @returns Execution result with actions and results
270
- */
271
- async commitTransaction(context) {
272
- const collectionActions = Array.from(context.getCollectionActions().entries()).map(([collectionId, actions]) => ({ collectionId, actions }));
273
- if (collectionActions.length === 0) {
274
- return { success: true }; // Nothing to commit
275
- }
276
- // Create transaction statements
277
- const statements = createActionsStatements(collectionActions);
278
- const reads = context.getReads();
279
- // Create stamp from context
280
- const stamp = await createTransactionStamp('local', // TODO: Get from context or coordinator
281
- Date.now(), '', // TODO: Get from engine
282
- context.engine);
283
- const transaction = {
284
- stamp,
285
- statements,
286
- reads,
287
- id: await createTransactionId(stamp.id, statements, reads)
288
- };
289
- const engine = new ActionsEngine(this);
290
- // Execute through standard path
291
- return await this.execute(transaction, engine);
292
- }
293
423
  /**
294
424
  * Execute a fully-formed transaction.
295
425
  *
296
- * This can be called directly with a complete transaction (e.g., from Quereus),
297
- * or indirectly via commitTransaction().
426
+ * This is called with a complete transaction (e.g., from Quereus).
298
427
  *
299
428
  * @param transaction - The transaction to execute
300
429
  * @param engine - The engine to use for executing the transaction
@@ -319,7 +448,41 @@ export class TransactionCoordinator {
319
448
  if (!result.actions || result.actions.length === 0) {
320
449
  return { success: true }; // Nothing to do
321
450
  }
322
- // 2. Apply actions to collections and collect transforms
451
+ // 1b. Stage the returned actions into the collection trackers.
452
+ //
453
+ // Reaching here means the engine RETURNED non-empty actions — i.e. the pure-
454
+ // translator model (see the ITransactionEngine contract): it translated the
455
+ // statements but did NOT apply them. So THIS path owns application — we stage the
456
+ // actions here via applyActions() (which also snapshots/tracks the stamp for
457
+ // rollback) BEFORE the loop below reads each tracker's transforms to materialise
458
+ // the log entry. (Previously ActionsEngine applied as a side effect and this
459
+ // method merely re-read the already-staged trackers; that side effect is gone, so
460
+ // the application must happen explicitly here. A side-effecting engine that
461
+ // applied internally would instead return EMPTY actions and short-circuit at the
462
+ // guard above.)
463
+ //
464
+ // applyActions() throws if a referenced collection is not registered — the same
465
+ // "Collection not found" the engine's side-effecting apply used to surface. Convert
466
+ // it back into a failure result so execute() keeps its return contract.
467
+ try {
468
+ await this.applyActions(result.actions, transaction.stamp.id);
469
+ }
470
+ catch (error) {
471
+ const engineMs = Date.now() - tEngine;
472
+ log('execute:done trxId=%s engine=%dms apply-failed=true total=%dms', trxId, engineMs, Date.now() - t0);
473
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
474
+ }
475
+ // 2. Build a log entry per collection from the now-staged tracker transforms.
476
+ //
477
+ // NOTE: like commit(), this loop appends a log entry into each collection's
478
+ // tracker and these failure returns do NOT restore that state — so a partially
479
+ // applied engine transaction leaves appended-but-uncommitted entries in the
480
+ // trackers. This is deliberately NOT snapshot/restore-wrapped the way commit()
481
+ // is, because execute()'s asymmetry makes it lower risk: it is not the retryable
482
+ // session.commit() entry point (a failed execute() is not re-driven through the
483
+ // same loop), and its actions were tracked via applyActions() so rollback(stampId)
484
+ // CAN unwind them (unlike commit()'s directly-staged path). If execute() ever
485
+ // becomes retryable, mirror the commit() snapshot/restore fix here.
323
486
  const tApply = Date.now();
324
487
  const collectionTransforms = new Map();
325
488
  const criticalBlocks = new Map();
@@ -334,13 +497,8 @@ export class TransactionCoordinator {
334
497
  criticalBlocks.set(collectionActions.collectionId, applyResult.logTailBlockId);
335
498
  actionResults.set(collectionActions.collectionId, applyResult.results);
336
499
  }
337
- // 3. Compute operations hash for validation
338
- const allOperations = Array.from(collectionTransforms.entries()).flatMap(([collectionId, transforms]) => [
339
- ...Object.entries(transforms.inserts ?? {}).map(([blockId, block]) => ({ type: 'insert', collectionId, blockId, block })),
340
- ...Object.entries(transforms.updates ?? {}).map(([blockId, operations]) => ({ type: 'update', collectionId, blockId, operations })),
341
- ...(transforms.deletes ?? []).map(blockId => ({ type: 'delete', collectionId, blockId }))
342
- ]);
343
- const operationsHash = await this.hashOperations(allOperations);
500
+ // 3. Compute operations hash for validation (order-independent; see commit()).
501
+ const operationsHash = await hashOperations(collectOperations(collectionTransforms));
344
502
  const applyMs = Date.now() - tApply;
345
503
  // 4. Coordinate (GATHER if multi-collection)
346
504
  const tCoord = Date.now();
@@ -348,21 +506,33 @@ export class TransactionCoordinator {
348
506
  const coordMs = Date.now() - tCoord;
349
507
  if (!coordResult.success) {
350
508
  log('execute:done trxId=%s engine=%dms apply=%dms coordinate=%dms success=false total=%dms', trxId, engineMs, applyMs, coordMs, Date.now() - t0);
351
- return coordResult;
509
+ // Stop lying to the caller about a partial commit: if some collections durably
510
+ // committed, surface that set. execute() is not snapshot/restore-wrapped (see the
511
+ // note above), but the committed subset must still get the success-path local
512
+ // treatment (recordCommitted + tracker.reset, as on the success path below) so its
513
+ // trackers aren't left mis-tracking already-durable state.
514
+ const committed = coordResult.committedCollections ?? new Set();
515
+ if (committed.size > 0) {
516
+ for (const collectionActions of result.actions) {
517
+ const collection = this.collections.get(collectionActions.collectionId);
518
+ if (collection && committed.has(collectionActions.collectionId)) {
519
+ collection.recordCommitted(transaction.id);
520
+ collection.tracker.reset();
521
+ }
522
+ }
523
+ }
524
+ return {
525
+ success: false,
526
+ error: coordResult.error,
527
+ committedCollections: committed.size > 0 ? [...committed] : undefined,
528
+ failedCollections: coordResult.failedCollections ? [...coordResult.failedCollections] : undefined,
529
+ };
352
530
  }
353
531
  // 5. Update actionContext and reset trackers after successful commit
354
532
  for (const collectionActions of result.actions) {
355
533
  const collection = this.collections.get(collectionActions.collectionId);
356
534
  if (collection) {
357
- const newRev = (collection['source'].actionContext?.rev ?? 0) + 1;
358
- const actionId = transaction.id;
359
- collection['source'].actionContext = {
360
- committed: [
361
- ...(collection['source'].actionContext?.committed ?? []),
362
- { actionId, rev: newRev }
363
- ],
364
- rev: newRev,
365
- };
535
+ collection.recordCommitted(transaction.id);
366
536
  collection.tracker.reset();
367
537
  }
368
538
  }
@@ -390,7 +560,7 @@ export class TransactionCoordinator {
390
560
  };
391
561
  }
392
562
  // At this point, actions have already been executed through collection.act()
393
- // when they were added to the TransactionContext. The collection's tracker
563
+ // (via the engine or the vtab's staging path). The collection's tracker
394
564
  // already has the transforms, and the actions are in the pending buffer.
395
565
  // Get transforms from the collection's tracker
396
566
  const transforms = collection.tracker.transforms;
@@ -404,7 +574,7 @@ export class TransactionCoordinator {
404
574
  }
405
575
  // Generate action ID from transaction ID
406
576
  const actionId = transaction.id;
407
- const newRev = (collection['source'].actionContext?.rev ?? 0) + 1;
577
+ const newRev = collection.getNextRev();
408
578
  // Add actions to log (this updates the tracker with log block changes).
409
579
  // Persist the transaction's read set on the entry so a later invalidation cascade can
410
580
  // discover this action's read-dependents (see ActionEntry.reads). The whole transaction's
@@ -452,7 +622,16 @@ export class TransactionCoordinator {
452
622
  // Targeted cancel: only cancel collections that are still pending (not already committed)
453
623
  await this.cancelPhase(transaction.id, pendResult.pendedBlockIds, commitResult.committedCollections);
454
624
  log('trx:phases trxId=%s gather=%dms pend=%dms commit=%dms (failed) total=%dms', trxId, gatherMs, pendMs, commitMs, Date.now() - t0);
455
- return { success: false, error: commitResult.error };
625
+ // Surface the committed/failed partition so commit()/execute() can report which
626
+ // collections durably landed. A non-empty committedCollections is a PARTIAL commit:
627
+ // those collections cannot be rolled back and the caller must reconcile.
628
+ return {
629
+ success: false,
630
+ error: commitResult.error,
631
+ committedCollections: commitResult.committedCollections,
632
+ failedCollections: commitResult.failedCollections,
633
+ staleLoss: commitResult.staleLoss,
634
+ };
456
635
  }
457
636
  // 4. PROPAGATE and CHECKPOINT phases are handled by clusters automatically
458
637
  // (as per user's note: "managed by each cluster, the client doesn't have to worry about them")
@@ -480,12 +659,15 @@ export class TransactionCoordinator {
480
659
  // Query each critical cluster for their nominees and merge into supercluster
481
660
  const nomineePromises = criticalBlockIds.map(blockId => this.transactor.queryClusterNominees(blockId));
482
661
  const results = await Promise.all(nomineePromises);
483
- // Merge all nominees into a single set
484
- const supercluster = results.reduce((acc, result) => {
485
- result.nominees.forEach(nominee => acc.add(nominee));
662
+ // Merge all nominees into a single set, deduped by peer identity. Each
663
+ // queryClusterNominees builds a fresh PeerId object per call (peerIdFromString),
664
+ // so a Set keyed by object reference would keep the same physical peer twice when
665
+ // it nominates for two critical clusters. Key by toString() to collapse duplicates.
666
+ const byId = results.reduce((acc, result) => {
667
+ result.nominees.forEach(nominee => acc.set(nominee.toString(), nominee));
486
668
  return acc;
487
- }, new Set());
488
- return supercluster;
669
+ }, new Map());
670
+ return new Set(byId.values());
489
671
  }
490
672
  /**
491
673
  * PEND phase: Distribute transaction to all affected block clusters.
@@ -499,44 +681,81 @@ export class TransactionCoordinator {
499
681
  if (collectionTransforms.size === 0) {
500
682
  return { success: false, error: 'No transforms to pend' };
501
683
  }
502
- const pendedBlockIds = new Map();
503
684
  const actionId = transaction.id;
504
685
  const nominees = superclusterNominees ? Array.from(superclusterNominees) : undefined;
505
- // Pend each collection's transforms
506
- for (const [collectionId, transforms] of collectionTransforms.entries()) {
507
- const collection = this.collections.get(collectionId);
508
- if (!collection) {
509
- return { success: false, error: `Collection not found: ${collectionId}` };
686
+ // Fan out the independent per-collection pends concurrently. Each settles to a
687
+ // { collectionId, blockIds } on success, or rejects with the per-collection reason.
688
+ // NOTE: unbounded fan-out — one concurrent coordinator round-trip per collection.
689
+ // Transactions touch few collections today; if one ever spans very many, bound this
690
+ // with a concurrency limiter so peak in-flight round-trips stays sane. Same for commitPhase.
691
+ const outcomes = await Promise.allSettled(Array.from(collectionTransforms.entries()).map(([collectionId, transforms]) => this.pendCollection(transaction, operationsHash, collectionId, transforms, actionId, nominees)));
692
+ // Partition settled results: every collection that DID pend (keyed with its block
693
+ // ids), plus the first failure reason if any collection failed.
694
+ const pendedBlockIds = new Map();
695
+ let failure;
696
+ // Classify across ALL failures (mirroring commitPhase, and independent of iteration order):
697
+ // the pend is a retryable clean stale loss only if at least one failure was a conflicting pend
698
+ // (PendRejectedError.conflict) AND none was a hard failure. A single hard failure (storage/
699
+ // policy rejection, or a thrown/unavailable transactor) will not clear on a re-read, so
700
+ // re-driving it would just burn the retry budget — fail fast instead.
701
+ let anyConflict = false;
702
+ let anyHard = false;
703
+ for (const outcome of outcomes) {
704
+ if (outcome.status === 'fulfilled') {
705
+ pendedBlockIds.set(outcome.value.collectionId, outcome.value.blockIds);
510
706
  }
511
- // Get revision from the collection's source
512
- const rev = (collection['source'].actionContext?.rev ?? 0) + 1;
513
- // Create pend request with transaction and operations hash for validation
514
- const pendRequest = {
515
- actionId,
516
- rev,
517
- transforms,
518
- policy: 'r', // Return policy: fail but return pending actions
519
- transaction,
520
- operationsHash,
521
- superclusterNominees: nominees
522
- };
523
- // Pend the transaction
524
- const pendResult = await this.transactor.pend(pendRequest);
525
- if (!pendResult.success) {
526
- // Cancel any already-pended collections before returning
527
- for (const [, pendedBlockIdList] of pendedBlockIds.entries()) {
528
- await this.transactor.cancel({ actionId, blockIds: pendedBlockIdList });
707
+ else {
708
+ if (failure === undefined) {
709
+ failure = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
529
710
  }
530
- return {
531
- success: false,
532
- error: `Pend failed for collection ${collectionId}: ${pendResult.reason}`
533
- };
711
+ if (outcome.reason instanceof PendRejectedError && outcome.reason.conflict)
712
+ anyConflict = true;
713
+ else
714
+ anyHard = true;
534
715
  }
535
- // Store the pended block IDs for commit phase
536
- pendedBlockIds.set(collectionId, pendResult.blockIds);
716
+ }
717
+ if (failure !== undefined) {
718
+ // Any failure aborts the whole pend. With concurrency several collections may
719
+ // have pended in parallel, so cancel EVERY successfully-pended collection — not
720
+ // only those started before the failure. Cancels are best-effort (cancelPhase
721
+ // swallows their errors) so they cannot mask the original pend failure.
722
+ await this.cancelPhase(actionId, pendedBlockIds);
723
+ return { success: false, error: failure, staleLoss: anyConflict && !anyHard };
537
724
  }
538
725
  return { success: true, pendedBlockIds };
539
726
  }
727
+ /**
728
+ * Pend a single collection's transforms. Resolves with the collection id and its
729
+ * pended block ids on success; throws with a per-collection reason on failure so the
730
+ * fan-out in {@link pendPhase} can settle it as a rejection.
731
+ */
732
+ async pendCollection(transaction, operationsHash, collectionId, transforms, actionId, nominees) {
733
+ const collection = this.collections.get(collectionId);
734
+ if (!collection) {
735
+ throw new Error(`Collection not found: ${collectionId}`);
736
+ }
737
+ // Get revision from the collection's source
738
+ const rev = collection.getNextRev();
739
+ // Create pend request with transaction and operations hash for validation
740
+ const pendRequest = {
741
+ actionId,
742
+ rev,
743
+ transforms,
744
+ policy: 'r', // Return policy: fail but return pending actions
745
+ transaction,
746
+ operationsHash,
747
+ superclusterNominees: nominees
748
+ };
749
+ const pendResult = await this.transactor.pend(pendRequest);
750
+ if (!pendResult.success) {
751
+ // A committed `missing` action or a `pending` action on a touched block is an
752
+ // optimistic-concurrency conflict — retryable after a re-read. A bare `reason` is a hard
753
+ // rejection (storage/policy) that re-driving won't fix.
754
+ const conflict = Boolean(pendResult.missing?.length || pendResult.pending?.length);
755
+ throw new PendRejectedError(collectionId, conflict, pendResult.reason);
756
+ }
757
+ return { collectionId, blockIds: pendResult.blockIds };
758
+ }
540
759
  /**
541
760
  * COMMIT phase: Commit to all critical blocks with retry for transient failures.
542
761
  *
@@ -545,60 +764,102 @@ export class TransactionCoordinator {
545
764
  * Returns which collections committed vs failed so the caller can do targeted cancel.
546
765
  */
547
766
  async commitPhase(actionId, criticalBlockIds, pendedBlockIds) {
767
+ // Fan out the independent per-collection commit-with-retry concurrently, then
768
+ // aggregate the committed/failed partition from the settled results.
769
+ const outcomes = await Promise.allSettled(Array.from(pendedBlockIds.entries()).map(([collectionId, blockIds]) => this.commitCollection(actionId, criticalBlockIds, collectionId, blockIds)));
548
770
  const committedCollections = new Set();
549
771
  const failedCollections = new Set();
550
- // Commit each collection's transaction with retry
551
- for (const [collectionId, blockIds] of pendedBlockIds.entries()) {
552
- const collection = this.collections.get(collectionId);
553
- if (!collection) {
554
- failedCollections.add(collectionId);
555
- return {
556
- success: false,
557
- error: `Collection not found: ${collectionId}`,
558
- committedCollections,
559
- failedCollections
560
- };
772
+ const errors = [];
773
+ // Classify failures: a returned stale loss (someone committed a newer rev) is retryable after
774
+ // a re-read; a thrown/transient-exhausted or structural failure is not. staleLoss holds only
775
+ // if EVERY failure was a stale loss — a single hard failure makes the whole attempt not worth
776
+ // re-driving.
777
+ let anyStale = false;
778
+ let anyHard = false;
779
+ for (const outcome of outcomes) {
780
+ if (outcome.status === 'fulfilled') {
781
+ const { collectionId, committed, error, stale } = outcome.value;
782
+ if (committed) {
783
+ committedCollections.add(collectionId);
784
+ }
785
+ else {
786
+ failedCollections.add(collectionId);
787
+ if (error)
788
+ errors.push(error);
789
+ if (stale)
790
+ anyStale = true;
791
+ else
792
+ anyHard = true;
793
+ }
561
794
  }
562
- // Get revision
563
- const rev = (collection['source'].actionContext?.rev ?? 0) + 1;
564
- // Find the critical block (log tail) for this collection
565
- const logTailBlockId = criticalBlockIds.find(blockId => blockIds.includes(blockId));
566
- if (!logTailBlockId) {
567
- failedCollections.add(collectionId);
568
- return {
569
- success: false,
570
- error: `Log tail block not found for collection ${collectionId}`,
571
- committedCollections,
572
- failedCollections
573
- };
795
+ else {
796
+ // commitCollection resolves rather than rejects, but treat any unexpected
797
+ // rejection as a (hard) failure so the partitioned sets stay honest.
798
+ errors.push(outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason));
799
+ anyHard = true;
574
800
  }
575
- // Create commit request
576
- const commitRequest = {
577
- actionId,
578
- blockIds,
579
- tailId: logTailBlockId,
580
- rev
801
+ }
802
+ if (failedCollections.size > 0 || errors.length > 0) {
803
+ return {
804
+ success: false,
805
+ error: errors.join('; ') || 'Commit failed',
806
+ committedCollections,
807
+ failedCollections,
808
+ staleLoss: anyStale && !anyHard,
581
809
  };
582
- // Retry up to 3 attempts for transient failures
583
- let committed = false;
584
- for (let attempt = 0; attempt < 3 && !committed; attempt++) {
810
+ }
811
+ return { success: true, committedCollections, failedCollections };
812
+ }
813
+ /**
814
+ * Commit a single collection's pended blocks, retrying transient failures up to three
815
+ * times (forward recovery). Always resolves — success is carried in the returned
816
+ * `committed` flag — so the fan-out in {@link commitPhase} can aggregate every result.
817
+ */
818
+ async commitCollection(actionId, criticalBlockIds, collectionId, blockIds) {
819
+ const collection = this.collections.get(collectionId);
820
+ if (!collection) {
821
+ return { collectionId, committed: false, error: `Collection not found: ${collectionId}` };
822
+ }
823
+ // Get revision
824
+ const rev = collection.getNextRev();
825
+ // Find the critical block (log tail) for this collection
826
+ const logTailBlockId = criticalBlockIds.find(blockId => blockIds.includes(blockId));
827
+ if (!logTailBlockId) {
828
+ return { collectionId, committed: false, error: `Log tail block not found for collection ${collectionId}` };
829
+ }
830
+ // Create commit request
831
+ const commitRequest = {
832
+ actionId,
833
+ blockIds,
834
+ tailId: logTailBlockId,
835
+ rev
836
+ };
837
+ // Retry ONLY transient/thrown failures (unreachable peers, timeout) — forward recovery.
838
+ // A returned { success:false } is a permanent stale loss (someone committed a newer rev);
839
+ // the identical request can never win, so return immediately without retrying. Either way
840
+ // cancelPhase (run by coordinateTransaction on commitPhase failure) releases the pend
841
+ // exactly once — commit itself no longer self-cancels.
842
+ let lastTransientError;
843
+ for (let attempt = 0; attempt < 3; attempt++) {
844
+ try {
585
845
  const commitResult = await this.transactor.commit(commitRequest);
586
846
  if (commitResult.success) {
587
- committed = true;
588
- committedCollections.add(collectionId);
589
- }
590
- else if (attempt === 2) {
591
- failedCollections.add(collectionId);
592
- return {
593
- success: false,
594
- error: `Commit failed for collection ${collectionId} after 3 attempts`,
595
- committedCollections,
596
- failedCollections
597
- };
847
+ return { collectionId, committed: true };
598
848
  }
849
+ // Permanent stale failure: do not retry here. It IS a clean stale loss, though, so
850
+ // mark it retryable at the coordinator level (after a re-read advances the rev).
851
+ return {
852
+ collectionId,
853
+ committed: false,
854
+ stale: true,
855
+ error: commitResult.reason ?? `Stale commit for collection ${collectionId}`
856
+ };
857
+ }
858
+ catch (e) {
859
+ lastTransientError = e instanceof Error ? e.message : String(e);
599
860
  }
600
861
  }
601
- return { success: true, committedCollections, failedCollections };
862
+ return { collectionId, committed: false, error: `Commit failed for collection ${collectionId} after 3 attempts: ${lastTransientError}` };
602
863
  }
603
864
  /**
604
865
  * CANCEL phase: Cancel pending actions on affected blocks.
@@ -607,11 +868,15 @@ export class TransactionCoordinator {
607
868
  * recomputing from transforms. Optionally skips already-committed collections.
608
869
  */
609
870
  async cancelPhase(actionId, pendedBlockIds, excludeCollections) {
610
- for (const [collectionId, blockIds] of pendedBlockIds.entries()) {
611
- if (excludeCollections?.has(collectionId))
612
- continue;
613
- await this.transactor.cancel({ actionId, blockIds });
614
- }
871
+ // Fan out the per-collection cancels concurrently. Each is best-effort: a cancel
872
+ // fault is logged and swallowed so it cannot mask the pend/commit failure that
873
+ // triggered this sweep, and so one failed cancel does not abort the others.
874
+ const cancels = Array.from(pendedBlockIds.entries())
875
+ .filter(([collectionId]) => !excludeCollections?.has(collectionId))
876
+ .map(([collectionId, blockIds]) => this.transactor.cancel({ actionId, blockIds }).catch(err => {
877
+ log('cancelPhase: best-effort cancel failed collection=%s: %o', collectionId, err);
878
+ }));
879
+ await Promise.all(cancels);
615
880
  }
616
881
  }
617
882
  //# sourceMappingURL=coordinator.js.map