@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/cluster/service.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { encodePeers, type RedirectPayload } from '../repo/redirect.js';
|
|
|
7
7
|
import { toClusterErrorEnvelope } from './cluster-error.js';
|
|
8
8
|
import { MAX_CONTROL_MESSAGE_BYTES } from '../protocol-limits.js';
|
|
9
9
|
import type { Uint8ArrayList } from 'uint8arraylist';
|
|
10
|
+
import { createInboundStreamAuthorization, type InboundStreamAuthorization, type InboundStreamAuthorizationInit } from '../inbound-authorization.js';
|
|
10
11
|
|
|
11
12
|
interface BaseComponents {
|
|
12
13
|
logger: { forComponent: (name: string) => Logger },
|
|
@@ -31,7 +32,7 @@ export interface ClusterServiceComponents extends BaseComponents {
|
|
|
31
32
|
getConnectionAddrs?: (peerId: PeerId) => string[]
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
export interface ClusterServiceInit {
|
|
35
|
+
export interface ClusterServiceInit extends InboundStreamAuthorizationInit {
|
|
35
36
|
protocol?: string,
|
|
36
37
|
protocolPrefix?: string,
|
|
37
38
|
maxInboundStreams?: number,
|
|
@@ -65,6 +66,8 @@ export class ClusterService implements Startable {
|
|
|
65
66
|
private running: boolean;
|
|
66
67
|
/** Responsibility K - small-mesh bypass threshold for redirect decisions */
|
|
67
68
|
private readonly responsibilityK: number;
|
|
69
|
+
/** Optional embedder authorization gate; `undefined` (the default) means no check runs. */
|
|
70
|
+
private readonly authorization: InboundStreamAuthorization | undefined;
|
|
68
71
|
|
|
69
72
|
constructor(components: ClusterServiceComponents, init: ClusterServiceInit = {}) {
|
|
70
73
|
this.components = components;
|
|
@@ -75,6 +78,7 @@ export class ClusterService implements Startable {
|
|
|
75
78
|
this.cluster = components.cluster;
|
|
76
79
|
this.running = false;
|
|
77
80
|
this.responsibilityK = init.responsibilityK ?? 1;
|
|
81
|
+
this.authorization = createInboundStreamAuthorization(init, this.protocol, (msg, ...args) => this.log.error(msg, ...args));
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
readonly [Symbol.toStringTag] = '@libp2p/cluster';
|
|
@@ -180,8 +184,8 @@ export class ClusterService implements Startable {
|
|
|
180
184
|
throw new Error(`Unknown operation: ${message.operation}`);
|
|
181
185
|
}
|
|
182
186
|
|
|
183
|
-
private handleIncomingStream(stream: Stream, connection
|
|
184
|
-
const peerId = connection
|
|
187
|
+
private handleIncomingStream(stream: Stream, connection?: Connection): void {
|
|
188
|
+
const peerId = connection?.remotePeer;
|
|
185
189
|
|
|
186
190
|
const processStream = async function* (this: ClusterService, source: AsyncIterable<Uint8ArrayList>) {
|
|
187
191
|
for await (const msg of source) {
|
|
@@ -215,6 +219,9 @@ export class ClusterService implements Startable {
|
|
|
215
219
|
|
|
216
220
|
void (async () => {
|
|
217
221
|
try {
|
|
222
|
+
// Authorization runs before ANY decoding or execution. Guarded on the field so a
|
|
223
|
+
// node without a predicate keeps the original path untouched.
|
|
224
|
+
if (this.authorization && await this.authorization.deny(stream, peerId?.toString())) return;
|
|
218
225
|
const responses = pipe(
|
|
219
226
|
stream,
|
|
220
227
|
(source) => lpDecode(source, { maxDataLength: MAX_CONTROL_MESSAGE_BYTES }),
|
|
@@ -221,6 +221,14 @@ export class SpreadOnChurnMonitor implements Startable {
|
|
|
221
221
|
// Read block data from local storage
|
|
222
222
|
const result = await this.deps.repo.get({ blockIds: [blockId] })
|
|
223
223
|
const blockResult = result[blockId]
|
|
224
|
+
if (blockResult?.unavailable !== undefined) {
|
|
225
|
+
// The repo could not work out whether it still holds this block (unmaterializable
|
|
226
|
+
// history / failed restore). Untracking on that answer would silently drop the block
|
|
227
|
+
// from the spread set on a guess, and only a later re-commit would put it back — so
|
|
228
|
+
// keep it tracked and let the next sweep (or a heal) settle it.
|
|
229
|
+
log('unavailable block=%s reason=%s (keeping tracked)', blockId, blockResult.unavailable)
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
224
232
|
if (!blockResult?.block) {
|
|
225
233
|
// The block has left local storage. No deletion event exists today to evict it
|
|
226
234
|
// from the tracked set, so prune here. Deleting the current element of a Set mid
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional, embedder-supplied authorization for inbound protocol streams.
|
|
3
|
+
*
|
|
4
|
+
* The `repo`, `cluster`, `sync` and `block-transfer` services otherwise go straight from
|
|
5
|
+
* "stream opened" to "decode and execute", so any peer that can open a connection can issue
|
|
6
|
+
* database operations. An application that owns a *private* database (only nodes it admitted
|
|
7
|
+
* may read or write) needs a seam ahead of decoding; this is that seam.
|
|
8
|
+
*
|
|
9
|
+
* Contract:
|
|
10
|
+
*
|
|
11
|
+
* - **Absent predicate → today's behavior exactly.** Every service holds
|
|
12
|
+
* `InboundStreamAuthorization | undefined`; when it is `undefined` the handler never
|
|
13
|
+
* awaits anything extra and no code on this path runs.
|
|
14
|
+
* - **Supplied predicate → fail closed.** Only a literal `true` allows the stream. `false`,
|
|
15
|
+
* a throw, a rejection, a timeout, or an unidentifiable remote peer all deny, and denial
|
|
16
|
+
* aborts the stream *before* any frame is decoded or any operation executed.
|
|
17
|
+
* - **Called once per inbound stream**, not once per operation — which is equivalent here
|
|
18
|
+
* because all four protocols are strictly one request per stream (each handler's generator
|
|
19
|
+
* `return`s after the first response, so a second queued frame is never read).
|
|
20
|
+
* - **Peer id encoding**: the predicate receives `connection.remotePeer.toString()` — the
|
|
21
|
+
* libp2p base58btc/CIDv1 peer-id string (e.g. `12D3KooW…`), the same form
|
|
22
|
+
* `PeerId.toString()` produces everywhere else in this codebase. Compare against that,
|
|
23
|
+
* never against a multiaddr, a public-key hash, or a base64 encoding.
|
|
24
|
+
* - **What a caller observes**: a stream reset. Denial is deliberately *not* reported on the
|
|
25
|
+
* wire — telling an unauthorized peer "you are not a member" confirms membership state to
|
|
26
|
+
* exactly the party the embedder decided not to trust, and the four protocols have four
|
|
27
|
+
* different response shapes with no common error frame. The denial is instead loud on the
|
|
28
|
+
* *denying* node: it is logged with the peer id, protocol and reason, and the stream is
|
|
29
|
+
* aborted with an {@link UnauthorizedInboundStreamError} carrying
|
|
30
|
+
* {@link INBOUND_STREAM_UNAUTHORIZED_CODE}, so local diagnostics can tell a denial apart
|
|
31
|
+
* from a transport fault.
|
|
32
|
+
* - **Cost**: the predicate sits in the hot path of every inbound stream, ahead of the work
|
|
33
|
+
* that stream would do. Embedders are expected to make it cheap — an in-memory set lookup —
|
|
34
|
+
* and to memoize anything that would otherwise hit storage or the network per stream.
|
|
35
|
+
*
|
|
36
|
+
* NOTE: denial is stateless and unthrottled — a denied peer may reopen streams as fast as
|
|
37
|
+
* libp2p's per-connection `maxInboundStreams` allows, and nothing here records the denial. That
|
|
38
|
+
* is fine while the predicate is an in-memory lookup. If a denied peer ever shows up as load, the
|
|
39
|
+
* fix is upstream of this module, not inside it: feed denials into `PeerReputationService`, or
|
|
40
|
+
* refuse the peer at the connection level with `NodeOptions.connectionGater`.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Predicate deciding whether `remotePeerId` may open `protocol` on this node.
|
|
45
|
+
*
|
|
46
|
+
* @param remotePeerId the dialing peer's `PeerId.toString()` (base58btc / CIDv1, `12D3KooW…`)
|
|
47
|
+
* @param protocol the full protocol id the stream was opened on, e.g.
|
|
48
|
+
* `/optimystic/<network>/repo/1.0.0`
|
|
49
|
+
* @returns `true` to allow. Anything else — `false`, a throw, or a rejection — denies.
|
|
50
|
+
*/
|
|
51
|
+
export type AuthorizeInboundStream = (remotePeerId: string, protocol: string) => Promise<boolean> | boolean;
|
|
52
|
+
|
|
53
|
+
/** Stable error code carried by {@link UnauthorizedInboundStreamError}. */
|
|
54
|
+
export const INBOUND_STREAM_UNAUTHORIZED_CODE = 'ERR_INBOUND_STREAM_UNAUTHORIZED';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The reason an inbound stream was aborted by the authorization gate. Distinct from a
|
|
58
|
+
* transport fault so the denying node's logs and any local abort-reason inspection can tell
|
|
59
|
+
* the two apart; the remote only ever sees the stream reset (see the module doc).
|
|
60
|
+
*/
|
|
61
|
+
export class UnauthorizedInboundStreamError extends Error {
|
|
62
|
+
readonly code = INBOUND_STREAM_UNAUTHORIZED_CODE;
|
|
63
|
+
constructor(remotePeerId: string, protocol: string, reason: string) {
|
|
64
|
+
super(`inbound stream denied: peer=${remotePeerId} protocol=${protocol} reason=${reason}`);
|
|
65
|
+
this.name = 'UnauthorizedInboundStreamError';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* How long the predicate may take before the stream is denied. A hanging predicate would
|
|
71
|
+
* otherwise pin an inbound stream slot indefinitely; timing out is the fail-closed reading of
|
|
72
|
+
* "we could not establish that this peer is allowed".
|
|
73
|
+
*/
|
|
74
|
+
export const DEFAULT_INBOUND_AUTHORIZATION_TIMEOUT_MS = 5_000;
|
|
75
|
+
|
|
76
|
+
/** The slice of a service's init that configures this gate. Mixed into all four service inits. */
|
|
77
|
+
export interface InboundStreamAuthorizationInit {
|
|
78
|
+
/**
|
|
79
|
+
* Optional predicate consulted once per inbound stream, before any decoding or execution.
|
|
80
|
+
* Absent → no check at all. See {@link AuthorizeInboundStream}.
|
|
81
|
+
*/
|
|
82
|
+
authorizeInboundStream?: AuthorizeInboundStream;
|
|
83
|
+
/**
|
|
84
|
+
* Deadline for {@link InboundStreamAuthorizationInit.authorizeInboundStream}; expiry denies
|
|
85
|
+
* the stream. Default {@link DEFAULT_INBOUND_AUTHORIZATION_TIMEOUT_MS}.
|
|
86
|
+
*/
|
|
87
|
+
authorizeInboundStreamTimeoutMs?: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The part of a libp2p `Stream` this gate needs: the ability to tear it down. */
|
|
91
|
+
interface AbortableStream {
|
|
92
|
+
abort: (err: Error) => void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Log sink shape shared by the component loggers and the `debug` loggers the services use. */
|
|
96
|
+
export type AuthorizationLog = (message: string, ...args: unknown[]) => void;
|
|
97
|
+
|
|
98
|
+
/** Marker resolved by the deadline race when the predicate has not settled in time. */
|
|
99
|
+
const TIMED_OUT = Symbol('inbound-authorization-timeout');
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A configured authorization gate for one service's protocol. Constructed only when the
|
|
103
|
+
* embedder supplied a predicate, so a service holding `undefined` runs the original code path.
|
|
104
|
+
*/
|
|
105
|
+
export class InboundStreamAuthorization {
|
|
106
|
+
constructor(
|
|
107
|
+
private readonly authorize: AuthorizeInboundStream,
|
|
108
|
+
private readonly protocol: string,
|
|
109
|
+
private readonly timeoutMs: number,
|
|
110
|
+
private readonly log: AuthorizationLog
|
|
111
|
+
) { }
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Consult the predicate for one inbound stream and abort the stream if it is not allowed.
|
|
115
|
+
*
|
|
116
|
+
* @returns `true` when the stream was denied — the caller must return immediately without
|
|
117
|
+
* decoding anything — and `false` when it may proceed.
|
|
118
|
+
*/
|
|
119
|
+
async deny(stream: AbortableStream, remotePeerId: string | undefined): Promise<boolean> {
|
|
120
|
+
// No identifiable remote → the predicate cannot be asked, so we cannot establish
|
|
121
|
+
// authorization. Fail closed rather than fall through to execution.
|
|
122
|
+
if (remotePeerId === undefined) {
|
|
123
|
+
return this.abort(stream, '<unidentified>', 'no remote peer id on the inbound connection');
|
|
124
|
+
}
|
|
125
|
+
const verdict = await this.decide(remotePeerId);
|
|
126
|
+
return verdict.allowed ? false : this.abort(stream, remotePeerId, verdict.reason);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Run the predicate under its deadline, converting every failure mode into a denial. */
|
|
130
|
+
private async decide(remotePeerId: string): Promise<{ allowed: true } | { allowed: false, reason: string }> {
|
|
131
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
132
|
+
try {
|
|
133
|
+
const verdict = this.authorize(remotePeerId, this.protocol);
|
|
134
|
+
// A synchronous predicate needs no timer at all.
|
|
135
|
+
if (typeof verdict === 'boolean') {
|
|
136
|
+
return verdict ? { allowed: true } : { allowed: false, reason: 'predicate returned false' };
|
|
137
|
+
}
|
|
138
|
+
const decision = await Promise.race([
|
|
139
|
+
verdict,
|
|
140
|
+
new Promise<typeof TIMED_OUT>(resolve => { timer = setTimeout(() => resolve(TIMED_OUT), this.timeoutMs); })
|
|
141
|
+
]);
|
|
142
|
+
if (decision === TIMED_OUT) {
|
|
143
|
+
return { allowed: false, reason: `predicate did not settle within ${this.timeoutMs}ms` };
|
|
144
|
+
}
|
|
145
|
+
return decision === true ? { allowed: true } : { allowed: false, reason: 'predicate returned false' };
|
|
146
|
+
} catch (err) {
|
|
147
|
+
// A throwing predicate is a bug in the embedder, not permission to proceed: log it
|
|
148
|
+
// (never swallow) and deny.
|
|
149
|
+
this.log('authorization predicate threw for peer=%s protocol=%s - %o', remotePeerId, this.protocol, err);
|
|
150
|
+
return { allowed: false, reason: `predicate threw: ${err instanceof Error ? err.message : String(err)}` };
|
|
151
|
+
} finally {
|
|
152
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Log the denial and tear the stream down. Always returns `true` (denied). */
|
|
157
|
+
private abort(stream: AbortableStream, remotePeerId: string, reason: string): true {
|
|
158
|
+
const error = new UnauthorizedInboundStreamError(remotePeerId, this.protocol, reason);
|
|
159
|
+
this.log('inbound stream denied peer=%s protocol=%s reason=%s', remotePeerId, this.protocol, reason);
|
|
160
|
+
try {
|
|
161
|
+
stream.abort(error);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
// The stream may already be torn down; the denial itself still stands.
|
|
164
|
+
this.log('aborting a denied stream failed peer=%s protocol=%s - %o', remotePeerId, this.protocol, err);
|
|
165
|
+
}
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Build the gate for a service, or `undefined` when the embedder supplied no predicate.
|
|
172
|
+
*
|
|
173
|
+
* Returning `undefined` (rather than an always-allow gate) is what makes the absent-predicate
|
|
174
|
+
* case genuinely free: the call site guards on the field and never awaits.
|
|
175
|
+
*/
|
|
176
|
+
export function createInboundStreamAuthorization(
|
|
177
|
+
init: InboundStreamAuthorizationInit,
|
|
178
|
+
protocol: string,
|
|
179
|
+
log: AuthorizationLog
|
|
180
|
+
): InboundStreamAuthorization | undefined {
|
|
181
|
+
if (init.authorizeInboundStream === undefined) {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
return new InboundStreamAuthorization(
|
|
185
|
+
init.authorizeInboundStream,
|
|
186
|
+
protocol,
|
|
187
|
+
init.authorizeInboundStreamTimeoutMs ?? DEFAULT_INBOUND_AUTHORIZATION_TIMEOUT_MS,
|
|
188
|
+
log
|
|
189
|
+
);
|
|
190
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export * from "./cluster/rebalance-monitor.js";
|
|
|
6
6
|
export * from "./cluster/spread-on-churn.js";
|
|
7
7
|
export * from "./cluster/block-transfer.js";
|
|
8
8
|
export * from "./cluster/block-transfer-service.js";
|
|
9
|
+
export * from "./inbound-authorization.js";
|
|
9
10
|
export * from "./protocol-client.js";
|
|
10
11
|
export * from "./repo/client.js";
|
|
11
12
|
export * from "./repo/cluster-coordinator.js";
|
|
@@ -299,7 +299,25 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
|
|
|
299
299
|
return { allow: true, reason: 'extended-isolation', warn: true };
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
+
/**
|
|
303
|
+
* Memoize the coordinator for a key. A pick of SELF is deliberately ignored — the
|
|
304
|
+
* cache is consulted ahead of every selection tier, so a self entry would keep the
|
|
305
|
+
* key routed at our own (possibly stale) replica for the full TTL long after a
|
|
306
|
+
* better-placed peer became reachable, and would return self without re-consulting
|
|
307
|
+
* {@link shouldAllowSelfCoordination}, letting a partitioned node silently serve its
|
|
308
|
+
* own data. Self needs no memoizing anyway: every tier that can select it re-derives
|
|
309
|
+
* it from a local lookup with no dial and no retry sleep.
|
|
310
|
+
*
|
|
311
|
+
* The gate lives here rather than at each call site because most writers are OUTSIDE
|
|
312
|
+
* this class — `recordCoordinator` is public and is fed self-valued picks by
|
|
313
|
+
* `NetworkTransactor` (it writes back whatever `findCoordinator` returned, including
|
|
314
|
+
* self) and by `RepoClient`/`ClusterClient` on redirect responses.
|
|
315
|
+
*/
|
|
302
316
|
public recordCoordinator(key: Uint8Array, peerId: PeerId, ttlMs = 30 * 60 * 1000): void {
|
|
317
|
+
if (peerId.toString() === this.libp2p.peerId.toString()) {
|
|
318
|
+
this.log('coordinator-cache:self-write-ignored key=%s', this.toCacheKey(key).substring(0, 12))
|
|
319
|
+
return
|
|
320
|
+
}
|
|
303
321
|
const k = this.toCacheKey(key)
|
|
304
322
|
const now = Date.now()
|
|
305
323
|
for (const [ck, entry] of this.coordinatorCache) {
|
|
@@ -417,11 +435,39 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
|
|
|
417
435
|
this.log('findCoordinator:fret-neighbors key=%s candidates=%d', keyStr, ids.length)
|
|
418
436
|
if (verbose) this.log('findCoordinator:fret-candidates key=%s ids=%o connected=%o', keyStr, ids, Array.from(connectedSet))
|
|
419
437
|
|
|
420
|
-
// Filter to only connected FRET neighbors, excluding banned peers
|
|
438
|
+
// Filter to only connected FRET neighbors, excluding banned peers. Self is
|
|
439
|
+
// never "connected" to itself, so it is admitted by the explicit self clause
|
|
440
|
+
// below — but ONLY when the self-coordination guard allows it, otherwise a
|
|
441
|
+
// node whose FRET neighborhood contains self (essentially always on a small or
|
|
442
|
+
// forming network) would bypass the guard and the last-resort tier's
|
|
443
|
+
// SELF_COORDINATION_BLOCKED would never fire. On refusal self is merely DROPPED
|
|
444
|
+
// from the candidate list, so the connected-peer fallback below still gets its
|
|
445
|
+
// chance at a good remote peer; only if that also comes up empty does the
|
|
446
|
+
// last-resort tier raise the accurate error.
|
|
447
|
+
const selfStr = this.libp2p.peerId.toString()
|
|
448
|
+
let selfAllowedThisAttempt: boolean | undefined
|
|
449
|
+
// Memoized per ATTEMPT, and evaluated lazily so an all-remote neighborhood never
|
|
450
|
+
// pays detectPartition() / getNetworkSizeEstimate(). Re-evaluated on each attempt
|
|
451
|
+
// because a connection can land during the 500ms inter-attempt sleep and
|
|
452
|
+
// legitimately flip the answer — as filterByMembership re-reads the peerStore.
|
|
453
|
+
// NOTE: on a small network self is a neighbor of nearly every key, so this runs
|
|
454
|
+
// per findCoordinator call and self-coordinated keys are never cached to absorb
|
|
455
|
+
// it. Fine while detectPartition()/getNetworkSizeEstimate() stay local FRET
|
|
456
|
+
// table reads; if either ever grows a probe or other network round-trip, cache
|
|
457
|
+
// the decision with a short TTL on the instance instead of per attempt.
|
|
458
|
+
const isSelfAdmissible = (): boolean => {
|
|
459
|
+
if (selfAllowedThisAttempt === undefined) {
|
|
460
|
+
const decision = this.shouldAllowSelfCoordination()
|
|
461
|
+
selfAllowedThisAttempt = decision.allow
|
|
462
|
+
if (!decision.allow) {
|
|
463
|
+
this.log('findCoordinator:fret-self-dropped key=%s reason=%s attempt=%d', keyStr, decision.reason, attempt)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return selfAllowedThisAttempt
|
|
467
|
+
}
|
|
421
468
|
const connectedFretIds = ids
|
|
422
|
-
.filter(id =>
|
|
423
|
-
|
|
424
|
-
&& !(this.reputation?.isBanned(id)))
|
|
469
|
+
.filter(id => !excludedSet.has(id) && !(this.reputation?.isBanned(id)))
|
|
470
|
+
.filter(id => connectedSet.has(id) || (id === selfStr && isSelfAdmissible()))
|
|
425
471
|
.sort((a, b) => (this.reputation?.getScore(a) ?? 0) - (this.reputation?.getScore(b) ?? 0))
|
|
426
472
|
this.log('findCoordinator:fret-connected key=%s count=%d peers=%o', keyStr, connectedFretIds.length, connectedFretIds.map(s => s.substring(0, 12)))
|
|
427
473
|
|
|
@@ -438,6 +484,8 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
|
|
|
438
484
|
const pick = ranked[0]
|
|
439
485
|
if (pick) {
|
|
440
486
|
const pid = peerIdFromString(pick)
|
|
487
|
+
// A self pick is a no-op here — recordCoordinator ignores self-valued
|
|
488
|
+
// writes (see its doc comment), matching the last-resort self tier below.
|
|
441
489
|
this.recordCoordinator(key, pid)
|
|
442
490
|
this.log('findCoordinator:done key=%s ms=%d source=%s', keyStr, Date.now() - t0, 'fret')
|
|
443
491
|
return pid
|
|
@@ -451,6 +499,8 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
|
|
|
451
499
|
// `unknown` peer is never picked). Note this candidate set is built from
|
|
452
500
|
// connected REMOTE peers and never includes self, so when no serving peer is
|
|
453
501
|
// present selection falls through to the last-resort self-coordination block.
|
|
502
|
+
// Being remote-only, this tier needs no self-coordination guard check, unlike the
|
|
503
|
+
// FRET tier above.
|
|
454
504
|
const connectedCandidates = connected
|
|
455
505
|
.filter(p => !excludedSet.has(p.toString()) && !(this.reputation?.isBanned(p.toString())))
|
|
456
506
|
.sort((a, b) => (this.reputation?.getScore(a.toString()) ?? 0) - (this.reputation?.getScore(b.toString()) ?? 0))
|