@optimystic/db-p2p 0.16.3 → 0.18.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/block-transfer-service.d.ts +14 -1
- package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
- package/dist/src/cluster/block-transfer-service.js +12 -3
- package/dist/src/cluster/block-transfer-service.js.map +1 -1
- package/dist/src/cluster/cluster-policy.d.ts +112 -0
- package/dist/src/cluster/cluster-policy.d.ts.map +1 -0
- package/dist/src/cluster/cluster-policy.js +88 -0
- package/dist/src/cluster/cluster-policy.js.map +1 -0
- package/dist/src/cluster/cluster-repo.d.ts +41 -13
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +116 -23
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/cluster/quorum-restore.d.ts +64 -14
- package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
- package/dist/src/cluster/quorum-restore.js +0 -0
- package/dist/src/cluster/quorum-restore.js.map +1 -1
- package/dist/src/cluster/reconcile-block.d.ts +60 -0
- package/dist/src/cluster/reconcile-block.d.ts.map +1 -0
- package/dist/src/cluster/reconcile-block.js +133 -0
- package/dist/src/cluster/reconcile-block.js.map +1 -0
- package/dist/src/cluster/service.d.ts +4 -1
- package/dist/src/cluster/service.d.ts.map +1 -1
- package/dist/src/cluster/service.js +9 -1
- package/dist/src/cluster/service.js.map +1 -1
- package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
- package/dist/src/cluster/spread-on-churn.js +8 -0
- package/dist/src/cluster/spread-on-churn.js.map +1 -1
- package/dist/src/inbound-authorization.d.ts +117 -0
- package/dist/src/inbound-authorization.d.ts.map +1 -0
- package/dist/src/inbound-authorization.js +149 -0
- package/dist/src/inbound-authorization.js.map +1 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/libp2p-key-network.d.ts +14 -0
- package/dist/src/libp2p-key-network.d.ts.map +1 -1
- package/dist/src/libp2p-key-network.js +54 -4
- package/dist/src/libp2p-key-network.js.map +1 -1
- package/dist/src/libp2p-node-base.d.ts +48 -7
- package/dist/src/libp2p-node-base.d.ts.map +1 -1
- package/dist/src/libp2p-node-base.js +61 -83
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/repo/cluster-coordinator.d.ts +21 -3
- package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
- package/dist/src/repo/cluster-coordinator.js +27 -5
- package/dist/src/repo/cluster-coordinator.js.map +1 -1
- package/dist/src/repo/coordinator-repo.d.ts +123 -16
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +354 -49
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/repo/service.d.ts +4 -1
- package/dist/src/repo/service.d.ts.map +1 -1
- package/dist/src/repo/service.js +9 -1
- package/dist/src/repo/service.js.map +1 -1
- package/dist/src/storage/block-storage.d.ts.map +1 -1
- package/dist/src/storage/block-storage.js +11 -0
- package/dist/src/storage/block-storage.js.map +1 -1
- package/dist/src/storage/storage-repo.d.ts +56 -0
- package/dist/src/storage/storage-repo.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.js +155 -13
- package/dist/src/storage/storage-repo.js.map +1 -1
- package/dist/src/sync/service.d.ts +4 -7
- package/dist/src/sync/service.d.ts.map +1 -1
- package/dist/src/sync/service.js +13 -10
- package/dist/src/sync/service.js.map +1 -1
- package/dist/src/testing/mesh-harness.d.ts +10 -0
- package/dist/src/testing/mesh-harness.d.ts.map +1 -1
- package/dist/src/testing/mesh-harness.js +55 -49
- package/dist/src/testing/mesh-harness.js.map +1 -1
- package/package.json +2 -2
- package/{README.md → readme.md} +37 -0
- package/src/cluster/block-transfer-service.ts +20 -5
- package/src/cluster/cluster-policy.ts +152 -0
- package/src/cluster/cluster-repo.ts +121 -26
- package/src/cluster/quorum-restore.ts +0 -0
- package/src/cluster/reconcile-block.ts +191 -0
- package/src/cluster/service.ts +10 -3
- package/src/cluster/spread-on-churn.ts +8 -0
- package/src/inbound-authorization.ts +190 -0
- package/src/index.ts +1 -0
- package/src/libp2p-key-network.ts +54 -4
- package/src/libp2p-node-base.ts +111 -94
- package/src/repo/cluster-coordinator.ts +30 -6
- package/src/repo/coordinator-repo.ts +419 -58
- package/src/repo/service.ts +10 -3
- package/src/storage/block-storage.ts +11 -0
- package/src/storage/storage-repo.ts +172 -15
- package/src/sync/service.ts +14 -12
- package/src/testing/mesh-harness.ts +55 -49
package/src/repo/service.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { encodePeers, type RedirectPayload } from './redirect.js'
|
|
|
8
8
|
import { MAX_BLOCK_MESSAGE_BYTES } from '../protocol-limits.js'
|
|
9
9
|
import type { Uint8ArrayList } from 'uint8arraylist'
|
|
10
10
|
import { createLogger } from '../logger.js'
|
|
11
|
+
import { createInboundStreamAuthorization, type InboundStreamAuthorization, type InboundStreamAuthorizationInit } from '../inbound-authorization.js'
|
|
11
12
|
|
|
12
13
|
const debugLog = createLogger('repo-service')
|
|
13
14
|
|
|
@@ -38,7 +39,7 @@ export type RepoServiceComponents = BaseComponents & {
|
|
|
38
39
|
libp2p?: Libp2p
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
export type RepoServiceInit = {
|
|
42
|
+
export type RepoServiceInit = InboundStreamAuthorizationInit & {
|
|
42
43
|
protocol?: string,
|
|
43
44
|
protocolPrefix?: string,
|
|
44
45
|
maxInboundStreams?: number,
|
|
@@ -81,6 +82,8 @@ export class RepoService implements Startable {
|
|
|
81
82
|
* self identity, and connection addrs through this explicitly-set reference.
|
|
82
83
|
*/
|
|
83
84
|
private libp2pRef: Libp2p | undefined
|
|
85
|
+
/** Optional embedder authorization gate; `undefined` (the default) means no check runs. */
|
|
86
|
+
private readonly authorization: InboundStreamAuthorization | undefined
|
|
84
87
|
|
|
85
88
|
constructor(components: RepoServiceComponents, init: RepoServiceInit = {}) {
|
|
86
89
|
this.components = components
|
|
@@ -92,6 +95,7 @@ export class RepoService implements Startable {
|
|
|
92
95
|
this.repo = components.repo
|
|
93
96
|
this.running = false
|
|
94
97
|
this.responsibilityK = init.responsibilityK ?? 1
|
|
98
|
+
this.authorization = createInboundStreamAuthorization(init, this.protocol, (msg, ...args) => this.log.error(msg, ...args))
|
|
95
99
|
}
|
|
96
100
|
|
|
97
101
|
readonly [Symbol.toStringTag] = '@libp2p/repo-service'
|
|
@@ -236,8 +240,8 @@ export class RepoService implements Startable {
|
|
|
236
240
|
/**
|
|
237
241
|
* Handle incoming streams on the repo protocol
|
|
238
242
|
*/
|
|
239
|
-
private handleIncomingStream(stream: Stream, connection
|
|
240
|
-
const peerId = connection
|
|
243
|
+
private handleIncomingStream(stream: Stream, connection?: Connection): void {
|
|
244
|
+
const peerId = connection?.remotePeer
|
|
241
245
|
|
|
242
246
|
const processStream = async function* (this: RepoService, source: AsyncIterable<Uint8ArrayList>) {
|
|
243
247
|
for await (const msg of source) {
|
|
@@ -278,6 +282,9 @@ export class RepoService implements Startable {
|
|
|
278
282
|
|
|
279
283
|
void (async () => {
|
|
280
284
|
try {
|
|
285
|
+
// Authorization runs before ANY decoding or execution. Guarded on the field so a
|
|
286
|
+
// node without a predicate keeps the original path untouched.
|
|
287
|
+
if (this.authorization && await this.authorization.deny(stream, peerId?.toString())) return
|
|
281
288
|
const responses = pipe(
|
|
282
289
|
stream,
|
|
283
290
|
(source) => lpDecode(source, { maxDataLength: MAX_BLOCK_MESSAGE_BYTES }),
|
|
@@ -36,6 +36,17 @@ export class BlockStorage implements IBlockStorage {
|
|
|
36
36
|
async getBlock(rev?: number): Promise<{ block: IBlock, actionRev: ActionRev } | undefined> {
|
|
37
37
|
const meta = await this.storage.getMetadata(this.blockId);
|
|
38
38
|
if (!meta) {
|
|
39
|
+
// No metadata at all ⇒ this node has never seen the block, and reads report it absent
|
|
40
|
+
// WITHOUT consulting `restoreCallback`. That is deliberate, not an oversight: `restoreCallback`
|
|
41
|
+
// is reachable only from ensureRevision below, so a never-seen block is never fetched HERE.
|
|
42
|
+
// Attempting a fetch at this layer would turn every read of a genuinely non-existent block —
|
|
43
|
+
// the common case for an insert probing for a collision — into a network round trip, because
|
|
44
|
+
// storage cannot tell "nobody has this" from "I don't have this".
|
|
45
|
+
//
|
|
46
|
+
// The layer that CAN tell them apart makes that call instead: `CoordinatorRepo` acquires the
|
|
47
|
+
// block only once the cohort has corroborated a `(rev, actionId)` for it
|
|
48
|
+
// (`restoreCorroborated` → `acquireBlockFromCohort`), so an id no peer claims still costs
|
|
49
|
+
// nothing beyond the latest-query it already performed. Keep this early return as-is.
|
|
39
50
|
return undefined;
|
|
40
51
|
}
|
|
41
52
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
IRepo, MessageOptions, BlockId, CommitRequest, CommitResult, GetBlockResults, PendRequest, PendResult, ActionBlocks,
|
|
3
3
|
ActionId, BlockGets, ActionPending, PendSuccess, ActionTransform, ActionTransforms,
|
|
4
|
-
GetBlockResult, IBlock, ActionRev,
|
|
4
|
+
GetBlockResult, IBlock, ActionRev, BlockUnavailableReason,
|
|
5
5
|
PendValidationHook,
|
|
6
6
|
CollectionId, IBlockChangeNotifier, CollectionChangeListener, CollectionChangeEvent
|
|
7
7
|
} from "@optimystic/db-core";
|
|
@@ -44,6 +44,37 @@ export async function withBlockCommitLatch<T>(blockId: BlockId, fn: () => Promis
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Stable, greppable prefix on the failure reason a commit carries when this node cannot materialize
|
|
49
|
+
* the revision it was asked to record. It is a STRING marker rather than only an error class because
|
|
50
|
+
* {@link StorageRepo.commit} reports per-block faults as `StaleFailure.reason` (a plain string that
|
|
51
|
+
* also crosses the wire), so the class identity is lost by the time a caller inspects the result.
|
|
52
|
+
*/
|
|
53
|
+
export const MISSING_BASE_REVISION_REASON = 'missing-base-revision';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* This node was asked to commit revision N of a block it holds no materializable base for, so
|
|
57
|
+
* applying the transform would materialize nothing while `latest` advanced to N — a block that is
|
|
58
|
+
* then unreadable locally, unservable to peers, and that rejects every later write (see
|
|
59
|
+
* {@link StorageRepo.internalCommit}). The commit is refused instead; the caller heals the node
|
|
60
|
+
* out-of-band (`ClusterMember` pulls the committed revision from a cohort peer) and retries.
|
|
61
|
+
*/
|
|
62
|
+
export class MissingBaseRevisionError extends Error {
|
|
63
|
+
constructor(readonly blockId: BlockId, readonly rev: number, detail: string) {
|
|
64
|
+
super(`${MISSING_BASE_REVISION_REASON}: block ${blockId} cannot materialize rev ${rev} — ${detail}`);
|
|
65
|
+
this.name = 'MissingBaseRevisionError';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* True when a {@link CommitResult} failed because this node holds no materializable base for one of
|
|
71
|
+
* the committed blocks. Distinguishes that recoverable divergence (heal by fetching the block from a
|
|
72
|
+
* cohort peer) from a genuine storage fault, which must still propagate.
|
|
73
|
+
*/
|
|
74
|
+
export function isMissingBaseRevisionFailure(result: CommitResult): boolean {
|
|
75
|
+
return !result.success && (result.reason?.startsWith(MISSING_BASE_REVISION_REASON) ?? false);
|
|
76
|
+
}
|
|
77
|
+
|
|
47
78
|
export type StorageRepoOptions = {
|
|
48
79
|
/** Optional hook to validate transactions in PendRequests */
|
|
49
80
|
validatePend?: PendValidationHook;
|
|
@@ -155,6 +186,11 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
|
|
|
155
186
|
const promotions: { collectionId: CollectionId, blockId: BlockId, actionId: ActionId, rev: number }[] = [];
|
|
156
187
|
const results = await Promise.all(distinctBlockIds.map(async (blockId) => {
|
|
157
188
|
const blockStorage = this.createBlockStorage(blockId);
|
|
189
|
+
// Set when this node KNOWS its answer for the block is a guess: the promotion
|
|
190
|
+
// below refused for a missing base, or getBlock() threw (truncated history /
|
|
191
|
+
// failed restore). An absent-reading block then reports `unavailable` instead of
|
|
192
|
+
// posing as an authoritative "never existed" — see BlockUnavailableReason.
|
|
193
|
+
let unavailable: BlockUnavailableReason | undefined;
|
|
158
194
|
|
|
159
195
|
// Ensure that all outstanding transactions in the context are committed.
|
|
160
196
|
// This promotes a landed-elsewhere pending via internalCommit, which mutates
|
|
@@ -185,14 +221,30 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
|
|
|
185
221
|
// Sort a COPY: when `latest` is undefined, `missing` aliases the caller's
|
|
186
222
|
// `context.committed` array, and an in-place `.sort()` would reorder the shared
|
|
187
223
|
// request context under the caller's feet.
|
|
188
|
-
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
224
|
+
try {
|
|
225
|
+
for (const { actionId, rev } of [...missing].sort((a, b) => a.rev - b.rev)) {
|
|
226
|
+
const pending = await blockStorage.getPendingTransaction(actionId);
|
|
227
|
+
if (pending) {
|
|
228
|
+
const collectionId = await this.internalCommit(blockId, actionId, rev, blockStorage);
|
|
229
|
+
if (collectionId !== undefined) {
|
|
230
|
+
promotions.push({ collectionId, blockId, actionId, rev });
|
|
231
|
+
}
|
|
194
232
|
}
|
|
195
233
|
}
|
|
234
|
+
} catch (err) {
|
|
235
|
+
// This node holds no materializable base for the block, so NO context revision
|
|
236
|
+
// can be promoted here (each builds on the one before). Leave `latest` where it
|
|
237
|
+
// is — the invariant internalCommit just enforced — and let the commit-path
|
|
238
|
+
// healing supply the content; a read must not fail for it. Every other fault
|
|
239
|
+
// still propagates.
|
|
240
|
+
if (!(err instanceof MissingBaseRevisionError)) {
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
// This node holds records PROVING the block exists (a pending it could not
|
|
244
|
+
// promote); if the block then reads as absent below, the answer is a guess,
|
|
245
|
+
// not an authoritative "never existed".
|
|
246
|
+
unavailable = 'unmaterializable';
|
|
247
|
+
log('get:promote-skipped-missing-base blockId=%s rev=%d reason=%s', blockId, err.rev, err.message);
|
|
196
248
|
}
|
|
197
249
|
});
|
|
198
250
|
}
|
|
@@ -205,7 +257,26 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
|
|
|
205
257
|
// (actionId, rev) self-heals it via storage.recover() in commit(). Not repaired lazily on
|
|
206
258
|
// the read path because get() holds no commit latch; if stale reads on unwritten blocks
|
|
207
259
|
// ever become a problem, add a latched lazy recover() here.
|
|
208
|
-
|
|
260
|
+
//
|
|
261
|
+
// getBlock() THROWS when this node holds a `latest` it cannot materialize (truncated
|
|
262
|
+
// history: "Failed to find materialized block", or a failed restore). Caught PER BLOCK so
|
|
263
|
+
// one broken block cannot fail the whole batch's Promise.all and take healthy siblings
|
|
264
|
+
// down with it. The read still fails for THIS block — TransactorSource throws
|
|
265
|
+
// BlockUnavailableError on the flagged entry — so nothing is swallowed.
|
|
266
|
+
let blockRev: Awaited<ReturnType<IBlockStorage['getBlock']>>;
|
|
267
|
+
try {
|
|
268
|
+
blockRev = await blockStorage.getBlock(context?.rev);
|
|
269
|
+
} catch (err) {
|
|
270
|
+
// NOTE: the entry drops `state.latest`, which this node does know (getLatest() does not
|
|
271
|
+
// materialize, so it does not throw). Empty state is what makes CoordinatorRepo treat the
|
|
272
|
+
// block as missing and consult the cohort — exactly the repair this block needs. If a
|
|
273
|
+
// consumer ever needs the revision behind an unavailable answer (e.g. to ask the cohort
|
|
274
|
+
// for a specific rev instead of the whole block), carry `latest` here and widen the
|
|
275
|
+
// coordinator's consult trigger to `isMissing || unavailable` so repair still fires.
|
|
276
|
+
log('get:unmaterializable blockId=%s error=%s', blockId,
|
|
277
|
+
err instanceof Error ? err.message : String(err));
|
|
278
|
+
return [blockId, { state: {}, unavailable: 'unmaterializable' } as GetBlockResult];
|
|
279
|
+
}
|
|
209
280
|
|
|
210
281
|
// Include pending action if requested — handled first so a pending-only
|
|
211
282
|
// insert (no committed revision yet) can still be served by applying the
|
|
@@ -213,6 +284,8 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
|
|
|
213
284
|
if (context?.actionId !== undefined) {
|
|
214
285
|
const pendingTransform = await blockStorage.getPendingTransaction(context.actionId);
|
|
215
286
|
if (!pendingTransform) {
|
|
287
|
+
// Caller-contract violation (the caller asserted a pending this repo never had, or
|
|
288
|
+
// cancelled) — an error, not an availability question. Deliberately NOT `unavailable`.
|
|
216
289
|
throw new Error(`Pending action ${context.actionId} not found`);
|
|
217
290
|
}
|
|
218
291
|
const block = applyTransform(blockRev?.block, pendingTransform);
|
|
@@ -221,12 +294,21 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
|
|
|
221
294
|
state: {
|
|
222
295
|
latest: await blockStorage.getLatest(),
|
|
223
296
|
pendings: [context.actionId]
|
|
224
|
-
}
|
|
225
|
-
|
|
297
|
+
},
|
|
298
|
+
// A pending applied to a missing base can materialize nothing (applyTransform drops
|
|
299
|
+
// updates with no block to apply them to) — that absence is a guess, and is flagged.
|
|
300
|
+
// A materialized block is a real answer regardless of the earlier refusal.
|
|
301
|
+
...(unavailable !== undefined && block === undefined ? { unavailable } : {})
|
|
302
|
+
} as GetBlockResult];
|
|
226
303
|
}
|
|
227
304
|
|
|
228
305
|
if (!blockRev) {
|
|
229
|
-
|
|
306
|
+
// `unavailable` distinguishes "never existed" (the common insert-probe case, no flag)
|
|
307
|
+
// from "this node cannot reconstruct it" (the promotion above refused for a missing
|
|
308
|
+
// base). A tombstoned block also lands here with meta.latest set, but it never enters
|
|
309
|
+
// the missing-base catch, so it stays an authoritative absent — keyed off the explicit
|
|
310
|
+
// flag, not off "no block".
|
|
311
|
+
return [blockId, { state: {}, ...(unavailable !== undefined ? { unavailable } : {}) } as GetBlockResult];
|
|
230
312
|
}
|
|
231
313
|
|
|
232
314
|
const pendings = await asyncIteratorToArray(blockStorage.listPendingTransactions());
|
|
@@ -279,6 +361,8 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
|
|
|
279
361
|
if (this.validatePend && request.transaction && request.operationsHash) {
|
|
280
362
|
const validationResult = await this.validatePend(request.transaction, request.operationsHash);
|
|
281
363
|
if (!validationResult.valid) {
|
|
364
|
+
// Hard rejection: the transaction itself is invalid, so no `conflict` flag — a
|
|
365
|
+
// re-read and re-pend would fail the same way and only burn the retry budget.
|
|
282
366
|
return {
|
|
283
367
|
success: false,
|
|
284
368
|
reason: validationResult.reason ?? 'Transaction validation failed'
|
|
@@ -330,16 +414,18 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
|
|
|
330
414
|
log('pend:stale actionId=%s missing=%d', request.actionId, missing.length);
|
|
331
415
|
return {
|
|
332
416
|
success: false,
|
|
417
|
+
conflict: true,
|
|
333
418
|
missing
|
|
334
419
|
};
|
|
335
420
|
}
|
|
336
421
|
|
|
337
422
|
if (pendings.length > 0) {
|
|
338
423
|
if (request.policy === 'f') { // Fail on pending actions
|
|
339
|
-
return { success: false, pending: pendings };
|
|
424
|
+
return { success: false, conflict: true, pending: pendings };
|
|
340
425
|
} else if (request.policy === 'r') { // Return populated pending actions
|
|
341
426
|
return {
|
|
342
427
|
success: false,
|
|
428
|
+
conflict: true,
|
|
343
429
|
pending: await Promise.all(pendings.map(async action => {
|
|
344
430
|
const blockStorage = this.createBlockStorage(action.blockId);
|
|
345
431
|
return {
|
|
@@ -639,14 +725,24 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
|
|
|
639
725
|
|
|
640
726
|
// Get prior materialized block if it exists
|
|
641
727
|
const latest = await storage.getLatest();
|
|
642
|
-
const priorBlock = latest
|
|
643
|
-
? (await storage.getBlock(latest.rev))?.block
|
|
644
|
-
: undefined;
|
|
728
|
+
const priorBlock = await this.readCommitBase(blockId, actionId, rev, storage, latest);
|
|
645
729
|
|
|
646
730
|
// Apply transform and save materialized block
|
|
647
731
|
// applyTransform handles undefined priorBlock correctly for inserts
|
|
648
732
|
const newBlock = applyTransform(priorBlock, transform);
|
|
649
733
|
|
|
734
|
+
// INVARIANT: `latest` must never advance past a revision this node can materialize.
|
|
735
|
+
// `applyTransform` silently drops `updates` when there is no block to apply them to, so a
|
|
736
|
+
// member that missed the block's CREATING revision would otherwise record rev N while storing
|
|
737
|
+
// nothing to serve it from. `latest === undefined` is precisely the "nothing below to fall
|
|
738
|
+
// back to" case: materializeBlock's descending walk needs some materialization at or below the
|
|
739
|
+
// target, and with no prior revision there is none. With a prior `latest` an absent newBlock is
|
|
740
|
+
// a legitimate tombstone (the walk resolves to an earlier materialization), so it stays allowed.
|
|
741
|
+
if (!newBlock && latest === undefined) {
|
|
742
|
+
return await this.refuseMissingBase(blockId, actionId, rev, storage,
|
|
743
|
+
'no committed revision to apply the transform to');
|
|
744
|
+
}
|
|
745
|
+
|
|
650
746
|
if (newBlock) {
|
|
651
747
|
await storage.saveMaterializedBlock(actionId, newBlock);
|
|
652
748
|
}
|
|
@@ -682,6 +778,67 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
|
|
|
682
778
|
// undefined so the caller skips it rather than emitting a bogus event.
|
|
683
779
|
return newBlock?.header.collectionId ?? priorBlock?.header.collectionId;
|
|
684
780
|
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* The materialization this commit builds on: the block at `latest`, or `undefined` when the block
|
|
784
|
+
* holds no committed revision yet (the normal insert case).
|
|
785
|
+
*
|
|
786
|
+
* `getBlock` THROWS when this node holds a `latest` it cannot materialize — a block already wedged
|
|
787
|
+
* by a pre-fix commit, or by truncated history. That is the same divergence as having no base at
|
|
788
|
+
* all, so it is translated into {@link MissingBaseRevisionError} rather than surfacing as an opaque
|
|
789
|
+
* storage fault: the healing path can then repair the block instead of the fault resetting the
|
|
790
|
+
* cluster stream, and a wedged node recovers on the next write touching the block.
|
|
791
|
+
*
|
|
792
|
+
* The catch is deliberately UNNARROWED — it also absorbs a transient fault (a raw-storage read
|
|
793
|
+
* error, a `restoreCallback` timeout on a block whose `ranges` do not cover its own `latest`).
|
|
794
|
+
* BlockStorage reports every one of these as a bare `Error`, so they cannot be told apart here,
|
|
795
|
+
* and treating them as divergence is the safe default: this node genuinely cannot materialize the
|
|
796
|
+
* base right now, and the cluster's policy is to heal rather than throw out of consensus. The
|
|
797
|
+
* price is that a transient fault ALSO drops the pending (see {@link refuseMissingBase}), so the
|
|
798
|
+
* block converges by replication instead of by a replay the retry could have done. Narrowing this
|
|
799
|
+
* would require typed faults out of BlockStorage; until then, prefer the tolerant reading.
|
|
800
|
+
*/
|
|
801
|
+
private async readCommitBase(
|
|
802
|
+
blockId: BlockId,
|
|
803
|
+
actionId: ActionId,
|
|
804
|
+
rev: number,
|
|
805
|
+
storage: IBlockStorage,
|
|
806
|
+
latest: ActionRev | undefined
|
|
807
|
+
): Promise<IBlock | undefined> {
|
|
808
|
+
if (!latest) {
|
|
809
|
+
return undefined;
|
|
810
|
+
}
|
|
811
|
+
try {
|
|
812
|
+
return (await storage.getBlock(latest.rev))?.block;
|
|
813
|
+
} catch (err) {
|
|
814
|
+
log('commit:unmaterializable-base blockId=%s baseRev=%d error=%s', blockId, latest.rev,
|
|
815
|
+
err instanceof Error ? err.message : String(err));
|
|
816
|
+
return await this.refuseMissingBase(blockId, actionId, rev, storage,
|
|
817
|
+
`local rev ${latest.rev} is not materializable here`);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Refuse a commit this node cannot materialize. Always throws {@link MissingBaseRevisionError};
|
|
823
|
+
* nothing durable has been written at this point, so the block is left exactly as it was minus the
|
|
824
|
+
* pending record.
|
|
825
|
+
*
|
|
826
|
+
* The pending is dropped because it can never be promoted here: promotion needs a base this node
|
|
827
|
+
* must obtain out-of-band, and once the healing path lands that revision `latest` is already >= rev,
|
|
828
|
+
* so a commit retry partitions the block as already-done/stale and never revisits the pending.
|
|
829
|
+
* Leaving it would also report a phantom conflicting action from {@link pend} for every later write.
|
|
830
|
+
*/
|
|
831
|
+
private async refuseMissingBase(
|
|
832
|
+
blockId: BlockId,
|
|
833
|
+
actionId: ActionId,
|
|
834
|
+
rev: number,
|
|
835
|
+
storage: IBlockStorage,
|
|
836
|
+
detail: string
|
|
837
|
+
): Promise<never> {
|
|
838
|
+
await storage.deletePendingTransaction(actionId);
|
|
839
|
+
log('commit:missing-base blockId=%s rev=%d actionId=%s detail=%s', blockId, rev, actionId, detail);
|
|
840
|
+
throw new MissingBaseRevisionError(blockId, rev, detail);
|
|
841
|
+
}
|
|
685
842
|
}
|
|
686
843
|
|
|
687
844
|
/**
|
package/src/sync/service.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ComponentLogger, Startable } from '@libp2p/interface';
|
|
1
|
+
import type { ComponentLogger, Connection, Startable } from '@libp2p/interface';
|
|
2
2
|
import type { IRepo } from '@optimystic/db-core';
|
|
3
3
|
import { buildSyncProtocol, type SyncRequest, type SyncResponse } from './protocol.js';
|
|
4
4
|
import { pipe } from 'it-pipe';
|
|
@@ -6,8 +6,9 @@ import { toString as u8ToString } from 'uint8arrays/to-string';
|
|
|
6
6
|
import * as lp from 'it-length-prefixed';
|
|
7
7
|
import { MAX_CONTROL_MESSAGE_BYTES } from '../protocol-limits.js';
|
|
8
8
|
import type { Uint8ArrayList } from 'uint8arraylist';
|
|
9
|
+
import { createInboundStreamAuthorization, type InboundStreamAuthorization, type InboundStreamAuthorizationInit } from '../inbound-authorization.js';
|
|
9
10
|
|
|
10
|
-
export interface SyncServiceInit {
|
|
11
|
+
export interface SyncServiceInit extends InboundStreamAuthorizationInit {
|
|
11
12
|
protocolPrefix?: string;
|
|
12
13
|
}
|
|
13
14
|
|
|
@@ -35,6 +36,8 @@ export class SyncService implements Startable {
|
|
|
35
36
|
private readonly protocol: string;
|
|
36
37
|
private readonly repo: IRepo;
|
|
37
38
|
private readonly registrar: { handle: (...args: any[]) => Promise<void>, unhandle: (...args: any[]) => Promise<void> };
|
|
39
|
+
/** Optional embedder authorization gate; `undefined` (the default) means no check runs. */
|
|
40
|
+
private readonly authorization: InboundStreamAuthorization | undefined;
|
|
38
41
|
|
|
39
42
|
constructor(
|
|
40
43
|
components: SyncServiceComponents,
|
|
@@ -44,6 +47,7 @@ export class SyncService implements Startable {
|
|
|
44
47
|
this.protocol = buildSyncProtocol(init.protocolPrefix ?? '');
|
|
45
48
|
this.repo = components.repo;
|
|
46
49
|
this.registrar = components.registrar;
|
|
50
|
+
this.authorization = createInboundStreamAuthorization(init, this.protocol, (msg, ...args) => this.log.error(msg, ...args));
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
async start(): Promise<void> {
|
|
@@ -62,21 +66,22 @@ export class SyncService implements Startable {
|
|
|
62
66
|
this.log('Sync service stopped');
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
/**
|
|
66
|
-
* Handle an incoming sync request stream.
|
|
67
|
-
* Uses a streaming pipeline (like the repo service) to process the
|
|
68
|
-
* first request and yield a response without waiting for the client
|
|
69
|
-
* to close its write side — avoids a read/write deadlock.
|
|
70
|
-
*/
|
|
71
69
|
/**
|
|
72
70
|
* Handle an incoming sync request stream.
|
|
73
71
|
* Uses a streaming pipeline (like the repo service) to process the
|
|
74
72
|
* request and yield a response immediately — avoids a read/write deadlock.
|
|
75
73
|
*/
|
|
76
|
-
private handleSyncRequest(stream: any): void {
|
|
74
|
+
private handleSyncRequest(stream: any, connection?: Connection): void {
|
|
77
75
|
const self = this;
|
|
76
|
+
// The registrar handler receives the stream (or, in an older shape, a `{ stream }`
|
|
77
|
+
// wrapper) directly; resolve it once so the authorization abort, the pipeline and the
|
|
78
|
+
// error path all act on the same object.
|
|
79
|
+
const actualStream = stream.stream ?? stream;
|
|
78
80
|
void (async () => {
|
|
79
81
|
try {
|
|
82
|
+
// Authorization runs before ANY decoding or execution. Guarded on the field so a
|
|
83
|
+
// node without a predicate keeps the original path untouched.
|
|
84
|
+
if (self.authorization && await self.authorization.deny(actualStream, connection?.remotePeer?.toString())) return;
|
|
80
85
|
const processStream = async function* (source: AsyncIterable<Uint8ArrayList> | Iterable<Uint8ArrayList>) {
|
|
81
86
|
for await (const msg of source) {
|
|
82
87
|
const json = u8ToString(msg.subarray(), 'utf8');
|
|
@@ -111,8 +116,6 @@ export class SyncService implements Startable {
|
|
|
111
116
|
};
|
|
112
117
|
|
|
113
118
|
// Use the same streaming pipeline pattern as the repo service.
|
|
114
|
-
// The registrar handler receives the stream (or data object) directly.
|
|
115
|
-
const actualStream = stream.stream ?? stream;
|
|
116
119
|
const responses = pipe(
|
|
117
120
|
actualStream,
|
|
118
121
|
(source: any) => lp.decode(source, { maxDataLength: MAX_CONTROL_MESSAGE_BYTES }),
|
|
@@ -125,7 +128,6 @@ export class SyncService implements Startable {
|
|
|
125
128
|
await actualStream.close();
|
|
126
129
|
} catch (error) {
|
|
127
130
|
self.log.error('Error handling sync request:', error);
|
|
128
|
-
const actualStream = stream.stream ?? stream;
|
|
129
131
|
try { actualStream.abort(error instanceof Error ? error : new Error(String(error))); } catch { /* ignore */ }
|
|
130
132
|
}
|
|
131
133
|
})();
|
|
@@ -107,6 +107,32 @@ export interface Mesh {
|
|
|
107
107
|
keyNetwork: IKeyNetwork;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Pull a block's committed content from a sibling cohort node into `storageRepo` — the mesh analogue
|
|
112
|
+
* of `libp2p-node-base`'s `createReconcileBlock` (SyncClient archive fetch + `saveReplicatedBlock`).
|
|
113
|
+
*
|
|
114
|
+
* Deliberately shared by BOTH callers, exactly as the live node shares one callback between them: the
|
|
115
|
+
* commit path (`clusterMember.reconcileBlock`, for a block committed without a materializable base)
|
|
116
|
+
* and the read path (`CoordinatorRepo.acquireBlockFromCohort`, for a corroborated revision the reader
|
|
117
|
+
* cannot promote locally). Stateless, so it is simply rebuilt per caller.
|
|
118
|
+
*
|
|
119
|
+
* `nodes` is captured by reference and is fully populated by the time either caller invokes it.
|
|
120
|
+
*/
|
|
121
|
+
const makeReconcileBlock = (nodes: MeshNode[], selfPeerId: string, storageRepo: StorageRepo): ReconcileBlockCallback =>
|
|
122
|
+
async (blockId, committed, cohortPeerIds) => {
|
|
123
|
+
for (const peerIdStr of cohortPeerIds) {
|
|
124
|
+
if (peerIdStr === selfPeerId) continue;
|
|
125
|
+
const target = nodes.find(n => n.peerId.toString() === peerIdStr);
|
|
126
|
+
if (!target) continue;
|
|
127
|
+
const result = await target.storageRepo.get({ blockIds: [blockId] }, { skipClusterFetch: true } as any);
|
|
128
|
+
const entry = result[blockId];
|
|
129
|
+
const latest = entry?.state?.latest;
|
|
130
|
+
if (!latest || !entry?.block || latest.rev < committed.rev) continue;
|
|
131
|
+
await storageRepo.saveReplicatedBlock(blockId, entry.block, latest);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
110
136
|
/**
|
|
111
137
|
* Creates N interconnected mesh nodes with real components and mock transport.
|
|
112
138
|
* ClusterClient calls route directly to target ClusterMember instances.
|
|
@@ -125,8 +151,6 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
|
|
|
125
151
|
// Build nodes array (partially — coordinatorRepo added after keyNetwork is ready)
|
|
126
152
|
const nodes: MeshNode[] = [];
|
|
127
153
|
const peerNetwork = new MockPeerNetwork();
|
|
128
|
-
// Map peerId → rawStorage for data sync simulation in clusterLatestCallback
|
|
129
|
-
const rawStorages = new Map<string, IRawStorage>();
|
|
130
154
|
|
|
131
155
|
// Phase 1: create storage + cluster members
|
|
132
156
|
let nodeIndex = 0;
|
|
@@ -134,7 +158,6 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
|
|
|
134
158
|
const rawStorage = options.rawStorageFactory
|
|
135
159
|
? options.rawStorageFactory(nodeIndex)
|
|
136
160
|
: new MemoryRawStorage();
|
|
137
|
-
rawStorages.set(peerId.toString(), rawStorage);
|
|
138
161
|
nodeIndex++;
|
|
139
162
|
const storageRepo = new StorageRepo(
|
|
140
163
|
(blockId: BlockId) => new BlockStorage(blockId, rawStorage)
|
|
@@ -152,24 +175,9 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
|
|
|
152
175
|
allowUnvalidatedSmallCluster: true
|
|
153
176
|
};
|
|
154
177
|
|
|
155
|
-
// Active reconciliation
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
// fetch + saveReplicatedBlock path wired in `libp2p-node-base`. `nodes` is captured
|
|
159
|
-
// by reference and fully populated by the time the callback is actually invoked.
|
|
160
|
-
const reconcileBlock: ReconcileBlockCallback = async (blockId, committed, cohortPeerIds) => {
|
|
161
|
-
for (const peerIdStr of cohortPeerIds) {
|
|
162
|
-
if (peerIdStr === peerId.toString()) continue;
|
|
163
|
-
const target = nodes.find(n => n.peerId.toString() === peerIdStr);
|
|
164
|
-
if (!target) continue;
|
|
165
|
-
const result = await target.storageRepo.get({ blockIds: [blockId] }, { skipClusterFetch: true } as any);
|
|
166
|
-
const entry = result[blockId];
|
|
167
|
-
const latest = entry?.state?.latest;
|
|
168
|
-
if (!latest || !entry?.block || latest.rev < committed.rev) continue;
|
|
169
|
-
await storageRepo.saveReplicatedBlock(blockId, entry.block, latest);
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
};
|
|
178
|
+
// Active reconciliation: when a member commits a block it never pended (cohort drift),
|
|
179
|
+
// pull the committed revision from a sibling cohort node that holds it.
|
|
180
|
+
const reconcileBlock = makeReconcileBlock(nodes, peerId.toString(), storageRepo);
|
|
173
181
|
|
|
174
182
|
const member = clusterMember({
|
|
175
183
|
storageRepo,
|
|
@@ -208,10 +216,12 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
|
|
|
208
216
|
};
|
|
209
217
|
|
|
210
218
|
for (const node of nodes) {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
//
|
|
214
|
-
//
|
|
219
|
+
// Per-node callback: reports the queried peer's latest revision, and NOTHING else. It used to
|
|
220
|
+
// also write the peer's block into local storage ("simulate data sync"), which made every
|
|
221
|
+
// read-repair assertion on this harness observe a convergence the production callback does not
|
|
222
|
+
// provide — masking exactly the defect that ticket `read-repair-cannot-transfer-block-content`
|
|
223
|
+
// existed to expose. Transfer now happens where it does in production: through
|
|
224
|
+
// `acquireBlockFromCohort` below, gated on a corroborated revision.
|
|
215
225
|
const clusterLatestCallback: ClusterLatestCallback = async (peerId: PeerId, blockId: BlockId, context?): Promise<ActionRev | undefined> => {
|
|
216
226
|
const target = nodes.find(n => n.peerId.equals(peerId));
|
|
217
227
|
if (!target) return undefined;
|
|
@@ -219,28 +229,7 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
|
|
|
219
229
|
{ blockIds: [blockId], context },
|
|
220
230
|
{ skipClusterFetch: true } as any
|
|
221
231
|
);
|
|
222
|
-
|
|
223
|
-
const latest = entry?.state?.latest;
|
|
224
|
-
|
|
225
|
-
// Simulate data sync: replicate committed block data to local storage
|
|
226
|
-
if (latest && entry?.block) {
|
|
227
|
-
const localBlockStorage = new BlockStorage(blockId as BlockId, localRawStorage);
|
|
228
|
-
const localLatest = await localBlockStorage.getLatest();
|
|
229
|
-
if (!localLatest || localLatest.rev < latest.rev) {
|
|
230
|
-
// Ensure metadata exists
|
|
231
|
-
const meta = await localRawStorage.getMetadata(blockId as BlockId);
|
|
232
|
-
if (!meta) {
|
|
233
|
-
// Seed empty ranges (honest: nothing reconstructible yet); the setLatest
|
|
234
|
-
// below merges [latest.rev, latest.rev+1] once the revision is persisted.
|
|
235
|
-
await localRawStorage.saveMetadata(blockId as BlockId, { latest: undefined, ranges: [] });
|
|
236
|
-
}
|
|
237
|
-
await localBlockStorage.saveMaterializedBlock(latest.actionId, entry.block);
|
|
238
|
-
await localBlockStorage.saveRevision(latest.rev, latest.actionId);
|
|
239
|
-
await localBlockStorage.setLatest(latest);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
return latest;
|
|
232
|
+
return result[blockId]?.state?.latest;
|
|
244
233
|
};
|
|
245
234
|
// Wrap key network to include self in findCluster (matches real Libp2pKeyPeerNetwork behavior)
|
|
246
235
|
const nodeKeyNetwork: IKeyNetwork = {
|
|
@@ -259,7 +248,7 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
|
|
|
259
248
|
};
|
|
260
249
|
const factory = coordinatorRepo(
|
|
261
250
|
nodeKeyNetwork,
|
|
262
|
-
|
|
251
|
+
createClusterClient,
|
|
263
252
|
{
|
|
264
253
|
clusterSize: options.clusterSize ?? nodeCount,
|
|
265
254
|
superMajorityThreshold: options.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD,
|
|
@@ -273,13 +262,30 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
|
|
|
273
262
|
storageRepo: node.storageRepo,
|
|
274
263
|
localCluster: node.clusterMember,
|
|
275
264
|
localPeerId: node.peerId,
|
|
276
|
-
clusterLatestCallback
|
|
265
|
+
clusterLatestCallback,
|
|
266
|
+
// The read path's transfer mechanism — the same callback the member uses on the commit path,
|
|
267
|
+
// mirroring how `libp2p-node-base` shares one `reconcileBlock` between both.
|
|
268
|
+
acquireBlockFromCohort: makeReconcileBlock(nodes, node.peerId.toString(), node.storageRepo)
|
|
277
269
|
});
|
|
278
270
|
}
|
|
279
271
|
|
|
280
272
|
return { nodes, failures, keyNetwork };
|
|
281
273
|
}
|
|
282
274
|
|
|
275
|
+
/**
|
|
276
|
+
* The nodes the key network keeps OUT of `blockId`'s cohort — peers that receive none of the
|
|
277
|
+
* block's cluster traffic, and so hold none of its content until something repairs them.
|
|
278
|
+
*
|
|
279
|
+
* Peer ids are generated fresh per mesh, so which node is responsible for a given block is random
|
|
280
|
+
* from run to run: in a 3-node `responsibilityK: 1` mesh, `nodes[1]` is the block's sole responsible
|
|
281
|
+
* peer about a third of the time, and then it receives the writer's commit directly. A test that
|
|
282
|
+
* needs a genuinely non-responsible node has to ask the routing layer rather than assume an index.
|
|
283
|
+
*/
|
|
284
|
+
export async function nonResponsibleNodes(mesh: Mesh, blockId: string): Promise<MeshNode[]> {
|
|
285
|
+
const cohort = await mesh.keyNetwork.findCluster(new TextEncoder().encode(blockId));
|
|
286
|
+
return mesh.nodes.filter(node => !(node.peerId.toString() in cohort));
|
|
287
|
+
}
|
|
288
|
+
|
|
283
289
|
export interface BuildTransactorOptions {
|
|
284
290
|
timeoutMs?: number;
|
|
285
291
|
abortOrCancelTimeoutMs?: number;
|