@optimystic/db-p2p 0.25.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/src/cluster/cluster-repo.d.ts +47 -91
  2. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  3. package/dist/src/cluster/cluster-repo.js +138 -200
  4. package/dist/src/cluster/cluster-repo.js.map +1 -1
  5. package/dist/src/cluster/race-resolution.d.ts +89 -0
  6. package/dist/src/cluster/race-resolution.d.ts.map +1 -0
  7. package/dist/src/cluster/race-resolution.js +140 -0
  8. package/dist/src/cluster/race-resolution.js.map +1 -0
  9. package/dist/src/cluster/record-operations.d.ts +25 -0
  10. package/dist/src/cluster/record-operations.d.ts.map +1 -0
  11. package/dist/src/cluster/record-operations.js +56 -0
  12. package/dist/src/cluster/record-operations.js.map +1 -0
  13. package/dist/src/cohort-topic/host.d.ts +133 -51
  14. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  15. package/dist/src/cohort-topic/host.js +290 -83
  16. package/dist/src/cohort-topic/host.js.map +1 -1
  17. package/dist/src/libp2p-node-base.d.ts +7 -4
  18. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  19. package/dist/src/libp2p-node-base.js +5 -5
  20. package/dist/src/libp2p-node-base.js.map +1 -1
  21. package/dist/src/storage/block-latch.d.ts +5 -4
  22. package/dist/src/storage/block-latch.d.ts.map +1 -1
  23. package/dist/src/storage/block-latch.js +5 -4
  24. package/dist/src/storage/block-latch.js.map +1 -1
  25. package/dist/src/storage/block-storage.d.ts +1 -1
  26. package/dist/src/storage/block-storage.d.ts.map +1 -1
  27. package/dist/src/storage/block-storage.js +11 -3
  28. package/dist/src/storage/block-storage.js.map +1 -1
  29. package/dist/src/storage/cached-raw-storage.d.ts +17 -1
  30. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
  31. package/dist/src/storage/cached-raw-storage.js +8 -1
  32. package/dist/src/storage/cached-raw-storage.js.map +1 -1
  33. package/dist/src/storage/cached-store-driver.d.ts +7 -0
  34. package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
  35. package/dist/src/storage/cached-store-driver.js +20 -0
  36. package/dist/src/storage/cached-store-driver.js.map +1 -1
  37. package/dist/src/storage/i-block-storage.d.ts +45 -2
  38. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  39. package/dist/src/storage/i-block-storage.js +29 -0
  40. package/dist/src/storage/i-block-storage.js.map +1 -1
  41. package/dist/src/storage/i-raw-storage.d.ts +16 -0
  42. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  43. package/dist/src/storage/kv-raw-storage.d.ts +5 -1
  44. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
  45. package/dist/src/storage/kv-raw-storage.js +8 -1
  46. package/dist/src/storage/kv-raw-storage.js.map +1 -1
  47. package/dist/src/storage/raw-store-driver.d.ts +15 -0
  48. package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
  49. package/dist/src/storage/shared-cache-pool.d.ts +5 -0
  50. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
  51. package/dist/src/storage/shared-cache-pool.js +6 -1
  52. package/dist/src/storage/shared-cache-pool.js.map +1 -1
  53. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  54. package/dist/src/storage/storage-repo.js +141 -99
  55. package/dist/src/storage/storage-repo.js.map +1 -1
  56. package/dist/src/storage/with-read-cache.d.ts +9 -5
  57. package/dist/src/storage/with-read-cache.d.ts.map +1 -1
  58. package/dist/src/storage/with-read-cache.js +16 -6
  59. package/dist/src/storage/with-read-cache.js.map +1 -1
  60. package/package.json +2 -2
  61. package/{README.md → readme.md} +2 -2
  62. package/src/cluster/cluster-repo.ts +187 -211
  63. package/src/cluster/race-resolution.ts +158 -0
  64. package/src/cluster/record-operations.ts +59 -0
  65. package/src/cohort-topic/host.ts +423 -118
  66. package/src/libp2p-node-base.ts +12 -9
  67. package/src/storage/block-latch.ts +5 -4
  68. package/src/storage/block-storage.ts +11 -3
  69. package/src/storage/cached-raw-storage.ts +21 -1
  70. package/src/storage/cached-store-driver.ts +23 -0
  71. package/src/storage/i-block-storage.ts +47 -2
  72. package/src/storage/i-raw-storage.ts +17 -0
  73. package/src/storage/kv-raw-storage.ts +8 -1
  74. package/src/storage/raw-store-driver.ts +26 -0
  75. package/src/storage/shared-cache-pool.ts +6 -1
  76. package/src/storage/storage-repo.ts +143 -103
  77. package/src/storage/with-read-cache.ts +16 -6
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The deterministic arbiter between two conflicting cluster transactions: which of two writes to a
3
+ * shared block wins. Every function here is a total function of its arguments, with no member state
4
+ * and no effect beyond a debug log — `resolveRace` runs on the vote path, where a throw would cost
5
+ * the member its vote entirely — so the ordering rule can be read and tested on its own (see
6
+ * `test/race-resolution.spec.ts`). The stateful scan that consults them (`findConflict`, which sweeps stale
7
+ * reservations and clears a losing transaction) stays on `ClusterMember` in `cluster-repo.ts`.
8
+ */
9
+ import type { ClusterRecord, RepoMessage } from "@optimystic/db-core";
10
+ import { clampPriority } from "@optimystic/db-core";
11
+ import { getActionId, getAffectedBlockIds } from "./record-operations.js";
12
+ import { createLogger } from "../logger.js";
13
+
14
+ // Same sub-namespace `cluster-repo.ts` logs under: these functions moved out of `ClusterMember`, and
15
+ // their emitted tags (and the `debug` namespace they land in) must stay byte-identical, because
16
+ // several specs capture by namespace and tag substring.
17
+ const log = createLogger('cluster-member')
18
+
19
+ /** Number of *approve* promise votes on a record — the count the commit rule uses. */
20
+ export function approvalCount(record: ClusterRecord): number {
21
+ return Object.values(record.promises).filter(s => s.type === 'approve').length;
22
+ }
23
+
24
+ /**
25
+ * Resolve a race between two conflicting transactions. Total and deterministic, so every honest
26
+ * member computes the identical winner (the Theorem 1 Case-2 premise). Order:
27
+ * 1. more *approve* promise signatures wins (progress monotonicity — see safety note below);
28
+ * 2. equal approval counts → higher aged priority wins (fairness — see {@link recordPriority});
29
+ * 3. still tied → higher message hash wins.
30
+ *
31
+ * The count is APPROVALS, not `promises` keys. `promises` is the vote map — a reject occupies a key
32
+ * there exactly as an approve does — so counting keys would treat a rejection as progress, letting a
33
+ * record that can never commit outrank (and therefore block, via the reservation scan `findConflict`
34
+ * in `cluster-repo.ts`) a fresh rival for the whole staleness window. Approvals is also the count
35
+ * the invariant below actually needs: the commit rule is `approvedPromises >= superMajority`,
36
+ * which never looks at rejections.
37
+ *
38
+ * Approval count is FIRST so this comparison never displaces a transaction that is further along.
39
+ * That restores the pre-priority safety invariant: a member commits purely on promise supermajority
40
+ * (`handleCommitNeeded` signs whenever `approvedPromises >= superMajority`; the commit path has NO
41
+ * conflict re-check), so `resolveRace` is the ONLY arbiter among concurrently-pending conflicts.
42
+ * With approvals-first, once transaction X holds a promise supermajority every conflicting rival Y has
43
+ * strictly fewer approvals — Y can only match X's count by getting the intersecting quorum member to
44
+ * approve it, but that member already holds X at supermajority and `resolveRace(X, Y)` returns
45
+ * `keep-existing` on X's higher count, so it never does. By quorum intersection any Y-supermajority
46
+ * overlaps X's in ≥1 honest member, and that member rejects Y. One winner (docs/correctness.md
47
+ * Theorem 9). Priority-first would break this: it could displace an already-quorum-reached X for a
48
+ * higher-priority Y with fewer approvals, letting BOTH commit (split brain) — the regression fixed by
49
+ * ticket occ-priority-first-breaks-promise-monotonicity.
50
+ *
51
+ * Priority is now a tie-break that runs only at EQUAL approval counts, which is exactly the
52
+ * concurrent-starvation case aging targets (two fresh rivals, 0 promises each, otherwise coin-flipping
53
+ * on the hash). Priority still breaks those ties deterministically, so aging still solves the stated
54
+ * fairness problem in its common case. It only orders two *concurrently-pending* conflicts; it does NOT
55
+ * defer a fresh pend for an absent aged transaction (that residual — sequential sub-window starvation —
56
+ * is the deferred feat-occ-priority-reservation).
57
+ *
58
+ * NOTE: residual-fairness tripwire. Under approvals-first an aged transaction can still lose to a fresh
59
+ * rival that has *legitimately* gathered even one more approval — that is not the pure-coin-flip
60
+ * starvation aging targets (equal counts, priority wins), it is the monotonicity behaviour we WANT (a
61
+ * more-progressed rival is never displaced). If deeper fairness against a genuinely-more-progressed
62
+ * rival is ever needed, it belongs to feat-occ-priority-reservation (reserve/defer at pend time), NOT
63
+ * to this race tie-break.
64
+ *
65
+ * NOTE: Byzantine self-assert is a fairness DoS, not a safety hole. A coordinator can stamp
66
+ * priority == MaxPriority on every transaction; recordPriority clamps to the cap so it cannot
67
+ * exceed it, and priority never influences validity/operationsHash/stale-read checks — and now sits
68
+ * below the approval count, so it can only break equal-count ties it might have ~50% won anyway,
69
+ * degrading to at-worst-status-quo fairness (the same graceful-degradation class as spam under
70
+ * honest-majority). Binding priority to provable age is out of scope (feat-occ-priority-reservation).
71
+ *
72
+ * NOTE: keep priority a self-contained additive message field + this one comparison key so it
73
+ * composes with — does not block — a future HLC/crdt-sync redesign of this same path
74
+ * (design-hot-log-tail-sharding-guidance).
75
+ */
76
+ export function resolveRace(existing: ClusterRecord, incoming: ClusterRecord): 'keep-existing' | 'accept-incoming' {
77
+ // 1. Transaction with more APPROVALS wins — never displace a more-progressed rival (safety, see
78
+ // above). Counting `promises` keys instead would count reject votes as progress: a record holding
79
+ // one rejection would outrank an untouched rival and reserve its blocks for the whole staleness
80
+ // window, and the commit rule this ordering protects (`approvedPromises >= superMajority`) never
81
+ // looks at rejections anyway.
82
+ const existingCount = approvalCount(existing);
83
+ const incomingCount = approvalCount(incoming);
84
+ if (existingCount !== incomingCount) {
85
+ return existingCount > incomingCount ? 'keep-existing' : 'accept-incoming';
86
+ }
87
+
88
+ // 2. Equal approval counts → higher aged priority wins (fairness tie-break).
89
+ const existingPriority = recordPriority(existing);
90
+ const incomingPriority = recordPriority(incoming);
91
+ if (existingPriority !== incomingPriority) {
92
+ return existingPriority > incomingPriority ? 'keep-existing' : 'accept-incoming';
93
+ }
94
+
95
+ // 3. Tie-breaker: higher message hash wins (deterministic).
96
+ return existing.messageHash > incoming.messageHash ? 'keep-existing' : 'accept-incoming';
97
+ }
98
+
99
+ /**
100
+ * Aged advisory priority carried by a record's pend operation, clamped to [0, MaxPriority].
101
+ * The multi-collection path carries it on `pend.validation.transaction.priority`; the single-collection
102
+ * (`Collection.sync`) path carries it as top-level `pend.priority`; a record with neither — a
103
+ * legacy/unversioned coordinator's transaction, or a non-pend operation — is priority 0
104
+ * (backward compatible: such transactions simply never age). Both carriers live inside the signed
105
+ * `message`, so priority is integrity-protected in transit; clamping here bounds a self-asserted
106
+ * out-of-range value to the cap.
107
+ *
108
+ * NOTE: `message` is fixed for a transaction's whole lifecycle (promises/commits accrue in the
109
+ * separate `promises`/`commits` maps, never in `message`), so a transaction keeps its rank through
110
+ * the commit phase — there is no "priority drops to 0 at commit" asymmetry. resolveRace is only
111
+ * consulted at the promise decision (`findConflict` in `cluster-repo.ts`), i.e. between two
112
+ * still-open conflicting transactions, which is exactly the concurrent-contention case priority
113
+ * is meant to order.
114
+ */
115
+ export function recordPriority(record: ClusterRecord): number {
116
+ for (const op of record.message.operations) {
117
+ if ('pend' in op) {
118
+ // Every hop optional: `validation` arrives off the wire inside a signed message whose
119
+ // hash binds its bytes, not its shape, so a malformed pair must yield priority 0 (what
120
+ // clampPriority already does for a missing or Byzantine number) rather than throw out
121
+ // of the vote path — the lost vote this fail-closed pass exists to prevent.
122
+ return clampPriority(op.pend.validation?.transaction?.priority ?? op.pend.priority);
123
+ }
124
+ }
125
+ return 0;
126
+ }
127
+
128
+ /**
129
+ * Whether two messages must serialize against each other: true when they touch a common block AND
130
+ * are not the same action. The same-action escape is what lets a commit follow its own pend — both
131
+ * name every block the action writes, so a bare overlap test would have each transaction blocking
132
+ * its own next phase. Gates whether {@link resolveRace} runs at all.
133
+ */
134
+ export function operationsConflict(ops1: RepoMessage['operations'], ops2: RepoMessage['operations']): boolean {
135
+ // Check if one is a commit for the same action as a pend - these don't conflict
136
+ const actionId1 = getActionId(ops1);
137
+ const actionId2 = getActionId(ops2);
138
+ if (actionId1 && actionId2 && actionId1 === actionId2) {
139
+ // Same action - commit is resolving the pend, not conflicting
140
+ return false;
141
+ }
142
+
143
+ const blocks1 = new Set(getAffectedBlockIds(ops1));
144
+ const blocks2 = new Set(getAffectedBlockIds(ops2));
145
+
146
+ for (const block of Array.from(blocks1)) {
147
+ if (blocks2.has(block)) {
148
+ log('cluster-member:conflict-detected', {
149
+ blocks1: Array.from(blocks1),
150
+ blocks2: Array.from(blocks2),
151
+ conflictingBlock: block
152
+ });
153
+ return true;
154
+ }
155
+ }
156
+
157
+ return false;
158
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Introspection of a `RepoMessage`'s own operations — what a record touches and which action it
3
+ * names. Pure functions of their arguments, with no dependency on cluster member state, so the
4
+ * conflict/race path and the membership admission gate can share one definition of each.
5
+ */
6
+ import type { RepoMessage } from "@optimystic/db-core";
7
+ import { blockIdsForTransforms } from "@optimystic/db-core";
8
+
9
+ /**
10
+ * Every block id the message's own operations name. Two consumers, deliberately sharing one
11
+ * definition: conflict detection (which writes must serialize against each other) and the membership
12
+ * admission gate's binding check in `cluster-repo.ts` (the set a legitimate `coordinatingBlockIds[0]`
13
+ * must come from — the check inside `deriveExpectedClusterView`, which makes a record naming anything
14
+ * else inadmissible). If the two ever disagreed, a coordinator could name a block the record is not
15
+ * judged to touch — so keep exactly one definition of this function and import it, never copy it.
16
+ */
17
+ export function getAffectedBlockIds(operations: RepoMessage['operations']): string[] {
18
+ const blockIds = new Set<string>();
19
+
20
+ for (const operation of operations) {
21
+ if ('get' in operation) {
22
+ operation.get.blockIds.forEach(id => blockIds.add(id));
23
+ } else if ('pend' in operation) {
24
+ // Use blockIdsForTransforms to correctly extract block IDs from Transforms structure
25
+ blockIdsForTransforms(operation.pend.transforms).forEach(id => blockIds.add(id));
26
+ } else if ('commit' in operation) {
27
+ operation.commit.blockIds.forEach(id => blockIds.add(id));
28
+ } else if ('cancel' in operation) {
29
+ operation.cancel.actionRef.blockIds.forEach(id => blockIds.add(id));
30
+ } else if ('invalidate' in operation) {
31
+ // The invalidation writes compensating revisions to these blocks; surfacing them lets
32
+ // conflict detection serialize a concurrent commit racing the invalidation on a block.
33
+ operation.invalidate.blockIds.forEach(id => blockIds.add(id));
34
+ }
35
+ }
36
+
37
+ return Array.from(blockIds);
38
+ }
39
+
40
+ /**
41
+ * The action id the message names, or `undefined` if it names none. `pend`, `commit` and `cancel`
42
+ * each carry one; `get` and `invalidate` do not — an `invalidate` deliberately does NOT surface its
43
+ * `invalidatedActionId`, which names the action being reversed rather than this message's own, and
44
+ * would otherwise tell `operationsConflict` that an invalidation and the pend it reverses are "the
45
+ * same action" and need not serialize. `RepoMessage.operations` is a one-element tuple, so the scan
46
+ * is a formality; it returns on the first operation that carries an id either way.
47
+ */
48
+ export function getActionId(operations: RepoMessage['operations']): string | undefined {
49
+ for (const operation of operations) {
50
+ if ('pend' in operation) {
51
+ return operation.pend.actionId;
52
+ } else if ('commit' in operation) {
53
+ return operation.commit.actionId;
54
+ } else if ('cancel' in operation) {
55
+ return operation.cancel.actionRef.actionId;
56
+ }
57
+ }
58
+ return undefined;
59
+ }