@optimystic/db-p2p 0.11.2 → 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 +29 -0
- package/dist/src/storage/block-storage.js.map +1 -1
- package/dist/src/storage/i-block-storage.d.ts +17 -0
- 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 +37 -8
- 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 +32 -0
- package/src/storage/i-block-storage.ts +15 -0
- package/src/storage/memory-storage.ts +20 -2
- package/src/storage/storage-repo.ts +38 -8
- package/src/testing/index.ts +1 -0
- package/src/testing/mesh-harness.ts +294 -0
|
@@ -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":"AAIA,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AACvD,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,EAAiB,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC1E,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AACzD,OAAO,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AAChE,OAAO,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAE3D,OAAO,EAAE,eAAe,EAA8B,MAAM,6BAA6B,CAAC;AAE1F,OAAO,EAAE,mBAAmB,EAAkB,MAAM,8BAA8B,CAAC;AACnF,OAAO,EAAE,QAAQ,IAAI,UAAU,EAAE,MAAM,aAAa,CAAC;AA+BrD,MAAM,eAAe;IACpB,KAAK,CAAC,OAAO,CAAC,OAAe,EAAE,SAAiB;QAC/C,OAAO,EAAE,CAAC;IACX,CAAC;CACD;AAED;;;;GAIG;AACH,MAAM,kBAAkB;IAEL;IACA;IACA;IAHlB,YACkB,KAAiB,EACjB,eAAuB,EACvB,WAA8B,EAAE;QAFhC,UAAK,GAAL,KAAK,CAAY;QACjB,oBAAe,GAAf,eAAe,CAAQ;QACvB,aAAQ,GAAR,QAAQ,CAAwB;IAC/C,CAAC;IAEJ,KAAK,CAAC,eAAe,CAAI,GAAe,EAAE,OAAyC;QAClF,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAC,CAAC,OAAO,EAAE,aAAa,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC;QAChF,MAAM,MAAM,GAAG,IAAI,CAAC,gBAAgB,CAAC,GAAG,CAAC,CAAC;QAC1C,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC;QAClE,IAAI,CAAC,IAAI,EAAE,CAAC;YACX,MAAM,IAAI,KAAK,CAAC,4DAA4D,CAAC,CAAC;QAC/E,CAAC;QACD,OAAO,IAAI,CAAC,MAAM,CAAC;IACpB,CAAC;IAED,KAAK,CAAC,WAAW,CAAC,GAAe;QAChC,IAAI,IAAI,CAAC,QAAQ,CAAC,gBAAgB,EAAE,CAAC;YACpC,OAAO,EAAkB,CAAC;QAC3B,CAAC;QAED,MAAM,MAAM,GAAG,IAAI,CAAC,gBAAgB,CAAC,GAAG,CAAC,CAAC;QAC1C,MAAM,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,eAAe,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC;QACxD,MAAM,QAAQ,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;QAEpC,MAAM,KAAK,GAAiB,EAAE,CAAC;QAC/B,KAAK,MAAM,IAAI,IAAI,QAAQ,EAAE,CAAC;YAC7B,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,EAAE,CAAC,GAAG;gBAC/B,UAAU,EAAE,CAAC,yBAAyB,CAAC;gBACvC,SAAS,EAAE,UAAU,CAAC,IAAI,CAAC,MAAM,CAAC,SAAU,CAAC,GAAG,EAAE,WAAW,CAAC;aAC9D,CAAC;QACH,CAAC;QACD,OAAO,KAAK,CAAC;IACd,CAAC;IAEO,gBAAgB,CAAC,GAAe;QACvC,MAAM,UAAU,GAAgB,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;YACpD,EAAE,EAAE,CAAC,CAAC,MAAM;YACZ,KAAK,EAAE,CAAC,yBAAyB,CAAC;SAClC,CAAC,CAAC,CAAC;QACJ,MAAM,MAAM,GAAG,mBAAmB,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC;QACpD,OAAO,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,CAAC,CAAE,CAAC,CAAC;IACxE,CAAC;CACD;AAQD;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,SAAiB,EAAE,OAAoB;IACvE,MAAM,QAAQ,GAAsB,EAAE,CAAC;IAEvC,mCAAmC;IACnC,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,GAAG,CACjC,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,EAAE,KAAK,IAAI,EAAE;QAC5C,MAAM,UAAU,GAAG,MAAM,eAAe,CAAC,SAAS,CAAC,CAAC;QACpD,OAAO,EAAE,MAAM,EAAE,oBAAoB,CAAC,UAAU,CAAC,EAAE,UAAU,EAAE,CAAC;IACjE,CAAC,CAAC,CACF,CAAC;IAEF,kFAAkF;IAClF,MAAM,KAAK,GAAe,EAAE,CAAC;IAC7B,MAAM,WAAW,GAAG,IAAI,eAAe,EAAE,CAAC;IAC1C,4EAA4E;IAC5E,MAAM,WAAW,GAAG,IAAI,GAAG,EAAuB,CAAC;IAEnD,4CAA4C;IAC5C,IAAI,SAAS,GAAG,CAAC,CAAC;IAClB,KAAK,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,IAAI,QAAQ,EAAE,CAAC;QAC/C,MAAM,UAAU,GAAG,OAAO,CAAC,iBAAiB;YAC3C,CAAC,CAAC,OAAO,CAAC,iBAAiB,CAAC,SAAS,CAAC;YACtC,CAAC,CAAC,IAAI,gBAAgB,EAAE,CAAC;QAC1B,WAAW,CAAC,GAAG,CAAC,MAAM,CAAC,QAAQ,EAAE,EAAE,UAAU,CAAC,CAAC;QAC/C,SAAS,EAAE,CAAC;QACZ,MAAM,WAAW,GAAG,IAAI,WAAW,CAClC,CAAC,OAAgB,EAAE,EAAE,CAAC,IAAI,YAAY,CAAC,OAAO,EAAE,UAAU,CAAC,CAC3D,CAAC;QAEF,MAAM,eAAe,GAA2B;YAC/C,sBAAsB,EAAE,OAAO,CAAC,sBAAsB,IAAI,IAAI;YAC9D,uBAAuB,EAAE,IAAI;YAC7B,sBAAsB,EAAE,CAAC;YACzB,oBAAoB,EAAE,OAAO,CAAC,oBAAoB,IAAI,IAAI;YAC1D,oBAAoB,EAAE,GAAG;YACzB,wBAAwB,EAAE,KAAK;SAC/B,CAAC;QAEF,MAAM,MAAM,GAAG,aAAa,CAAC;YAC5B,WAAW;YACX,WAAW;YACX,MAAM;YACN,UAAU;YACV,eAAe;SACf,CAAC,CAAC;QAEH,KAAK,CAAC,IAAI,CAAC;YACV,MAAM;YACN,UAAU;YACV,WAAW;YACX,aAAa,EAAE,MAAM;YACrB,eAAe,EAAE,SAAgB,CAAC,oBAAoB;SACtD,CAAC,CAAC;IACJ,CAAC;IAED,kFAAkF;IAClF,MAAM,UAAU,GAAG,IAAI,kBAAkB,CAAC,KAAK,EAAE,OAAO,CAAC,eAAe,EAAE,QAAQ,CAAC,CAAC;IAEpF,MAAM,mBAAmB,GAAG,CAAC,YAAoB,EAAY,EAAE;QAC9D,MAAM,MAAM,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC,CAAC;QAC9D,IAAI,CAAC,MAAM,EAAE,CAAC;YACb,MAAM,IAAI,KAAK,CAAC,iBAAiB,YAAY,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;QAC7D,CAAC;QACD,OAAO;YACN,KAAK,CAAC,MAAM,CAAC,MAAqB;gBACjC,IAAI,QAAQ,CAAC,YAAY,EAAE,GAAG,CAAC,YAAY,CAAC,QAAQ,EAAE,CAAC,EAAE,CAAC;oBACzD,MAAM,IAAI,KAAK,CAAC,QAAQ,YAAY,CAAC,QAAQ,EAAE,iBAAiB,CAAC,CAAC;gBACnE,CAAC;gBACD,OAAO,MAAM,CAAC,aAAa,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;YAC5C,CAAC;SACD,CAAC;IACH,CAAC,CAAC;IAEF,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QAC1B,MAAM,eAAe,GAAG,WAAW,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,EAAE,CAAE,CAAC;QAEjE,+EAA+E;QAC/E,iDAAiD;QACjD,MAAM,qBAAqB,GAA0B,KAAK,EAAE,MAAc,EAAE,OAAgB,EAAE,OAAQ,EAAkC,EAAE;YACzI,MAAM,MAAM,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC;YACxD,IAAI,CAAC,MAAM;gBAAE,OAAO,SAAS,CAAC;YAC9B,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,WAAW,CAAC,GAAG,CAC1C,EAAE,QAAQ,EAAE,CAAC,OAAO,CAAC,EAAE,OAAO,EAAE,EAChC,EAAE,gBAAgB,EAAE,IAAI,EAAS,CACjC,CAAC;YACF,MAAM,KAAK,GAAG,MAAM,CAAC,OAAO,CAAC,CAAC;YAC9B,MAAM,MAAM,GAAG,KAAK,EAAE,KAAK,EAAE,MAAM,CAAC;YAEpC,sEAAsE;YACtE,IAAI,MAAM,IAAI,KAAK,EAAE,KAAK,EAAE,CAAC;gBAC5B,MAAM,iBAAiB,GAAG,IAAI,YAAY,CAAC,OAAkB,EAAE,eAAe,CAAC,CAAC;gBAChF,MAAM,WAAW,GAAG,MAAM,iBAAiB,CAAC,SAAS,EAAE,CAAC;gBACxD,IAAI,CAAC,WAAW,IAAI,WAAW,CAAC,GAAG,GAAG,MAAM,CAAC,GAAG,EAAE,CAAC;oBAClD,yBAAyB;oBACzB,MAAM,IAAI,GAAG,MAAM,eAAe,CAAC,WAAW,CAAC,OAAkB,CAAC,CAAC;oBACnE,IAAI,CAAC,IAAI,EAAE,CAAC;wBACX,MAAM,eAAe,CAAC,YAAY,CAAC,OAAkB,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;oBAC9F,CAAC;oBACD,MAAM,iBAAiB,CAAC,qBAAqB,CAAC,MAAM,CAAC,QAAQ,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC;oBAC5E,MAAM,iBAAiB,CAAC,YAAY,CAAC,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC;oBAClE,MAAM,iBAAiB,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;gBAC3C,CAAC;YACF,CAAC;YAED,OAAO,MAAM,CAAC;QACf,CAAC,CAAC;QACF,+FAA+F;QAC/F,MAAM,cAAc,GAAgB;YACnC,eAAe,EAAE,CAAC,GAAG,EAAE,IAAI,EAAE,EAAE,CAAC,UAAU,CAAC,eAAe,CAAC,GAAG,EAAE,IAAI,CAAC;YACrE,KAAK,CAAC,WAAW,CAAC,GAAG;gBACpB,MAAM,KAAK,GAAG,MAAM,UAAU,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;gBAChD,MAAM,OAAO,GAAG,IAAI,CAAC,MAAM,CAAC,QAAQ,EAAE,CAAC;gBACvC,IAAI,CAAC,CAAC,OAAO,IAAI,KAAK,CAAC,EAAE,CAAC;oBACzB,KAAK,CAAC,OAAO,CAAC,GAAG;wBAChB,UAAU,EAAE,CAAC,yBAAyB,CAAC;wBACvC,SAAS,EAAE,UAAU,CAAC,IAAI,CAAC,MAAM,CAAC,SAAU,CAAC,GAAG,EAAE,WAAW,CAAC;qBAC9D,CAAC;gBACH,CAAC;gBACD,OAAO,KAAK,CAAC;YACd,CAAC;SACD,CAAC;QACF,MAAM,OAAO,GAAG,eAAe,CAC9B,cAAc,EACd,CAAC,MAAc,EAAE,EAAE,CAAC,mBAAmB,CAAC,MAAM,CAAQ,EACtD;YACC,WAAW,EAAE,OAAO,CAAC,WAAW,IAAI,SAAS;YAC7C,sBAAsB,EAAE,OAAO,CAAC,sBAAsB,IAAI,IAAI;YAC9D,oBAAoB,EAAE,OAAO,CAAC,oBAAoB,IAAI,IAAI;SAC1D,CACD,CAAC;QACF,IAAI,CAAC,eAAe,GAAG,OAAO,CAAC;YAC9B,WAAW,EAAE,IAAI,CAAC,WAAW;YAC7B,YAAY,EAAE,IAAI,CAAC,aAAa;YAChC,WAAW,EAAE,IAAI,CAAC,MAAM;YACxB,qBAAqB;SACrB,CAAC,CAAC;IACJ,CAAC;IAED,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAE,CAAC;AACxC,CAAC;AAOD;;;;;GAKG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC,IAAU,EAAE,UAAkC,EAAE,EAAe,EAAE;IACvG,MAAM,UAAU,GAAG,IAAI,GAAG,EAAiB,CAAC;IAC5C,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;QAC/B,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,EAAE,EAAE,IAAI,CAAC,eAAmC,CAAC,CAAC;IAClF,CAAC;IACD,OAAO,IAAI,iBAAiB,CAAC;QAC5B,SAAS,EAAE,OAAO,CAAC,SAAS,IAAI,KAAK;QACrC,sBAAsB,EAAE,OAAO,CAAC,sBAAsB,IAAI,KAAK;QAC/D,UAAU,EAAE,IAAI,CAAC,UAAU;QAC3B,OAAO,EAAE,CAAC,MAAgB,EAAE,EAAE;YAC7B,MAAM,IAAI,GAAG,UAAU,CAAC,GAAG,CAAC,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAC;YAC/C,IAAI,CAAC,IAAI;gBAAE,MAAM,IAAI,KAAK,CAAC,gBAAgB,MAAM,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;YAChE,OAAO,IAAI,CAAC;QACb,CAAC;KACD,CAAC,CAAC;AACJ,CAAC,CAAC;AAEF;;;;GAIG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,IAAU,EAAE,UAAkC,EAAE,EAA4B,EAAE;IACrH,MAAM,WAAW,GAAG,IAAI,GAAG,EAAuB,CAAC;IACnD,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;QAC/B,WAAW,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,EAAE,EAAE,sBAAsB,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC;IAChF,CAAC;IACD,OAAO,WAAW,CAAC;AACpB,CAAC,CAAC"}
|
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
|
+
}
|
|
@@ -91,6 +91,38 @@ export class BlockStorage implements IBlockStorage {
|
|
|
91
91
|
await this.storage.saveMetadata(this.blockId, meta);
|
|
92
92
|
}
|
|
93
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
|
+
|
|
94
126
|
private async ensureRevision(meta: BlockMetadata, rev: number): Promise<void> {
|
|
95
127
|
if (this.inRanges(rev, meta.ranges)) {
|
|
96
128
|
return;
|
|
@@ -43,4 +43,19 @@ export interface IBlockStorage {
|
|
|
43
43
|
|
|
44
44
|
/** Sets the latest revision information */
|
|
45
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 }>;
|
|
46
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> {
|
|
@@ -211,11 +211,23 @@ export class StorageRepo implements IRepo {
|
|
|
211
211
|
storage: this.createBlockStorage(blockId)
|
|
212
212
|
}));
|
|
213
213
|
|
|
214
|
-
//
|
|
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 }[] = [];
|
|
215
222
|
const missedCommits: { blockId: BlockId, transforms: ActionTransform[] }[] = [];
|
|
216
|
-
for (const
|
|
223
|
+
for (const entry of blockStorages) {
|
|
224
|
+
const { blockId, storage } = entry;
|
|
217
225
|
const latest = await storage.getLatest();
|
|
218
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
|
+
}
|
|
219
231
|
const transforms: ActionTransform[] = [];
|
|
220
232
|
for await (const actionRev of storage.listRevisions(request.rev, latest.rev)) {
|
|
221
233
|
const transform = await storage.getTransaction(actionRev.actionId);
|
|
@@ -229,7 +241,9 @@ export class StorageRepo implements IRepo {
|
|
|
229
241
|
});
|
|
230
242
|
}
|
|
231
243
|
missedCommits.push({ blockId, transforms }); // Push, even if transforms is empty, because we want to reject the older version
|
|
244
|
+
continue;
|
|
232
245
|
}
|
|
246
|
+
toCommit.push(entry);
|
|
233
247
|
}
|
|
234
248
|
|
|
235
249
|
if (missedCommits.length) {
|
|
@@ -240,9 +254,11 @@ export class StorageRepo implements IRepo {
|
|
|
240
254
|
};
|
|
241
255
|
}
|
|
242
256
|
|
|
243
|
-
// 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.
|
|
244
260
|
const missingPends: { blockId: BlockId, actionId: ActionId }[] = [];
|
|
245
|
-
for (const { blockId, storage } of
|
|
261
|
+
for (const { blockId, storage } of toCommit) {
|
|
246
262
|
const pendingAction = await storage.getPendingTransaction(request.actionId);
|
|
247
263
|
if (!pendingAction) {
|
|
248
264
|
missingPends.push({ blockId, actionId: request.actionId });
|
|
@@ -253,14 +269,15 @@ export class StorageRepo implements IRepo {
|
|
|
253
269
|
throw new Error(`Pending action ${request.actionId} not found for block(s): ${missingPends.map(p => p.blockId).join(', ')}`);
|
|
254
270
|
}
|
|
255
271
|
|
|
256
|
-
// Commit the action for each block
|
|
257
|
-
// This loop will execute atomically for all blocks due to the acquired locks
|
|
258
|
-
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) {
|
|
259
275
|
try {
|
|
260
276
|
// internalCommit will throw if it encounters an issue
|
|
261
277
|
await this.internalCommit(blockId, request.actionId, request.rev, storage);
|
|
262
278
|
} catch (err) {
|
|
263
|
-
//
|
|
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.
|
|
264
281
|
return {
|
|
265
282
|
success: false,
|
|
266
283
|
reason: err instanceof Error ? err.message : 'Unknown error during commit'
|
|
@@ -276,6 +293,19 @@ export class StorageRepo implements IRepo {
|
|
|
276
293
|
return { success: true };
|
|
277
294
|
}
|
|
278
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
|
+
|
|
279
309
|
private async internalCommit(blockId: BlockId, actionId: ActionId, rev: number, storage: IBlockStorage): Promise<void> {
|
|
280
310
|
// Note: This method is called within the locked critical section of commit()
|
|
281
311
|
// So, operations like getPendingTransaction, getLatest, getBlock, saveMaterializedBlock,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './mesh-harness.js';
|