@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.
- package/dist/src/cluster/cluster-repo.d.ts +47 -91
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +138 -200
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/cluster/race-resolution.d.ts +89 -0
- package/dist/src/cluster/race-resolution.d.ts.map +1 -0
- package/dist/src/cluster/race-resolution.js +140 -0
- package/dist/src/cluster/race-resolution.js.map +1 -0
- package/dist/src/cluster/record-operations.d.ts +25 -0
- package/dist/src/cluster/record-operations.d.ts.map +1 -0
- package/dist/src/cluster/record-operations.js +56 -0
- package/dist/src/cluster/record-operations.js.map +1 -0
- package/dist/src/cohort-topic/host.d.ts +133 -51
- package/dist/src/cohort-topic/host.d.ts.map +1 -1
- package/dist/src/cohort-topic/host.js +290 -83
- package/dist/src/cohort-topic/host.js.map +1 -1
- package/dist/src/libp2p-node-base.d.ts +7 -4
- package/dist/src/libp2p-node-base.d.ts.map +1 -1
- package/dist/src/libp2p-node-base.js +5 -5
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/storage/block-latch.d.ts +5 -4
- package/dist/src/storage/block-latch.d.ts.map +1 -1
- package/dist/src/storage/block-latch.js +5 -4
- package/dist/src/storage/block-latch.js.map +1 -1
- package/dist/src/storage/block-storage.d.ts +1 -1
- package/dist/src/storage/block-storage.d.ts.map +1 -1
- package/dist/src/storage/block-storage.js +11 -3
- package/dist/src/storage/block-storage.js.map +1 -1
- package/dist/src/storage/cached-raw-storage.d.ts +17 -1
- package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/cached-raw-storage.js +8 -1
- package/dist/src/storage/cached-raw-storage.js.map +1 -1
- package/dist/src/storage/cached-store-driver.d.ts +7 -0
- package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
- package/dist/src/storage/cached-store-driver.js +20 -0
- package/dist/src/storage/cached-store-driver.js.map +1 -1
- package/dist/src/storage/i-block-storage.d.ts +45 -2
- package/dist/src/storage/i-block-storage.d.ts.map +1 -1
- package/dist/src/storage/i-block-storage.js +29 -0
- package/dist/src/storage/i-block-storage.js.map +1 -1
- package/dist/src/storage/i-raw-storage.d.ts +16 -0
- package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/kv-raw-storage.d.ts +5 -1
- package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/kv-raw-storage.js +8 -1
- package/dist/src/storage/kv-raw-storage.js.map +1 -1
- package/dist/src/storage/raw-store-driver.d.ts +15 -0
- package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
- package/dist/src/storage/shared-cache-pool.d.ts +5 -0
- package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
- package/dist/src/storage/shared-cache-pool.js +6 -1
- package/dist/src/storage/shared-cache-pool.js.map +1 -1
- package/dist/src/storage/storage-repo.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.js +141 -99
- package/dist/src/storage/storage-repo.js.map +1 -1
- package/dist/src/storage/with-read-cache.d.ts +9 -5
- package/dist/src/storage/with-read-cache.d.ts.map +1 -1
- package/dist/src/storage/with-read-cache.js +16 -6
- package/dist/src/storage/with-read-cache.js.map +1 -1
- package/package.json +2 -2
- package/{README.md → readme.md} +2 -2
- package/src/cluster/cluster-repo.ts +187 -211
- package/src/cluster/race-resolution.ts +158 -0
- package/src/cluster/record-operations.ts +59 -0
- package/src/cohort-topic/host.ts +423 -118
- package/src/libp2p-node-base.ts +12 -9
- package/src/storage/block-latch.ts +5 -4
- package/src/storage/block-storage.ts +11 -3
- package/src/storage/cached-raw-storage.ts +21 -1
- package/src/storage/cached-store-driver.ts +23 -0
- package/src/storage/i-block-storage.ts +47 -2
- package/src/storage/i-raw-storage.ts +17 -0
- package/src/storage/kv-raw-storage.ts +8 -1
- package/src/storage/raw-store-driver.ts +26 -0
- package/src/storage/shared-cache-pool.ts +6 -1
- package/src/storage/storage-repo.ts +143 -103
- 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
|
+
}
|