@optimystic/db-p2p 0.11.1 → 0.11.3
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/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +31 -8
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/storage/block-storage.d.ts +4 -0
- package/dist/src/storage/block-storage.d.ts.map +1 -1
- package/dist/src/storage/block-storage.js +35 -3
- package/dist/src/storage/block-storage.js.map +1 -1
- package/dist/src/storage/i-block-storage.d.ts +24 -1
- package/dist/src/storage/i-block-storage.d.ts.map +1 -1
- package/dist/src/storage/memory-storage.d.ts +17 -0
- package/dist/src/storage/memory-storage.d.ts.map +1 -1
- package/dist/src/storage/memory-storage.js +20 -2
- package/dist/src/storage/memory-storage.js.map +1 -1
- package/dist/src/storage/storage-repo.d.ts +8 -0
- package/dist/src/storage/storage-repo.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.js +44 -13
- package/dist/src/storage/storage-repo.js.map +1 -1
- package/dist/src/testing/index.d.ts +2 -0
- package/dist/src/testing/index.d.ts.map +1 -0
- package/dist/src/testing/index.js +2 -0
- package/dist/src/testing/index.js.map +1 -0
- package/dist/src/testing/mesh-harness.d.ts +60 -0
- package/dist/src/testing/mesh-harness.d.ts.map +1 -0
- package/dist/src/testing/mesh-harness.js +220 -0
- package/dist/src/testing/mesh-harness.js.map +1 -0
- package/package.json +8 -3
- package/src/repo/coordinator-repo.ts +31 -9
- package/src/storage/block-storage.ts +38 -3
- package/src/storage/i-block-storage.ts +22 -1
- package/src/storage/memory-storage.ts +20 -2
- package/src/storage/storage-repo.ts +46 -13
- package/src/testing/index.ts +1 -0
- package/src/testing/mesh-harness.ts +294 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { PeerId, PrivateKey } from '@libp2p/interface';
|
|
2
|
+
import type { IKeyNetwork, ITransactor } from '@optimystic/db-core';
|
|
3
|
+
import { ClusterMember } from '../cluster/cluster-repo.js';
|
|
4
|
+
import { StorageRepo } from '../storage/storage-repo.js';
|
|
5
|
+
import type { IRawStorage } from '../storage/i-raw-storage.js';
|
|
6
|
+
import type { CoordinatorRepo } from '../repo/coordinator-repo.js';
|
|
7
|
+
export interface MeshNode {
|
|
8
|
+
peerId: PeerId;
|
|
9
|
+
privateKey: PrivateKey;
|
|
10
|
+
storageRepo: StorageRepo;
|
|
11
|
+
clusterMember: ClusterMember;
|
|
12
|
+
coordinatorRepo: CoordinatorRepo;
|
|
13
|
+
}
|
|
14
|
+
export interface MeshOptions {
|
|
15
|
+
responsibilityK: number;
|
|
16
|
+
clusterSize?: number;
|
|
17
|
+
superMajorityThreshold?: number;
|
|
18
|
+
allowClusterDownsize?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Optional per-node raw-storage factory. Invoked once per node (indexed from 0)
|
|
21
|
+
* to supply the IRawStorage that backs StorageRepo. If omitted, each node gets
|
|
22
|
+
* a fresh `MemoryRawStorage`. Used by fault-injection tests to wrap the store
|
|
23
|
+
* with a crashing proxy, or by restart tests to rebuild over preserved state.
|
|
24
|
+
*/
|
|
25
|
+
rawStorageFactory?: (index: number) => IRawStorage;
|
|
26
|
+
}
|
|
27
|
+
export interface MeshFailureConfig {
|
|
28
|
+
/** Peers that should fail on cluster update (simulate unreachable) */
|
|
29
|
+
failingPeers?: Set<string>;
|
|
30
|
+
/** Make findCluster return empty (simulate DHT failure) */
|
|
31
|
+
findClusterFails?: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface Mesh {
|
|
34
|
+
nodes: MeshNode[];
|
|
35
|
+
failures: MeshFailureConfig;
|
|
36
|
+
keyNetwork: IKeyNetwork;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Creates N interconnected mesh nodes with real components and mock transport.
|
|
40
|
+
* ClusterClient calls route directly to target ClusterMember instances.
|
|
41
|
+
*/
|
|
42
|
+
export declare function createMesh(nodeCount: number, options: MeshOptions): Promise<Mesh>;
|
|
43
|
+
export interface BuildTransactorOptions {
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
abortOrCancelTimeoutMs?: number;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Builds a NetworkTransactor over a mesh. All nodes share the same mock
|
|
49
|
+
* infrastructure so a single transactor routes to every peer via `getRepo`.
|
|
50
|
+
* Suitable for solo-mesh tests; for multi-node tests prefer
|
|
51
|
+
* `buildNetworkTransactors` to label "which node is driving".
|
|
52
|
+
*/
|
|
53
|
+
export declare const buildNetworkTransactor: (mesh: Mesh, options?: BuildTransactorOptions) => ITransactor;
|
|
54
|
+
/**
|
|
55
|
+
* Builds one NetworkTransactor per mesh node, keyed by peer-id string. Each
|
|
56
|
+
* transactor shares the mesh's key network and peer→repo map — the separate
|
|
57
|
+
* instances exist so tests can semantically say "driven by node A".
|
|
58
|
+
*/
|
|
59
|
+
export declare const buildNetworkTransactors: (mesh: Mesh, options?: BuildTransactorOptions) => Map<string, ITransactor>;
|
|
60
|
+
//# sourceMappingURL=mesh-harness.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mesh-harness.d.ts","sourceRoot":"","sources":["../../../src/testing/mesh-harness.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAC5D,OAAO,KAAK,EAAE,WAAW,EAA4F,WAAW,EAAsB,MAAM,qBAAqB,CAAC;AAMlL,OAAO,EAAE,aAAa,EAAiB,MAAM,4BAA4B,CAAC;AAC1E,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAGzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,6BAA6B,CAAC;AAE/D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAC;AAInE,MAAM,WAAW,QAAQ;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,UAAU,CAAC;IACvB,WAAW,EAAE,WAAW,CAAC;IACzB,aAAa,EAAE,aAAa,CAAC;IAC7B,eAAe,EAAE,eAAe,CAAC;CACjC;AAED,MAAM,WAAW,WAAW;IAC3B,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,WAAW,CAAC;CACnD;AAED,MAAM,WAAW,iBAAiB;IACjC,sEAAsE;IACtE,YAAY,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC3B,2DAA2D;IAC3D,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC3B;AA2DD,MAAM,WAAW,IAAI;IACpB,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,QAAQ,EAAE,iBAAiB,CAAC;IAC5B,UAAU,EAAE,WAAW,CAAC;CACxB;AAED;;;GAGG;AACH,wBAAsB,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CA2IvF;AAED,MAAM,WAAW,sBAAsB;IACtC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,sBAAsB,CAAC,EAAE,MAAM,CAAC;CAChC;AAED;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,GAAI,MAAM,IAAI,EAAE,UAAS,sBAA2B,KAAG,WAezF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,GAAI,MAAM,IAAI,EAAE,UAAS,sBAA2B,KAAG,GAAG,CAAC,MAAM,EAAE,WAAW,CAMjH,CAAC"}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { NetworkTransactor } from '@optimystic/db-core';
|
|
2
|
+
import { peerIdFromPrivateKey } from '@libp2p/peer-id';
|
|
3
|
+
import { generateKeyPair } from '@libp2p/crypto/keys';
|
|
4
|
+
import { clusterMember } from '../cluster/cluster-repo.js';
|
|
5
|
+
import { StorageRepo } from '../storage/storage-repo.js';
|
|
6
|
+
import { MemoryRawStorage } from '../storage/memory-storage.js';
|
|
7
|
+
import { BlockStorage } from '../storage/block-storage.js';
|
|
8
|
+
import { coordinatorRepo } from '../repo/coordinator-repo.js';
|
|
9
|
+
import { sortPeersByDistance } from '../routing/responsibility.js';
|
|
10
|
+
import { toString as u8ToString } from 'uint8arrays';
|
|
11
|
+
class MockPeerNetwork {
|
|
12
|
+
async connect(_peerId, _protocol) {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Mock IKeyNetwork that returns peers based on XOR distance.
|
|
18
|
+
* With responsibilityK >= nodeCount, all nodes are returned.
|
|
19
|
+
* Otherwise, K-nearest by XOR distance are returned.
|
|
20
|
+
*/
|
|
21
|
+
class MockMeshKeyNetwork {
|
|
22
|
+
nodes;
|
|
23
|
+
responsibilityK;
|
|
24
|
+
failures;
|
|
25
|
+
constructor(nodes, responsibilityK, failures = {}) {
|
|
26
|
+
this.nodes = nodes;
|
|
27
|
+
this.responsibilityK = responsibilityK;
|
|
28
|
+
this.failures = failures;
|
|
29
|
+
}
|
|
30
|
+
async findCoordinator(key, options) {
|
|
31
|
+
const excluded = new Set((options?.excludedPeers ?? []).map(p => p.toString()));
|
|
32
|
+
const sorted = this.sortedByDistance(key);
|
|
33
|
+
const pick = sorted.find(n => !excluded.has(n.peerId.toString()));
|
|
34
|
+
if (!pick) {
|
|
35
|
+
throw new Error('No coordinator available for key (all candidates excluded)');
|
|
36
|
+
}
|
|
37
|
+
return pick.peerId;
|
|
38
|
+
}
|
|
39
|
+
async findCluster(key) {
|
|
40
|
+
if (this.failures.findClusterFails) {
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
const sorted = this.sortedByDistance(key);
|
|
44
|
+
const k = Math.min(this.responsibilityK, sorted.length);
|
|
45
|
+
const selected = sorted.slice(0, k);
|
|
46
|
+
const peers = {};
|
|
47
|
+
for (const node of selected) {
|
|
48
|
+
peers[node.peerId.toString()] = {
|
|
49
|
+
multiaddrs: ['/ip4/127.0.0.1/tcp/8000'],
|
|
50
|
+
publicKey: u8ToString(node.peerId.publicKey.raw, 'base64url')
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return peers;
|
|
54
|
+
}
|
|
55
|
+
sortedByDistance(key) {
|
|
56
|
+
const knownPeers = this.nodes.map(n => ({
|
|
57
|
+
id: n.peerId,
|
|
58
|
+
addrs: ['/ip4/127.0.0.1/tcp/8000']
|
|
59
|
+
}));
|
|
60
|
+
const sorted = sortPeersByDistance(knownPeers, key);
|
|
61
|
+
return sorted.map(kp => this.nodes.find(n => n.peerId.equals(kp.id)));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Creates N interconnected mesh nodes with real components and mock transport.
|
|
66
|
+
* ClusterClient calls route directly to target ClusterMember instances.
|
|
67
|
+
*/
|
|
68
|
+
export async function createMesh(nodeCount, options) {
|
|
69
|
+
const failures = {};
|
|
70
|
+
// Generate key pairs for all nodes
|
|
71
|
+
const keyPairs = await Promise.all(Array.from({ length: nodeCount }, async () => {
|
|
72
|
+
const privateKey = await generateKeyPair('Ed25519');
|
|
73
|
+
return { peerId: peerIdFromPrivateKey(privateKey), privateKey };
|
|
74
|
+
}));
|
|
75
|
+
// Build nodes array (partially — coordinatorRepo added after keyNetwork is ready)
|
|
76
|
+
const nodes = [];
|
|
77
|
+
const peerNetwork = new MockPeerNetwork();
|
|
78
|
+
// Map peerId → rawStorage for data sync simulation in clusterLatestCallback
|
|
79
|
+
const rawStorages = new Map();
|
|
80
|
+
// Phase 1: create storage + cluster members
|
|
81
|
+
let nodeIndex = 0;
|
|
82
|
+
for (const { peerId, privateKey } of keyPairs) {
|
|
83
|
+
const rawStorage = options.rawStorageFactory
|
|
84
|
+
? options.rawStorageFactory(nodeIndex)
|
|
85
|
+
: new MemoryRawStorage();
|
|
86
|
+
rawStorages.set(peerId.toString(), rawStorage);
|
|
87
|
+
nodeIndex++;
|
|
88
|
+
const storageRepo = new StorageRepo((blockId) => new BlockStorage(blockId, rawStorage));
|
|
89
|
+
const consensusConfig = {
|
|
90
|
+
superMajorityThreshold: options.superMajorityThreshold ?? 0.75,
|
|
91
|
+
simpleMajorityThreshold: 0.51,
|
|
92
|
+
minAbsoluteClusterSize: 2,
|
|
93
|
+
allowClusterDownsize: options.allowClusterDownsize ?? true,
|
|
94
|
+
clusterSizeTolerance: 0.5,
|
|
95
|
+
partitionDetectionWindow: 60000
|
|
96
|
+
};
|
|
97
|
+
const member = clusterMember({
|
|
98
|
+
storageRepo,
|
|
99
|
+
peerNetwork,
|
|
100
|
+
peerId,
|
|
101
|
+
privateKey,
|
|
102
|
+
consensusConfig
|
|
103
|
+
});
|
|
104
|
+
nodes.push({
|
|
105
|
+
peerId,
|
|
106
|
+
privateKey,
|
|
107
|
+
storageRepo,
|
|
108
|
+
clusterMember: member,
|
|
109
|
+
coordinatorRepo: undefined // filled in phase 2
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
// Phase 2: create key network and coordinator repos (needs all nodes for routing)
|
|
113
|
+
const keyNetwork = new MockMeshKeyNetwork(nodes, options.responsibilityK, failures);
|
|
114
|
+
const createClusterClient = (targetPeerId) => {
|
|
115
|
+
const target = nodes.find(n => n.peerId.equals(targetPeerId));
|
|
116
|
+
if (!target) {
|
|
117
|
+
throw new Error(`Unknown peer: ${targetPeerId.toString()}`);
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
async update(record) {
|
|
121
|
+
if (failures.failingPeers?.has(targetPeerId.toString())) {
|
|
122
|
+
throw new Error(`Peer ${targetPeerId.toString()} is unreachable`);
|
|
123
|
+
}
|
|
124
|
+
return target.clusterMember.update(record);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
for (const node of nodes) {
|
|
129
|
+
const localRawStorage = rawStorages.get(node.peerId.toString());
|
|
130
|
+
// Per-node callback: queries remote peer and replicates committed data locally
|
|
131
|
+
// (simulates what SyncClient does in production)
|
|
132
|
+
const clusterLatestCallback = async (peerId, blockId, context) => {
|
|
133
|
+
const target = nodes.find(n => n.peerId.equals(peerId));
|
|
134
|
+
if (!target)
|
|
135
|
+
return undefined;
|
|
136
|
+
const result = await target.storageRepo.get({ blockIds: [blockId], context }, { skipClusterFetch: true });
|
|
137
|
+
const entry = result[blockId];
|
|
138
|
+
const latest = entry?.state?.latest;
|
|
139
|
+
// Simulate data sync: replicate committed block data to local storage
|
|
140
|
+
if (latest && entry?.block) {
|
|
141
|
+
const localBlockStorage = new BlockStorage(blockId, localRawStorage);
|
|
142
|
+
const localLatest = await localBlockStorage.getLatest();
|
|
143
|
+
if (!localLatest || localLatest.rev < latest.rev) {
|
|
144
|
+
// Ensure metadata exists
|
|
145
|
+
const meta = await localRawStorage.getMetadata(blockId);
|
|
146
|
+
if (!meta) {
|
|
147
|
+
await localRawStorage.saveMetadata(blockId, { latest: undefined, ranges: [[0]] });
|
|
148
|
+
}
|
|
149
|
+
await localBlockStorage.saveMaterializedBlock(latest.actionId, entry.block);
|
|
150
|
+
await localBlockStorage.saveRevision(latest.rev, latest.actionId);
|
|
151
|
+
await localBlockStorage.setLatest(latest);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return latest;
|
|
155
|
+
};
|
|
156
|
+
// Wrap key network to include self in findCluster (matches real Libp2pKeyPeerNetwork behavior)
|
|
157
|
+
const nodeKeyNetwork = {
|
|
158
|
+
findCoordinator: (key, opts) => keyNetwork.findCoordinator(key, opts),
|
|
159
|
+
async findCluster(key) {
|
|
160
|
+
const peers = await keyNetwork.findCluster(key);
|
|
161
|
+
const selfStr = node.peerId.toString();
|
|
162
|
+
if (!(selfStr in peers)) {
|
|
163
|
+
peers[selfStr] = {
|
|
164
|
+
multiaddrs: ['/ip4/127.0.0.1/tcp/8000'],
|
|
165
|
+
publicKey: u8ToString(node.peerId.publicKey.raw, 'base64url')
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
return peers;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
const factory = coordinatorRepo(nodeKeyNetwork, (peerId) => createClusterClient(peerId), {
|
|
172
|
+
clusterSize: options.clusterSize ?? nodeCount,
|
|
173
|
+
superMajorityThreshold: options.superMajorityThreshold ?? 0.75,
|
|
174
|
+
allowClusterDownsize: options.allowClusterDownsize ?? true
|
|
175
|
+
});
|
|
176
|
+
node.coordinatorRepo = factory({
|
|
177
|
+
storageRepo: node.storageRepo,
|
|
178
|
+
localCluster: node.clusterMember,
|
|
179
|
+
localPeerId: node.peerId,
|
|
180
|
+
clusterLatestCallback
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return { nodes, failures, keyNetwork };
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Builds a NetworkTransactor over a mesh. All nodes share the same mock
|
|
187
|
+
* infrastructure so a single transactor routes to every peer via `getRepo`.
|
|
188
|
+
* Suitable for solo-mesh tests; for multi-node tests prefer
|
|
189
|
+
* `buildNetworkTransactors` to label "which node is driving".
|
|
190
|
+
*/
|
|
191
|
+
export const buildNetworkTransactor = (mesh, options = {}) => {
|
|
192
|
+
const repoByPeer = new Map();
|
|
193
|
+
for (const node of mesh.nodes) {
|
|
194
|
+
repoByPeer.set(node.peerId.toString(), node.coordinatorRepo);
|
|
195
|
+
}
|
|
196
|
+
return new NetworkTransactor({
|
|
197
|
+
timeoutMs: options.timeoutMs ?? 5_000,
|
|
198
|
+
abortOrCancelTimeoutMs: options.abortOrCancelTimeoutMs ?? 5_000,
|
|
199
|
+
keyNetwork: mesh.keyNetwork,
|
|
200
|
+
getRepo: (peerId) => {
|
|
201
|
+
const repo = repoByPeer.get(peerId.toString());
|
|
202
|
+
if (!repo)
|
|
203
|
+
throw new Error(`Unknown peer ${peerId.toString()}`);
|
|
204
|
+
return repo;
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
};
|
|
208
|
+
/**
|
|
209
|
+
* Builds one NetworkTransactor per mesh node, keyed by peer-id string. Each
|
|
210
|
+
* transactor shares the mesh's key network and peer→repo map — the separate
|
|
211
|
+
* instances exist so tests can semantically say "driven by node A".
|
|
212
|
+
*/
|
|
213
|
+
export const buildNetworkTransactors = (mesh, options = {}) => {
|
|
214
|
+
const transactors = new Map();
|
|
215
|
+
for (const node of mesh.nodes) {
|
|
216
|
+
transactors.set(node.peerId.toString(), buildNetworkTransactor(mesh, options));
|
|
217
|
+
}
|
|
218
|
+
return transactors;
|
|
219
|
+
};
|
|
220
|
+
//# sourceMappingURL=mesh-harness.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mesh-harness.js","sourceRoot":"","sources":["../../../src/testing/mesh-harness.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@optimystic/db-p2p",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "P2P database functionality for Optimystic",
|
|
6
6
|
"main": "dist/src/index.js",
|
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
"./rn": {
|
|
24
24
|
"types": "./dist/src/rn.d.ts",
|
|
25
25
|
"import": "./dist/src/rn.js"
|
|
26
|
+
},
|
|
27
|
+
"./testing": {
|
|
28
|
+
"types": "./dist/src/testing/index.d.ts",
|
|
29
|
+
"import": "./dist/src/testing/index.js"
|
|
26
30
|
}
|
|
27
31
|
},
|
|
28
32
|
"repository": {
|
|
@@ -46,7 +50,8 @@
|
|
|
46
50
|
"clean": "rimraf dist",
|
|
47
51
|
"build": "tsc",
|
|
48
52
|
"test": "node --import ./register.mjs node_modules/mocha/bin/mocha.js \"test/**/*.spec.ts\" --colors --reporter min",
|
|
49
|
-
"test:verbose": "node --import ./register.mjs node_modules/mocha/bin/mocha.js \"test/**/*.spec.ts\" --colors --reporter spec"
|
|
53
|
+
"test:verbose": "node --import ./register.mjs node_modules/mocha/bin/mocha.js \"test/**/*.spec.ts\" --colors --reporter spec",
|
|
54
|
+
"test:integration": "OPTIMYSTIC_INTEGRATION=1 node --import ./register.mjs node_modules/mocha/bin/mocha.js \"test/**/*.integration.spec.ts\" --colors --reporter spec"
|
|
50
55
|
},
|
|
51
56
|
"devDependencies": {
|
|
52
57
|
"@types/chai": "^5.2.3",
|
|
@@ -74,7 +79,7 @@
|
|
|
74
79
|
"@libp2p/tcp": "^11.0.10",
|
|
75
80
|
"@libp2p/websockets": "^10.1.3",
|
|
76
81
|
"@multiformats/multiaddr": "^13.0.1",
|
|
77
|
-
"@optimystic/db-core": "^0.11.
|
|
82
|
+
"@optimystic/db-core": "^0.11.3",
|
|
78
83
|
"async-mutex": "^0.5.0",
|
|
79
84
|
"debug": "^4.4.3",
|
|
80
85
|
"it-all": "^3.0.9",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { PendRequest, ActionBlocks, IRepo, MessageOptions, CommitResult, GetBlockResults, PendResult, BlockGets, CommitRequest, RepoMessage, IKeyNetwork, ICluster, ClusterConsensusConfig, BlockId, ActionRev, ActionContext } from "@optimystic/db-core";
|
|
2
|
-
import { LruMap } from "@optimystic/db-core";
|
|
1
|
+
import type { PendRequest, ActionBlocks, IRepo, MessageOptions, CommitResult, GetBlockResults, PendResult, BlockGets, CommitRequest, RepoMessage, IKeyNetwork, ICluster, ClusterConsensusConfig, BlockId, ActionRev, ActionContext, ClusterRecord } from "@optimystic/db-core";
|
|
2
|
+
import { LruMap, blockIdsForTransforms } from "@optimystic/db-core";
|
|
3
3
|
import { ClusterCoordinator } from "./cluster-coordinator.js";
|
|
4
4
|
import type { ClusterClient } from "../cluster/client.js";
|
|
5
5
|
import type { PeerId } from "@libp2p/interface";
|
|
@@ -237,7 +237,7 @@ export class CoordinatorRepo implements IRepo {
|
|
|
237
237
|
}
|
|
238
238
|
|
|
239
239
|
async pend(request: PendRequest, options?: MessageOptions): Promise<PendResult> {
|
|
240
|
-
const allBlockIds =
|
|
240
|
+
const allBlockIds = blockIdsForTransforms(request.transforms);
|
|
241
241
|
await this.verifyResponsibility(allBlockIds);
|
|
242
242
|
const coordinatingBlockIds = (options as any)?.coordinatingBlockIds ?? allBlockIds;
|
|
243
243
|
|
|
@@ -273,7 +273,7 @@ export class CoordinatorRepo implements IRepo {
|
|
|
273
273
|
return {
|
|
274
274
|
success: true,
|
|
275
275
|
pending: [],
|
|
276
|
-
blockIds:
|
|
276
|
+
blockIds: allBlockIds
|
|
277
277
|
};
|
|
278
278
|
} catch (error) {
|
|
279
279
|
log('coordinator-repo:pend-error', { actionId: request.actionId, error: (error as Error).message });
|
|
@@ -326,16 +326,38 @@ export class CoordinatorRepo implements IRepo {
|
|
|
326
326
|
};
|
|
327
327
|
|
|
328
328
|
try {
|
|
329
|
-
const { localExecuted } = await this.coordinator.executeClusterTransaction(blockIds[0]!, message, options);
|
|
330
|
-
|
|
331
|
-
|
|
329
|
+
const { record, localExecuted } = await this.coordinator.executeClusterTransaction(blockIds[0]!, message, options);
|
|
330
|
+
if (localExecuted) {
|
|
331
|
+
return { success: true };
|
|
332
|
+
}
|
|
333
|
+
// Local cluster didn't execute during consensus. Attempt a local commit,
|
|
334
|
+
// but tolerate failure (e.g., "pending action not found") when the cluster
|
|
335
|
+
// already reached consensus — this coordinator was likely picked for commit
|
|
336
|
+
// after missing the pend phase (unreachable during pend, fresh join, etc.).
|
|
337
|
+
// The cluster's majority is authoritative; this peer will catch up via sync.
|
|
338
|
+
try {
|
|
332
339
|
return await this.storageRepo.commit(request, options);
|
|
340
|
+
} catch (err) {
|
|
341
|
+
if (clusterReachedCommitConsensus(record)) {
|
|
342
|
+
log('coordinator-repo:commit-local-failed-cluster-succeeded', {
|
|
343
|
+
actionId: request.actionId,
|
|
344
|
+
error: (err as Error).message
|
|
345
|
+
});
|
|
346
|
+
return { success: true };
|
|
347
|
+
}
|
|
348
|
+
throw err;
|
|
333
349
|
}
|
|
334
|
-
// Local cluster already executed - return success
|
|
335
|
-
return { success: true };
|
|
336
350
|
} catch (error) {
|
|
337
351
|
log('coordinator-repo:commit-error', { actionId: request.actionId, error: (error as Error).message });
|
|
338
352
|
throw error;
|
|
339
353
|
}
|
|
340
354
|
}
|
|
341
355
|
}
|
|
356
|
+
|
|
357
|
+
/** True if a simple majority of cluster peers signed an approving commit. */
|
|
358
|
+
function clusterReachedCommitConsensus(record: ClusterRecord): boolean {
|
|
359
|
+
const peerCount = Object.keys(record.peers).length;
|
|
360
|
+
if (peerCount === 0) return false;
|
|
361
|
+
const approvedCommits = Object.values(record.commits).filter(s => s.type === 'approve').length;
|
|
362
|
+
return approvedCommits > peerCount / 2;
|
|
363
|
+
}
|
|
@@ -26,11 +26,14 @@ export class BlockStorage implements IBlockStorage {
|
|
|
26
26
|
return undefined;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
// Pending-only state: metadata was seeded by savePendingTransaction but no
|
|
30
|
+
// revision has been committed yet. Treat as "doesn't exist" for the default
|
|
31
|
+
// request path — matches StorageRepo.get()'s contract that undefined => empty.
|
|
32
|
+
if (rev === undefined && meta.latest === undefined) {
|
|
33
|
+
return undefined;
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
const targetRev = rev ?? meta.latest!.rev;
|
|
34
37
|
await this.ensureRevision(meta, targetRev);
|
|
35
38
|
return await this.materializeBlock(meta, targetRev);
|
|
36
39
|
}
|
|
@@ -88,6 +91,38 @@ export class BlockStorage implements IBlockStorage {
|
|
|
88
91
|
await this.storage.saveMetadata(this.blockId, meta);
|
|
89
92
|
}
|
|
90
93
|
|
|
94
|
+
async recover(): Promise<{ reconciled: boolean; latest?: ActionRev }> {
|
|
95
|
+
const meta = await this.storage.getMetadata(this.blockId);
|
|
96
|
+
if (!meta) {
|
|
97
|
+
return { reconciled: false };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const currentRev = meta.latest?.rev ?? 0;
|
|
101
|
+
let maxRev = currentRev;
|
|
102
|
+
let maxActionId = meta.latest?.actionId;
|
|
103
|
+
|
|
104
|
+
// Probe forward until we hit a gap or a revision whose action is not yet
|
|
105
|
+
// in the committed log (Crash-D2 state — retry-commit owns that advance).
|
|
106
|
+
for (let next = currentRev + 1; ; next++) {
|
|
107
|
+
const actionId = await this.storage.getRevision(this.blockId, next);
|
|
108
|
+
if (actionId === undefined) break;
|
|
109
|
+
const promoted = await this.storage.getTransaction(this.blockId, actionId);
|
|
110
|
+
if (promoted === undefined) break;
|
|
111
|
+
maxRev = next;
|
|
112
|
+
maxActionId = actionId;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (maxRev > currentRev && maxActionId !== undefined) {
|
|
116
|
+
const advanced: ActionRev = { rev: maxRev, actionId: maxActionId };
|
|
117
|
+
meta.latest = advanced;
|
|
118
|
+
await this.storage.saveMetadata(this.blockId, meta);
|
|
119
|
+
log('recover blockId=%s advanced latest from rev=%d to rev=%d', this.blockId, currentRev, maxRev);
|
|
120
|
+
return { reconciled: true, latest: advanced };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { reconciled: false, latest: meta.latest };
|
|
124
|
+
}
|
|
125
|
+
|
|
91
126
|
private async ensureRevision(meta: BlockMetadata, rev: number): Promise<void> {
|
|
92
127
|
if (this.inRanges(rev, meta.ranges)) {
|
|
93
128
|
return;
|
|
@@ -5,7 +5,13 @@ export interface IBlockStorage {
|
|
|
5
5
|
/** Gets the latest revision information for this block */
|
|
6
6
|
getLatest(): Promise<ActionRev | undefined>;
|
|
7
7
|
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Gets a materialized block at the given revision.
|
|
10
|
+
* Returns undefined when the block has no materialized content yet — either
|
|
11
|
+
* no metadata exists, or metadata exists (seeded by a pending transaction)
|
|
12
|
+
* but no revision has been committed. Throws only when a specific `rev` was
|
|
13
|
+
* requested but cannot be located.
|
|
14
|
+
*/
|
|
9
15
|
getBlock(rev?: number): Promise<{ block: IBlock, actionRev: ActionRev } | undefined>;
|
|
10
16
|
|
|
11
17
|
/** Gets an action by ID */
|
|
@@ -37,4 +43,19 @@ export interface IBlockStorage {
|
|
|
37
43
|
|
|
38
44
|
/** Sets the latest revision information */
|
|
39
45
|
setLatest(latest: ActionRev): Promise<void>;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Reconciles `metadata.latest` with the highest contiguous fully-promoted revision in
|
|
49
|
+
* the revisions table. Intended for post-crash recovery of the Crash-D3 gap, where
|
|
50
|
+
* `promotePendingTransaction` succeeded but `setLatest` did not: the revision and
|
|
51
|
+
* committed-log entry are durable, but `meta.latest` still points at the prior rev
|
|
52
|
+
* (or is undefined), and retry-commit is rejected because the pending record is gone.
|
|
53
|
+
*
|
|
54
|
+
* Stops at the first rev whose action is not yet in the committed log, preserving the
|
|
55
|
+
* Crash-D2 invariant that retry-commit — not recovery — owns advancement past a half-
|
|
56
|
+
* promoted state.
|
|
57
|
+
*
|
|
58
|
+
* Idempotent and monotonic (latest only advances forward).
|
|
59
|
+
*/
|
|
60
|
+
recover(): Promise<{ reconciled: boolean; latest?: ActionRev }>;
|
|
40
61
|
}
|
|
@@ -17,12 +17,30 @@ export class MemoryRawStorage implements IRawStorage {
|
|
|
17
17
|
return `${blockId}:${actionId}`;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Retrieves metadata for a block.
|
|
22
|
+
*
|
|
23
|
+
* @pitfall **MUST return a clone** - `BlockStorage.setLatest` mutates the returned
|
|
24
|
+
* metadata in place (`meta.latest = latest`) before calling `saveMetadata`. Returning
|
|
25
|
+
* the stored reference leaks that mutation into RAM even when a subsequent
|
|
26
|
+
* `saveMetadata` call fails, masking mid-commit crashes that a persistent store
|
|
27
|
+
* (file/sqlite/leveldb) would surface correctly.
|
|
28
|
+
* @see docs/internals.md "Storage Returns References" pitfall
|
|
29
|
+
*/
|
|
20
30
|
async getMetadata(blockId: BlockId): Promise<BlockMetadata | undefined> {
|
|
21
|
-
|
|
31
|
+
const meta = this.metadata.get(blockId);
|
|
32
|
+
return meta ? structuredClone(meta) : undefined;
|
|
22
33
|
}
|
|
23
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Stores metadata for a block.
|
|
37
|
+
*
|
|
38
|
+
* @pitfall **MUST store a clone** - callers may continue mutating the metadata object
|
|
39
|
+
* after saving; storing the reference lets those mutations corrupt persisted state.
|
|
40
|
+
* @see docs/internals.md "Storage Returns References" pitfall
|
|
41
|
+
*/
|
|
24
42
|
async saveMetadata(blockId: BlockId, metadata: BlockMetadata): Promise<void> {
|
|
25
|
-
this.metadata.set(blockId, metadata);
|
|
43
|
+
this.metadata.set(blockId, structuredClone(metadata));
|
|
26
44
|
}
|
|
27
45
|
|
|
28
46
|
async getRevision(blockId: BlockId, rev: number): Promise<ActionId | undefined> {
|
|
@@ -51,17 +51,16 @@ export class StorageRepo implements IRepo {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
const blockRev = await blockStorage.getBlock(context?.rev);
|
|
54
|
-
if (!blockRev) {
|
|
55
|
-
return [blockId, { state: {} } as GetBlockResult];
|
|
56
|
-
}
|
|
57
54
|
|
|
58
|
-
// Include pending action if requested
|
|
55
|
+
// Include pending action if requested — handled first so a pending-only
|
|
56
|
+
// insert (no committed revision yet) can still be served by applying the
|
|
57
|
+
// pending transform to an undefined prior block.
|
|
59
58
|
if (context?.actionId !== undefined) {
|
|
60
59
|
const pendingTransform = await blockStorage.getPendingTransaction(context.actionId);
|
|
61
60
|
if (!pendingTransform) {
|
|
62
61
|
throw new Error(`Pending action ${context.actionId} not found`);
|
|
63
62
|
}
|
|
64
|
-
const block = applyTransform(blockRev
|
|
63
|
+
const block = applyTransform(blockRev?.block, pendingTransform);
|
|
65
64
|
return [blockId, {
|
|
66
65
|
block,
|
|
67
66
|
state: {
|
|
@@ -71,6 +70,10 @@ export class StorageRepo implements IRepo {
|
|
|
71
70
|
}];
|
|
72
71
|
}
|
|
73
72
|
|
|
73
|
+
if (!blockRev) {
|
|
74
|
+
return [blockId, { state: {} } as GetBlockResult];
|
|
75
|
+
}
|
|
76
|
+
|
|
74
77
|
const pendings = await asyncIteratorToArray(blockStorage.listPendingTransactions());
|
|
75
78
|
return [blockId, {
|
|
76
79
|
block: blockRev.block,
|
|
@@ -208,11 +211,23 @@ export class StorageRepo implements IRepo {
|
|
|
208
211
|
storage: this.createBlockStorage(blockId)
|
|
209
212
|
}));
|
|
210
213
|
|
|
211
|
-
//
|
|
214
|
+
// Partition blocks into:
|
|
215
|
+
// - alreadyDone: latest.rev === request.rev && latest.actionId === request.actionId
|
|
216
|
+
// (idempotent retry — a prior commit of this same action already landed here;
|
|
217
|
+
// skip rather than treat as a conflict. Needed to rollforward stranded blocks
|
|
218
|
+
// after a mid-batch crash committed some but not all blocks.)
|
|
219
|
+
// - missedCommits: latest.rev >= request.rev but not the same actionId → real stale conflict.
|
|
220
|
+
// - toCommit: latest.rev < request.rev or no latest yet → run internalCommit.
|
|
221
|
+
const toCommit: { blockId: BlockId, storage: IBlockStorage }[] = [];
|
|
212
222
|
const missedCommits: { blockId: BlockId, transforms: ActionTransform[] }[] = [];
|
|
213
|
-
for (const
|
|
223
|
+
for (const entry of blockStorages) {
|
|
224
|
+
const { blockId, storage } = entry;
|
|
214
225
|
const latest = await storage.getLatest();
|
|
215
226
|
if (latest && latest.rev >= request.rev) {
|
|
227
|
+
if (latest.rev === request.rev && latest.actionId === request.actionId) {
|
|
228
|
+
// Idempotent no-op for this block — already committed with this exact (actionId, rev).
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
216
231
|
const transforms: ActionTransform[] = [];
|
|
217
232
|
for await (const actionRev of storage.listRevisions(request.rev, latest.rev)) {
|
|
218
233
|
const transform = await storage.getTransaction(actionRev.actionId);
|
|
@@ -226,7 +241,9 @@ export class StorageRepo implements IRepo {
|
|
|
226
241
|
});
|
|
227
242
|
}
|
|
228
243
|
missedCommits.push({ blockId, transforms }); // Push, even if transforms is empty, because we want to reject the older version
|
|
244
|
+
continue;
|
|
229
245
|
}
|
|
246
|
+
toCommit.push(entry);
|
|
230
247
|
}
|
|
231
248
|
|
|
232
249
|
if (missedCommits.length) {
|
|
@@ -237,9 +254,11 @@ export class StorageRepo implements IRepo {
|
|
|
237
254
|
};
|
|
238
255
|
}
|
|
239
256
|
|
|
240
|
-
// Check for missing pending actions
|
|
257
|
+
// Check for missing pending actions only on blocks that still need to commit.
|
|
258
|
+
// Already-done blocks will have had their pending promoted, so skipping them here
|
|
259
|
+
// is what makes the idempotent rollforward work.
|
|
241
260
|
const missingPends: { blockId: BlockId, actionId: ActionId }[] = [];
|
|
242
|
-
for (const { blockId, storage } of
|
|
261
|
+
for (const { blockId, storage } of toCommit) {
|
|
243
262
|
const pendingAction = await storage.getPendingTransaction(request.actionId);
|
|
244
263
|
if (!pendingAction) {
|
|
245
264
|
missingPends.push({ blockId, actionId: request.actionId });
|
|
@@ -250,14 +269,15 @@ export class StorageRepo implements IRepo {
|
|
|
250
269
|
throw new Error(`Pending action ${request.actionId} not found for block(s): ${missingPends.map(p => p.blockId).join(', ')}`);
|
|
251
270
|
}
|
|
252
271
|
|
|
253
|
-
// Commit the action for each block
|
|
254
|
-
// This loop will execute atomically for all blocks due to the acquired locks
|
|
255
|
-
for (const { blockId, storage } of
|
|
272
|
+
// Commit the action for each block that still needs it.
|
|
273
|
+
// This loop will execute atomically for all blocks due to the acquired locks.
|
|
274
|
+
for (const { blockId, storage } of toCommit) {
|
|
256
275
|
try {
|
|
257
276
|
// internalCommit will throw if it encounters an issue
|
|
258
277
|
await this.internalCommit(blockId, request.actionId, request.rev, storage);
|
|
259
278
|
} catch (err) {
|
|
260
|
-
//
|
|
279
|
+
// Partial-commit recovery: a retry with the same (actionId, rev) will treat
|
|
280
|
+
// already-done blocks as idempotent no-ops and advance the remainder.
|
|
261
281
|
return {
|
|
262
282
|
success: false,
|
|
263
283
|
reason: err instanceof Error ? err.message : 'Unknown error during commit'
|
|
@@ -273,6 +293,19 @@ export class StorageRepo implements IRepo {
|
|
|
273
293
|
return { success: true };
|
|
274
294
|
}
|
|
275
295
|
|
|
296
|
+
/**
|
|
297
|
+
* Reconciles `metadata.latest` for a single block with the highest contiguous
|
|
298
|
+
* fully-promoted revision in durable storage. Use after a crash between
|
|
299
|
+
* `promotePendingTransaction` and `setLatest` when retry-commit cannot help
|
|
300
|
+
* (the pending record is already gone) but the revision and committed-log entry
|
|
301
|
+
* are durable. Idempotent and monotonic.
|
|
302
|
+
*/
|
|
303
|
+
async recoverBlock(blockId: BlockId): Promise<void> {
|
|
304
|
+
log('recoverBlock blockId=%s', blockId);
|
|
305
|
+
const storage = this.createBlockStorage(blockId);
|
|
306
|
+
await storage.recover();
|
|
307
|
+
}
|
|
308
|
+
|
|
276
309
|
private async internalCommit(blockId: BlockId, actionId: ActionId, rev: number, storage: IBlockStorage): Promise<void> {
|
|
277
310
|
// Note: This method is called within the locked critical section of commit()
|
|
278
311
|
// So, operations like getPendingTransaction, getLatest, getBlock, saveMaterializedBlock,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './mesh-harness.js';
|