@peerbit/pubsub 5.4.3 → 5.4.5
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/fanout-tree.d.ts +7 -2
- package/dist/src/fanout-tree.d.ts.map +1 -1
- package/dist/src/fanout-tree.js +339 -81
- package/dist/src/fanout-tree.js.map +1 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +299 -77
- package/dist/src/index.js.map +1 -1
- package/package.json +4 -4
- package/src/fanout-tree.ts +462 -111
- package/src/index.ts +375 -96
package/src/index.ts
CHANGED
|
@@ -234,9 +234,15 @@ type TopicRootCandidateClaimRecord = {
|
|
|
234
234
|
const sameCandidates = (left: string[], right: string[]) =>
|
|
235
235
|
left.length === right.length &&
|
|
236
236
|
left.every((candidate, index) => candidate === right[index]);
|
|
237
|
-
//
|
|
238
|
-
//
|
|
237
|
+
// Retain the previous 12 second allowance as one deadline for the complete
|
|
238
|
+
// peer-query phase. It must not multiply by peer count or retry count.
|
|
239
239
|
const DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS = 12_000;
|
|
240
|
+
// A responder may need to open an outbound stream after an inbound-only dial.
|
|
241
|
+
// Bounded rounds leave enough time for that setup while still allowing a lost
|
|
242
|
+
// request or a newly-ready stream to be retried within the phase deadline.
|
|
243
|
+
const TOPIC_ROOT_QUERY_ROUND_TIMEOUT_MS = 3_000;
|
|
244
|
+
const TOPIC_ROOT_HOST_SCAN_TIMEOUT_MS = DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS;
|
|
245
|
+
const TOPIC_ROOT_HOST_SCAN_CONCURRENCY = 8;
|
|
240
246
|
const DIRECT_SHARD_ROOT_CONFIRM_TIMEOUT_MS = 2_000;
|
|
241
247
|
|
|
242
248
|
const DEFAULT_PUBSUB_FANOUT_CHANNEL_OPTIONS: Omit<
|
|
@@ -458,6 +464,15 @@ export class TopicControlPlane
|
|
|
458
464
|
string,
|
|
459
465
|
TopicRootCandidateClaimRecord
|
|
460
466
|
>();
|
|
467
|
+
// A directly observed departure makes only that origin's current lease
|
|
468
|
+
// ineffective locally. This is not a component-wide withdrawal: claims learned
|
|
469
|
+
// only through relays keep their existing lease semantics. Keeping the verified
|
|
470
|
+
// bytes + replay floor prevents a relay from resurrecting the departed lease;
|
|
471
|
+
// the map is bounded by the same retained-origin cap as the claim map.
|
|
472
|
+
private readonly suppressedDepartedTopicRootCandidateClaims = new Map<
|
|
473
|
+
string,
|
|
474
|
+
{ bytes: Uint8Array; timestamp: bigint }
|
|
475
|
+
>();
|
|
461
476
|
// Active claims expire, but their per-origin timestamp floors survive for the
|
|
462
477
|
// auto-mode lifecycle so a frozen wall clock cannot renew a replayed lease.
|
|
463
478
|
// Retaining only the deterministic lowest origins keeps this state hard-bounded.
|
|
@@ -873,6 +888,25 @@ export class TopicControlPlane
|
|
|
873
888
|
(peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1,
|
|
874
889
|
);
|
|
875
890
|
const removed = await super._removePeer(publicKey);
|
|
891
|
+
let suppressedDepartedOrigin = false;
|
|
892
|
+
if (
|
|
893
|
+
removed &&
|
|
894
|
+
this.autoTopicRootCandidates &&
|
|
895
|
+
protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1 &&
|
|
896
|
+
this.signedTopicRootCandidateClaims.has(hash)
|
|
897
|
+
) {
|
|
898
|
+
const claim = this.signedTopicRootCandidateClaims.get(hash)!;
|
|
899
|
+
const suppressed =
|
|
900
|
+
this.suppressedDepartedTopicRootCandidateClaims.get(hash);
|
|
901
|
+
suppressedDepartedOrigin =
|
|
902
|
+
!suppressed ||
|
|
903
|
+
suppressed.timestamp !== claim.timestamp ||
|
|
904
|
+
!bytesEqual(suppressed.bytes, claim.bytes);
|
|
905
|
+
this.suppressedDepartedTopicRootCandidateClaims.set(hash, {
|
|
906
|
+
bytes: claim.bytes,
|
|
907
|
+
timestamp: claim.timestamp,
|
|
908
|
+
});
|
|
909
|
+
}
|
|
876
910
|
if (
|
|
877
911
|
this.autoTopicRootCandidates &&
|
|
878
912
|
(protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_0 ||
|
|
@@ -882,7 +916,7 @@ export class TopicControlPlane
|
|
|
882
916
|
(peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1,
|
|
883
917
|
);
|
|
884
918
|
this.rebuildAutoTopicRootCandidatesFromClaims(BigInt(Date.now()), {
|
|
885
|
-
immediate: hadSignedPeer !== hasSignedPeer,
|
|
919
|
+
immediate: suppressedDepartedOrigin || hadSignedPeer !== hasSignedPeer,
|
|
886
920
|
});
|
|
887
921
|
}
|
|
888
922
|
return removed;
|
|
@@ -926,6 +960,7 @@ export class TopicControlPlane
|
|
|
926
960
|
private clearSignedTopicRootCandidateState() {
|
|
927
961
|
this.clearTopicRootCandidateClaimTimer();
|
|
928
962
|
this.signedTopicRootCandidateClaims.clear();
|
|
963
|
+
this.suppressedDepartedTopicRootCandidateClaims.clear();
|
|
929
964
|
this.topicRootCandidateClaimReplayFloors.clear();
|
|
930
965
|
this.localSignedTopicRootCandidateClaim = undefined;
|
|
931
966
|
this.topicRootCandidateClaimRefreshNotBefore = undefined;
|
|
@@ -1199,6 +1234,7 @@ export class TopicControlPlane
|
|
|
1199
1234
|
for (const [origin, claim] of this.signedTopicRootCandidateClaims) {
|
|
1200
1235
|
if (claim.expires <= now || claim.acceptUntil <= monotonicNow) {
|
|
1201
1236
|
this.signedTopicRootCandidateClaims.delete(origin);
|
|
1237
|
+
this.suppressedDepartedTopicRootCandidateClaims.delete(origin);
|
|
1202
1238
|
changed = true;
|
|
1203
1239
|
}
|
|
1204
1240
|
}
|
|
@@ -1223,7 +1259,12 @@ export class TopicControlPlane
|
|
|
1223
1259
|
? []
|
|
1224
1260
|
: [this.publicKeyHash]),
|
|
1225
1261
|
...legacyDirectCandidates,
|
|
1226
|
-
...this.signedTopicRootCandidateClaims.
|
|
1262
|
+
...[...this.signedTopicRootCandidateClaims.entries()]
|
|
1263
|
+
.filter(
|
|
1264
|
+
([origin, claim]) =>
|
|
1265
|
+
!this.isDepartedTopicRootCandidateClaimSuppressed(origin, claim),
|
|
1266
|
+
)
|
|
1267
|
+
.map(([origin]) => origin),
|
|
1227
1268
|
]),
|
|
1228
1269
|
]
|
|
1229
1270
|
.filter(isCanonicalTopicRootCandidate)
|
|
@@ -1268,6 +1309,7 @@ export class TopicControlPlane
|
|
|
1268
1309
|
// because the retained maximum only decreases, it cannot re-enter.
|
|
1269
1310
|
this.topicRootCandidateClaimReplayFloors.delete(highest);
|
|
1270
1311
|
this.signedTopicRootCandidateClaims.delete(highest);
|
|
1312
|
+
this.suppressedDepartedTopicRootCandidateClaims.delete(highest);
|
|
1271
1313
|
if (highest === this.publicKeyHash) {
|
|
1272
1314
|
this.localSignedTopicRootCandidateClaim = undefined;
|
|
1273
1315
|
}
|
|
@@ -1275,9 +1317,22 @@ export class TopicControlPlane
|
|
|
1275
1317
|
}
|
|
1276
1318
|
this.topicRootCandidateClaimReplayFloors.set(origin, claim.timestamp);
|
|
1277
1319
|
this.signedTopicRootCandidateClaims.set(origin, claim);
|
|
1320
|
+
this.suppressedDepartedTopicRootCandidateClaims.delete(origin);
|
|
1278
1321
|
return true;
|
|
1279
1322
|
}
|
|
1280
1323
|
|
|
1324
|
+
private isDepartedTopicRootCandidateClaimSuppressed(
|
|
1325
|
+
origin: string,
|
|
1326
|
+
claim: TopicRootCandidateClaimRecord,
|
|
1327
|
+
): boolean {
|
|
1328
|
+
const suppressed =
|
|
1329
|
+
this.suppressedDepartedTopicRootCandidateClaims.get(origin);
|
|
1330
|
+
return (
|
|
1331
|
+
suppressed?.timestamp === claim.timestamp &&
|
|
1332
|
+
bytesEqual(suppressed.bytes, claim.bytes)
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1281
1336
|
private canAdvanceTopicRootCandidateClaimReplayFloor(
|
|
1282
1337
|
origin: string,
|
|
1283
1338
|
timestamp: bigint,
|
|
@@ -1354,6 +1409,7 @@ export class TopicControlPlane
|
|
|
1354
1409
|
private async importSignedTopicRootCandidateClaim(
|
|
1355
1410
|
rawClaim: Uint8Array,
|
|
1356
1411
|
outerPeerHash: string,
|
|
1412
|
+
expectedPeer?: PeerStreams,
|
|
1357
1413
|
): Promise<boolean> {
|
|
1358
1414
|
const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
|
|
1359
1415
|
if (
|
|
@@ -1398,11 +1454,35 @@ export class TopicControlPlane
|
|
|
1398
1454
|
if (acceptanceDeadline === undefined) {
|
|
1399
1455
|
return false;
|
|
1400
1456
|
}
|
|
1457
|
+
|
|
1458
|
+
this.rebuildAutoTopicRootCandidatesFromClaims(now);
|
|
1459
|
+
const retained = this.signedTopicRootCandidateClaims.get(origin);
|
|
1460
|
+
if (
|
|
1461
|
+
outerPeerHash === origin &&
|
|
1462
|
+
expectedPeer !== undefined &&
|
|
1463
|
+
expectedPeer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1 &&
|
|
1464
|
+
this.peers.get(outerPeerHash) === expectedPeer &&
|
|
1465
|
+
lifecycleRevision === this.topicControlPlaneLifecycleRevision &&
|
|
1466
|
+
this.autoTopicRootCandidates &&
|
|
1467
|
+
this.started &&
|
|
1468
|
+
!this.stopping &&
|
|
1469
|
+
!this.topicControlPlaneStopping &&
|
|
1470
|
+
retained?.timestamp === timestamp &&
|
|
1471
|
+
bytesEqual(retained.bytes, rawClaim) &&
|
|
1472
|
+
this.isDepartedTopicRootCandidateClaimSuppressed(origin, retained)
|
|
1473
|
+
) {
|
|
1474
|
+
// These exact bytes were already verified and remain under the original
|
|
1475
|
+
// monotonic acceptance deadline. A current authenticated origin stream is
|
|
1476
|
+
// sufficient liveness evidence; do not advance the floor or lease.
|
|
1477
|
+
this.suppressedDepartedTopicRootCandidateClaims.delete(origin);
|
|
1478
|
+
this.rebuildAutoTopicRootCandidatesFromClaims(now, { immediate: true });
|
|
1479
|
+
return false;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1401
1482
|
if (!this.canAdvanceTopicRootCandidateClaimReplayFloor(origin, timestamp)) {
|
|
1402
1483
|
return false;
|
|
1403
1484
|
}
|
|
1404
1485
|
|
|
1405
|
-
this.rebuildAutoTopicRootCandidatesFromClaims(now);
|
|
1406
1486
|
if (!this.consumeTopicRootCandidateClaimVerifyBudget(outerPeerHash)) {
|
|
1407
1487
|
return false;
|
|
1408
1488
|
}
|
|
@@ -1425,7 +1505,9 @@ export class TopicControlPlane
|
|
|
1425
1505
|
!this.autoTopicRootCandidates ||
|
|
1426
1506
|
!this.started ||
|
|
1427
1507
|
this.stopping ||
|
|
1428
|
-
this.topicControlPlaneStopping
|
|
1508
|
+
this.topicControlPlaneStopping ||
|
|
1509
|
+
(expectedPeer !== undefined &&
|
|
1510
|
+
this.peers.get(outerPeerHash) !== expectedPeer)
|
|
1429
1511
|
) {
|
|
1430
1512
|
return false;
|
|
1431
1513
|
}
|
|
@@ -1499,6 +1581,10 @@ export class TopicControlPlane
|
|
|
1499
1581
|
}
|
|
1500
1582
|
}
|
|
1501
1583
|
const claims = [...this.signedTopicRootCandidateClaims.entries()]
|
|
1584
|
+
.filter(
|
|
1585
|
+
([origin, claim]) =>
|
|
1586
|
+
!this.isDepartedTopicRootCandidateClaimSuppressed(origin, claim),
|
|
1587
|
+
)
|
|
1502
1588
|
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
1503
1589
|
.map(([, claim]) => claim.bytes);
|
|
1504
1590
|
await this.sendSignedTopicRootCandidateClaims(claims, signedStreams);
|
|
@@ -2062,7 +2148,9 @@ export class TopicControlPlane
|
|
|
2062
2148
|
|
|
2063
2149
|
private async resolveTopicRootState(
|
|
2064
2150
|
topic: string,
|
|
2065
|
-
options?: TopicRootResolutionOptions
|
|
2151
|
+
options?: TopicRootResolutionOptions & {
|
|
2152
|
+
queryConnectedPeers?: boolean;
|
|
2153
|
+
},
|
|
2066
2154
|
): Promise<{ root?: string; authoritative: boolean }> {
|
|
2067
2155
|
throwIfAborted(options?.signal);
|
|
2068
2156
|
const tracked = await this.topicRootControlPlane.resolveTrackedTopicRoot(
|
|
@@ -2072,37 +2160,80 @@ export class TopicControlPlane
|
|
|
2072
2160
|
if (tracked) {
|
|
2073
2161
|
return { root: tracked, authoritative: true };
|
|
2074
2162
|
}
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
if (resolvedThroughPeers) {
|
|
2081
|
-
// Unconfigured peer-query replies cannot override the locally
|
|
2082
|
-
// deterministic root for internal shards in auto mode. Roots, resolvers,
|
|
2083
|
-
// and trackers explicitly configured on this control plane are resolved
|
|
2084
|
-
// above and remain authoritative. A leaf relying on a queried gateway for
|
|
2085
|
-
// shard roots must first disable auto mode with setTopicRootCandidates([]).
|
|
2086
|
-
return this.normalizePeerTopicRootState(topic, resolvedThroughPeers);
|
|
2163
|
+
if (options?.queryConnectedPeers === false) {
|
|
2164
|
+
return {
|
|
2165
|
+
root: this.topicRootControlPlane.resolveDeterministicTopicRoot(topic),
|
|
2166
|
+
authoritative: false,
|
|
2167
|
+
};
|
|
2087
2168
|
}
|
|
2088
2169
|
|
|
2089
|
-
const
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2170
|
+
const deadlineController = new AbortController();
|
|
2171
|
+
const deadlineTimer = setTimeout(
|
|
2172
|
+
() =>
|
|
2173
|
+
deadlineController.abort(
|
|
2174
|
+
new AbortError("topic root peer-query deadline reached"),
|
|
2175
|
+
),
|
|
2176
|
+
DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS,
|
|
2177
|
+
);
|
|
2178
|
+
deadlineTimer.unref?.();
|
|
2179
|
+
const querySignals = linkAbortSignals([
|
|
2180
|
+
options?.signal,
|
|
2181
|
+
deadlineController.signal,
|
|
2182
|
+
]);
|
|
2183
|
+
try {
|
|
2184
|
+
let attempt = 0;
|
|
2185
|
+
for (;;) {
|
|
2186
|
+
throwIfAborted(querySignals.signal);
|
|
2187
|
+
// Preserve the immediate deterministic fallback when no queryable stream
|
|
2188
|
+
// exists at entry. Once a peer was queryable, keep the bounded readiness
|
|
2189
|
+
// window alive: later rounds re-snapshot streams that can become writable
|
|
2190
|
+
// after an inbound-only dial.
|
|
2191
|
+
if (
|
|
2192
|
+
attempt === 0 &&
|
|
2193
|
+
this.getConnectedTopicRootTrackers().length === 0
|
|
2194
|
+
) {
|
|
2195
|
+
break;
|
|
2196
|
+
}
|
|
2197
|
+
if (attempt > 0) {
|
|
2198
|
+
await withAbort(
|
|
2199
|
+
delay(150 * Math.min(attempt, 2), {
|
|
2200
|
+
signal: querySignals.signal,
|
|
2201
|
+
}),
|
|
2202
|
+
querySignals.signal,
|
|
2203
|
+
);
|
|
2204
|
+
}
|
|
2205
|
+
attempt += 1;
|
|
2206
|
+
const resolvedThroughPeers = await this.resolveTopicRootThroughPeers(
|
|
2207
|
+
topic,
|
|
2208
|
+
{
|
|
2209
|
+
signal: querySignals.signal,
|
|
2210
|
+
timeoutMs: TOPIC_ROOT_QUERY_ROUND_TIMEOUT_MS,
|
|
2211
|
+
},
|
|
2100
2212
|
);
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2213
|
+
if (resolvedThroughPeers !== undefined) {
|
|
2214
|
+
// Unconfigured peer-query replies cannot override the locally
|
|
2215
|
+
// deterministic root for internal shards in auto mode. Roots,
|
|
2216
|
+
// resolvers, and trackers explicitly configured on this control
|
|
2217
|
+
// plane are resolved above and remain authoritative. A leaf relying
|
|
2218
|
+
// on a queried gateway for shard roots must first disable auto mode
|
|
2219
|
+
// with setTopicRootCandidates([]).
|
|
2220
|
+
return this.normalizePeerTopicRootState(topic, resolvedThroughPeers);
|
|
2104
2221
|
}
|
|
2105
2222
|
}
|
|
2223
|
+
} catch (error) {
|
|
2224
|
+
// Candidate, lifecycle, and caller cancellation are terminal to this
|
|
2225
|
+
// invocation and must retain their exact reason. Only the private phase
|
|
2226
|
+
// deadline falls through to the same deterministic result as exhausted
|
|
2227
|
+
// peer queries.
|
|
2228
|
+
if (options?.signal?.aborted) {
|
|
2229
|
+
throw options.signal.reason ?? error;
|
|
2230
|
+
}
|
|
2231
|
+
if (!deadlineController.signal.aborted) {
|
|
2232
|
+
throw error;
|
|
2233
|
+
}
|
|
2234
|
+
} finally {
|
|
2235
|
+
clearTimeout(deadlineTimer);
|
|
2236
|
+
querySignals.clear();
|
|
2106
2237
|
}
|
|
2107
2238
|
|
|
2108
2239
|
return {
|
|
@@ -2128,7 +2259,9 @@ export class TopicControlPlane
|
|
|
2128
2259
|
|
|
2129
2260
|
private async resolveShardRootState(
|
|
2130
2261
|
shardTopic: string,
|
|
2131
|
-
options?: TopicRootResolutionOptions
|
|
2262
|
+
options?: TopicRootResolutionOptions & {
|
|
2263
|
+
queryConnectedPeers?: boolean;
|
|
2264
|
+
},
|
|
2132
2265
|
): Promise<{ root: string; candidateGeneration: string }> {
|
|
2133
2266
|
const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
|
|
2134
2267
|
for (;;) {
|
|
@@ -2143,8 +2276,14 @@ export class TopicControlPlane
|
|
|
2143
2276
|
|
|
2144
2277
|
const candidateGeneration = this.getTopicRootCandidateGeneration();
|
|
2145
2278
|
const hasConnectedTrackers =
|
|
2279
|
+
options?.queryConnectedPeers !== false &&
|
|
2146
2280
|
this.getConnectedTopicRootTrackers().length > 0;
|
|
2147
|
-
|
|
2281
|
+
// A proactive local scan must not inherit an authoritative cache entry that
|
|
2282
|
+
// originated from a connected-peer reply under the same candidate generation.
|
|
2283
|
+
const cached =
|
|
2284
|
+
options?.queryConnectedPeers === false
|
|
2285
|
+
? undefined
|
|
2286
|
+
: this.shardRootCache.get(shardTopic);
|
|
2148
2287
|
if (cached && (cached.authoritative || !hasConnectedTrackers)) {
|
|
2149
2288
|
return { root: cached.root, candidateGeneration };
|
|
2150
2289
|
}
|
|
@@ -2201,6 +2340,7 @@ export class TopicControlPlane
|
|
|
2201
2340
|
private async sendDirectControlMessage(
|
|
2202
2341
|
peer: PeerStreams,
|
|
2203
2342
|
pubsubMessage: PubSubMessage,
|
|
2343
|
+
signal?: AbortSignal,
|
|
2204
2344
|
) {
|
|
2205
2345
|
const embedded = await this.createMessage(
|
|
2206
2346
|
this.encodePubSubMessage(pubsubMessage),
|
|
@@ -2213,7 +2353,13 @@ export class TopicControlPlane
|
|
|
2213
2353
|
skipRecipientValidation: true,
|
|
2214
2354
|
} as any,
|
|
2215
2355
|
);
|
|
2216
|
-
await this.publishMessage(
|
|
2356
|
+
await this.publishMessage(
|
|
2357
|
+
this.publicKey,
|
|
2358
|
+
embedded,
|
|
2359
|
+
[peer],
|
|
2360
|
+
undefined,
|
|
2361
|
+
signal,
|
|
2362
|
+
);
|
|
2217
2363
|
}
|
|
2218
2364
|
|
|
2219
2365
|
private resolvePendingTopicRootQuery(
|
|
@@ -2245,6 +2391,9 @@ export class TopicControlPlane
|
|
|
2245
2391
|
|
|
2246
2392
|
const expectedPeerHash = peer.publicKey.hashcode();
|
|
2247
2393
|
const requestId = this.nextTopicRootRequestIdValue();
|
|
2394
|
+
const queryController = new AbortController();
|
|
2395
|
+
const querySignals = linkAbortSignals([signal, queryController.signal]);
|
|
2396
|
+
let timedOut = false;
|
|
2248
2397
|
const responsePromise = new Promise<string | undefined>((resolve) => {
|
|
2249
2398
|
let settled = false;
|
|
2250
2399
|
const settle = (root: string | undefined) => {
|
|
@@ -2255,14 +2404,16 @@ export class TopicControlPlane
|
|
|
2255
2404
|
this.pendingTopicRootQueries.delete(requestId);
|
|
2256
2405
|
}
|
|
2257
2406
|
clearTimeout(timer);
|
|
2258
|
-
|
|
2259
|
-
signal.removeEventListener("abort", onAbort);
|
|
2260
|
-
}
|
|
2407
|
+
querySignals.signal.removeEventListener("abort", onAbort);
|
|
2261
2408
|
resolve(root);
|
|
2262
2409
|
};
|
|
2263
|
-
const onAbort =
|
|
2410
|
+
const onAbort = () => settle(undefined);
|
|
2264
2411
|
const timer = setTimeout(
|
|
2265
|
-
() =>
|
|
2412
|
+
() => {
|
|
2413
|
+
timedOut = true;
|
|
2414
|
+
settle(undefined);
|
|
2415
|
+
queryController.abort(new AbortError("topic root query timed out"));
|
|
2416
|
+
},
|
|
2266
2417
|
Math.max(1, Math.floor(timeoutMs)),
|
|
2267
2418
|
);
|
|
2268
2419
|
timer.unref?.();
|
|
@@ -2272,31 +2423,45 @@ export class TopicControlPlane
|
|
|
2272
2423
|
resolve: settle,
|
|
2273
2424
|
timer,
|
|
2274
2425
|
});
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
if (signal.aborted) onAbort();
|
|
2278
|
-
}
|
|
2426
|
+
querySignals.signal.addEventListener("abort", onAbort, { once: true });
|
|
2427
|
+
if (querySignals.signal.aborted) onAbort();
|
|
2279
2428
|
});
|
|
2280
2429
|
|
|
2281
2430
|
try {
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2431
|
+
try {
|
|
2432
|
+
const sending = withAbort(
|
|
2433
|
+
this.sendDirectControlMessage(
|
|
2434
|
+
peer,
|
|
2435
|
+
new TopicRootQuery({ requestId, topic }),
|
|
2436
|
+
querySignals.signal,
|
|
2437
|
+
),
|
|
2438
|
+
querySignals.signal,
|
|
2439
|
+
);
|
|
2440
|
+
// A write can finish after the request is already on the wire (or stall
|
|
2441
|
+
// while opening the reverse stream). Let either a response or the same
|
|
2442
|
+
// query timeout release this await instead of extending the timeout around
|
|
2443
|
+
// the outbound send.
|
|
2444
|
+
await Promise.race([
|
|
2445
|
+
sending,
|
|
2446
|
+
responsePromise.then((): undefined => undefined),
|
|
2447
|
+
]);
|
|
2448
|
+
} catch (error) {
|
|
2449
|
+
const pending = this.pendingTopicRootQueries.get(requestId);
|
|
2450
|
+
if (pending && !timedOut) {
|
|
2451
|
+
this.pendingTopicRootQueries.delete(requestId);
|
|
2452
|
+
clearTimeout(pending.timer);
|
|
2453
|
+
pending.resolve(undefined);
|
|
2454
|
+
}
|
|
2455
|
+
if (signal?.aborted) {
|
|
2456
|
+
throw signal.reason ?? error;
|
|
2457
|
+
}
|
|
2295
2458
|
}
|
|
2296
|
-
if (signal?.aborted) throw error;
|
|
2297
|
-
}
|
|
2298
2459
|
|
|
2299
|
-
|
|
2460
|
+
return await withAbort(responsePromise, signal);
|
|
2461
|
+
} finally {
|
|
2462
|
+
queryController.abort(new AbortError("topic root query settled"));
|
|
2463
|
+
querySignals.clear();
|
|
2464
|
+
}
|
|
2300
2465
|
}
|
|
2301
2466
|
|
|
2302
2467
|
private async confirmDirectShardRoot(
|
|
@@ -2390,7 +2555,7 @@ export class TopicControlPlane
|
|
|
2390
2555
|
|
|
2391
2556
|
private async resolveTopicRootThroughPeers(
|
|
2392
2557
|
topic: string,
|
|
2393
|
-
options?: TopicRootResolutionOptions,
|
|
2558
|
+
options?: TopicRootResolutionOptions & { timeoutMs?: number },
|
|
2394
2559
|
): Promise<string | undefined> {
|
|
2395
2560
|
throwIfAborted(options?.signal);
|
|
2396
2561
|
const peers = this.getConnectedTopicRootTrackers();
|
|
@@ -2398,24 +2563,82 @@ export class TopicControlPlane
|
|
|
2398
2563
|
return undefined;
|
|
2399
2564
|
}
|
|
2400
2565
|
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2566
|
+
const roundController = new AbortController();
|
|
2567
|
+
const roundTimeoutMs = Math.max(
|
|
2568
|
+
1,
|
|
2569
|
+
Math.floor(options?.timeoutMs ?? DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS),
|
|
2570
|
+
);
|
|
2571
|
+
let roundTimedOut = false;
|
|
2572
|
+
const roundTimer = setTimeout(() => {
|
|
2573
|
+
roundTimedOut = true;
|
|
2574
|
+
roundController.abort(new AbortError("topic root query round timed out"));
|
|
2575
|
+
}, roundTimeoutMs);
|
|
2576
|
+
roundTimer.unref?.();
|
|
2577
|
+
const roundSignals = linkAbortSignals([
|
|
2578
|
+
options?.signal,
|
|
2579
|
+
roundController.signal,
|
|
2580
|
+
]);
|
|
2581
|
+
const queries = peers.map((peer) =>
|
|
2582
|
+
this.queryTopicRootFromPeer(
|
|
2583
|
+
peer,
|
|
2584
|
+
topic,
|
|
2585
|
+
roundTimeoutMs,
|
|
2586
|
+
roundSignals.signal,
|
|
2587
|
+
),
|
|
2588
|
+
);
|
|
2589
|
+
const unresolved = Symbol("unresolved topic root query");
|
|
2590
|
+
const results: Array<string | undefined | typeof unresolved> = peers.map(
|
|
2591
|
+
() => unresolved,
|
|
2592
|
+
);
|
|
2593
|
+
// All requests start together, but the stable peer ordering remains the
|
|
2594
|
+
// conflict-resolution policy: a later answer is usable only after every
|
|
2595
|
+
// earlier peer has answered undefined or reached its round timeout.
|
|
2596
|
+
const prioritizedResult = new Promise<string | undefined>(
|
|
2597
|
+
(resolve, reject) => {
|
|
2598
|
+
let settled = false;
|
|
2599
|
+
const resolveInPeerOrder = () => {
|
|
2600
|
+
for (const result of results) {
|
|
2601
|
+
if (result === unresolved) return;
|
|
2602
|
+
if (result !== undefined) {
|
|
2603
|
+
settled = true;
|
|
2604
|
+
resolve(result);
|
|
2605
|
+
return;
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
settled = true;
|
|
2609
|
+
resolve(undefined);
|
|
2610
|
+
};
|
|
2611
|
+
queries.forEach((query, index) => {
|
|
2612
|
+
query.then(
|
|
2613
|
+
(root) => {
|
|
2614
|
+
if (settled) return;
|
|
2615
|
+
results[index] = root;
|
|
2616
|
+
resolveInPeerOrder();
|
|
2617
|
+
},
|
|
2618
|
+
(error) => {
|
|
2619
|
+
if (settled) return;
|
|
2620
|
+
if (roundTimedOut && !options?.signal?.aborted) {
|
|
2621
|
+
results[index] = undefined;
|
|
2622
|
+
resolveInPeerOrder();
|
|
2623
|
+
return;
|
|
2624
|
+
}
|
|
2625
|
+
settled = true;
|
|
2626
|
+
reject(error);
|
|
2627
|
+
},
|
|
2628
|
+
);
|
|
2629
|
+
});
|
|
2630
|
+
},
|
|
2631
|
+
);
|
|
2632
|
+
try {
|
|
2633
|
+
return await prioritizedResult;
|
|
2634
|
+
} finally {
|
|
2635
|
+
clearTimeout(roundTimer);
|
|
2636
|
+
// Cancel response timers and in-flight sends for every losing query before
|
|
2637
|
+
// the winning root (or an abort) can leave this round.
|
|
2638
|
+
roundController.abort(new AbortError("topic root query round settled"));
|
|
2639
|
+
await Promise.allSettled(queries);
|
|
2640
|
+
roundSignals.clear();
|
|
2417
2641
|
}
|
|
2418
|
-
return undefined;
|
|
2419
2642
|
}
|
|
2420
2643
|
|
|
2421
2644
|
private async ensureFanoutChannel(
|
|
@@ -2833,25 +3056,77 @@ export class TopicControlPlane
|
|
|
2833
3056
|
if (!this.started) throw new NotStartedError();
|
|
2834
3057
|
const lifecycleSignal = this.topicRootResolutionAbortController.signal;
|
|
2835
3058
|
return this.withTopicRootCandidateResolution(
|
|
2836
|
-
async ({
|
|
3059
|
+
async ({ signal }) => {
|
|
2837
3060
|
const joins: Promise<void>[] = [];
|
|
3061
|
+
const scanController = new AbortController();
|
|
3062
|
+
const scanTimer = setTimeout(() => {
|
|
3063
|
+
scanController.abort(
|
|
3064
|
+
new AbortError(
|
|
3065
|
+
`topic root host scan timed out after ${TOPIC_ROOT_HOST_SCAN_TIMEOUT_MS}ms`,
|
|
3066
|
+
),
|
|
3067
|
+
);
|
|
3068
|
+
}, TOPIC_ROOT_HOST_SCAN_TIMEOUT_MS);
|
|
3069
|
+
scanTimer.unref?.();
|
|
3070
|
+
const scanSignals = linkAbortSignals([signal, scanController.signal]);
|
|
3071
|
+
let nextShard = 0;
|
|
3072
|
+
let scanFailure: { error: unknown } | undefined;
|
|
3073
|
+
const scanWorker = async () => {
|
|
3074
|
+
try {
|
|
3075
|
+
for (;;) {
|
|
3076
|
+
if (scanFailure) return;
|
|
3077
|
+
throwIfAborted(scanSignals.signal);
|
|
3078
|
+
const shard = nextShard++;
|
|
3079
|
+
if (shard >= this.shardCount) return;
|
|
3080
|
+
const shardTopic = `${this.shardTopicPrefix}${shard}`;
|
|
3081
|
+
const resolved = await this.resolveShardRootState(shardTopic, {
|
|
3082
|
+
signal: scanSignals.signal,
|
|
3083
|
+
// Proactive hosting follows the generation-bound local control
|
|
3084
|
+
// plane. Connected-peer queries remain on the lazy open path;
|
|
3085
|
+
// asking every peer for every shard multiplies one stale-peer
|
|
3086
|
+
// timeout by the complete shard count.
|
|
3087
|
+
queryConnectedPeers: false,
|
|
3088
|
+
});
|
|
3089
|
+
throwIfAborted(scanSignals.signal);
|
|
3090
|
+
if (scanFailure || resolved.root !== this.publicKeyHash) continue;
|
|
3091
|
+
const joining = this.ensureFanoutChannel(shardTopic, {
|
|
3092
|
+
pin: true,
|
|
3093
|
+
root: resolved.root,
|
|
3094
|
+
rootCandidateGeneration: resolved.candidateGeneration,
|
|
3095
|
+
// The shared deadline bounds root discovery only. Preserve the
|
|
3096
|
+
// existing contract that joins already started are drained.
|
|
3097
|
+
signal,
|
|
3098
|
+
});
|
|
3099
|
+
void joining.catch(() => {});
|
|
3100
|
+
joins.push(joining);
|
|
3101
|
+
}
|
|
3102
|
+
} catch (error) {
|
|
3103
|
+
if (!scanFailure) {
|
|
3104
|
+
scanFailure = { error };
|
|
3105
|
+
// Do not make a sibling resolver consume the rest of the shared
|
|
3106
|
+
// deadline after this scan already has a terminal failure. Joins use
|
|
3107
|
+
// the outer signal and are intentionally drained below.
|
|
3108
|
+
scanController.abort(error);
|
|
3109
|
+
}
|
|
3110
|
+
}
|
|
3111
|
+
};
|
|
2838
3112
|
try {
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
3113
|
+
try {
|
|
3114
|
+
await Promise.all(
|
|
3115
|
+
Array.from(
|
|
3116
|
+
{
|
|
3117
|
+
length: Math.min(
|
|
3118
|
+
TOPIC_ROOT_HOST_SCAN_CONCURRENCY,
|
|
3119
|
+
this.shardCount,
|
|
3120
|
+
),
|
|
3121
|
+
},
|
|
3122
|
+
() => scanWorker(),
|
|
3123
|
+
),
|
|
3124
|
+
);
|
|
3125
|
+
} finally {
|
|
3126
|
+
clearTimeout(scanTimer);
|
|
3127
|
+
scanSignals.clear();
|
|
2854
3128
|
}
|
|
3129
|
+
if (scanFailure) throw scanFailure.error;
|
|
2855
3130
|
await Promise.all(joins);
|
|
2856
3131
|
} catch (error) {
|
|
2857
3132
|
await Promise.allSettled(joins);
|
|
@@ -3808,7 +4083,11 @@ export class TopicControlPlane
|
|
|
3808
4083
|
const outerPeerHash = stream.publicKey.hashcode();
|
|
3809
4084
|
for (const claim of pubsubMessage.claims) {
|
|
3810
4085
|
if (
|
|
3811
|
-
await this.importSignedTopicRootCandidateClaim(
|
|
4086
|
+
await this.importSignedTopicRootCandidateClaim(
|
|
4087
|
+
claim,
|
|
4088
|
+
outerPeerHash,
|
|
4089
|
+
stream,
|
|
4090
|
+
)
|
|
3812
4091
|
) {
|
|
3813
4092
|
imported.push(claim);
|
|
3814
4093
|
}
|