@peerbit/pubsub 5.4.4 → 5.4.6
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 +3 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +303 -78
- package/dist/src/index.js.map +1 -1
- package/package.json +4 -4
- package/src/fanout-tree.ts +462 -111
- package/src/index.ts +381 -98
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.
|
|
@@ -849,7 +864,7 @@ export class TopicControlPlane
|
|
|
849
864
|
if (peer.isWritable) {
|
|
850
865
|
void this.sendAutoTopicRootCandidates([peer]).catch(() => {});
|
|
851
866
|
}
|
|
852
|
-
if (
|
|
867
|
+
if (existingPeer !== peer) {
|
|
853
868
|
peer.addEventListener("stream:outbound", sendWhenOutboundReady);
|
|
854
869
|
peer.addEventListener(
|
|
855
870
|
"close",
|
|
@@ -866,13 +881,36 @@ export class TopicControlPlane
|
|
|
866
881
|
return peer;
|
|
867
882
|
}
|
|
868
883
|
|
|
869
|
-
protected override async _removePeer(
|
|
884
|
+
protected override async _removePeer(
|
|
885
|
+
publicKey: PublicSignKey,
|
|
886
|
+
): Promise<PeerStreams | undefined> {
|
|
870
887
|
const hash = publicKey.hashcode();
|
|
871
888
|
const protocol = this.peers.get(hash)?.protocol;
|
|
872
889
|
const hadSignedPeer = [...this.peers.values()].some(
|
|
873
890
|
(peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1,
|
|
874
891
|
);
|
|
875
892
|
const removed = await super._removePeer(publicKey);
|
|
893
|
+
// A replacement may arrive after base removal but before this continuation.
|
|
894
|
+
if (!removed || this.peers.has(hash)) return;
|
|
895
|
+
let suppressedDepartedOrigin = false;
|
|
896
|
+
if (
|
|
897
|
+
removed &&
|
|
898
|
+
this.autoTopicRootCandidates &&
|
|
899
|
+
protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1 &&
|
|
900
|
+
this.signedTopicRootCandidateClaims.has(hash)
|
|
901
|
+
) {
|
|
902
|
+
const claim = this.signedTopicRootCandidateClaims.get(hash)!;
|
|
903
|
+
const suppressed =
|
|
904
|
+
this.suppressedDepartedTopicRootCandidateClaims.get(hash);
|
|
905
|
+
suppressedDepartedOrigin =
|
|
906
|
+
!suppressed ||
|
|
907
|
+
suppressed.timestamp !== claim.timestamp ||
|
|
908
|
+
!bytesEqual(suppressed.bytes, claim.bytes);
|
|
909
|
+
this.suppressedDepartedTopicRootCandidateClaims.set(hash, {
|
|
910
|
+
bytes: claim.bytes,
|
|
911
|
+
timestamp: claim.timestamp,
|
|
912
|
+
});
|
|
913
|
+
}
|
|
876
914
|
if (
|
|
877
915
|
this.autoTopicRootCandidates &&
|
|
878
916
|
(protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_0 ||
|
|
@@ -882,7 +920,7 @@ export class TopicControlPlane
|
|
|
882
920
|
(peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1,
|
|
883
921
|
);
|
|
884
922
|
this.rebuildAutoTopicRootCandidatesFromClaims(BigInt(Date.now()), {
|
|
885
|
-
immediate: hadSignedPeer !== hasSignedPeer,
|
|
923
|
+
immediate: suppressedDepartedOrigin || hadSignedPeer !== hasSignedPeer,
|
|
886
924
|
});
|
|
887
925
|
}
|
|
888
926
|
return removed;
|
|
@@ -926,6 +964,7 @@ export class TopicControlPlane
|
|
|
926
964
|
private clearSignedTopicRootCandidateState() {
|
|
927
965
|
this.clearTopicRootCandidateClaimTimer();
|
|
928
966
|
this.signedTopicRootCandidateClaims.clear();
|
|
967
|
+
this.suppressedDepartedTopicRootCandidateClaims.clear();
|
|
929
968
|
this.topicRootCandidateClaimReplayFloors.clear();
|
|
930
969
|
this.localSignedTopicRootCandidateClaim = undefined;
|
|
931
970
|
this.topicRootCandidateClaimRefreshNotBefore = undefined;
|
|
@@ -1199,6 +1238,7 @@ export class TopicControlPlane
|
|
|
1199
1238
|
for (const [origin, claim] of this.signedTopicRootCandidateClaims) {
|
|
1200
1239
|
if (claim.expires <= now || claim.acceptUntil <= monotonicNow) {
|
|
1201
1240
|
this.signedTopicRootCandidateClaims.delete(origin);
|
|
1241
|
+
this.suppressedDepartedTopicRootCandidateClaims.delete(origin);
|
|
1202
1242
|
changed = true;
|
|
1203
1243
|
}
|
|
1204
1244
|
}
|
|
@@ -1223,7 +1263,12 @@ export class TopicControlPlane
|
|
|
1223
1263
|
? []
|
|
1224
1264
|
: [this.publicKeyHash]),
|
|
1225
1265
|
...legacyDirectCandidates,
|
|
1226
|
-
...this.signedTopicRootCandidateClaims.
|
|
1266
|
+
...[...this.signedTopicRootCandidateClaims.entries()]
|
|
1267
|
+
.filter(
|
|
1268
|
+
([origin, claim]) =>
|
|
1269
|
+
!this.isDepartedTopicRootCandidateClaimSuppressed(origin, claim),
|
|
1270
|
+
)
|
|
1271
|
+
.map(([origin]) => origin),
|
|
1227
1272
|
]),
|
|
1228
1273
|
]
|
|
1229
1274
|
.filter(isCanonicalTopicRootCandidate)
|
|
@@ -1268,6 +1313,7 @@ export class TopicControlPlane
|
|
|
1268
1313
|
// because the retained maximum only decreases, it cannot re-enter.
|
|
1269
1314
|
this.topicRootCandidateClaimReplayFloors.delete(highest);
|
|
1270
1315
|
this.signedTopicRootCandidateClaims.delete(highest);
|
|
1316
|
+
this.suppressedDepartedTopicRootCandidateClaims.delete(highest);
|
|
1271
1317
|
if (highest === this.publicKeyHash) {
|
|
1272
1318
|
this.localSignedTopicRootCandidateClaim = undefined;
|
|
1273
1319
|
}
|
|
@@ -1275,9 +1321,22 @@ export class TopicControlPlane
|
|
|
1275
1321
|
}
|
|
1276
1322
|
this.topicRootCandidateClaimReplayFloors.set(origin, claim.timestamp);
|
|
1277
1323
|
this.signedTopicRootCandidateClaims.set(origin, claim);
|
|
1324
|
+
this.suppressedDepartedTopicRootCandidateClaims.delete(origin);
|
|
1278
1325
|
return true;
|
|
1279
1326
|
}
|
|
1280
1327
|
|
|
1328
|
+
private isDepartedTopicRootCandidateClaimSuppressed(
|
|
1329
|
+
origin: string,
|
|
1330
|
+
claim: TopicRootCandidateClaimRecord,
|
|
1331
|
+
): boolean {
|
|
1332
|
+
const suppressed =
|
|
1333
|
+
this.suppressedDepartedTopicRootCandidateClaims.get(origin);
|
|
1334
|
+
return (
|
|
1335
|
+
suppressed?.timestamp === claim.timestamp &&
|
|
1336
|
+
bytesEqual(suppressed.bytes, claim.bytes)
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1281
1340
|
private canAdvanceTopicRootCandidateClaimReplayFloor(
|
|
1282
1341
|
origin: string,
|
|
1283
1342
|
timestamp: bigint,
|
|
@@ -1354,6 +1413,7 @@ export class TopicControlPlane
|
|
|
1354
1413
|
private async importSignedTopicRootCandidateClaim(
|
|
1355
1414
|
rawClaim: Uint8Array,
|
|
1356
1415
|
outerPeerHash: string,
|
|
1416
|
+
expectedPeer?: PeerStreams,
|
|
1357
1417
|
): Promise<boolean> {
|
|
1358
1418
|
const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
|
|
1359
1419
|
if (
|
|
@@ -1398,11 +1458,35 @@ export class TopicControlPlane
|
|
|
1398
1458
|
if (acceptanceDeadline === undefined) {
|
|
1399
1459
|
return false;
|
|
1400
1460
|
}
|
|
1461
|
+
|
|
1462
|
+
this.rebuildAutoTopicRootCandidatesFromClaims(now);
|
|
1463
|
+
const retained = this.signedTopicRootCandidateClaims.get(origin);
|
|
1464
|
+
if (
|
|
1465
|
+
outerPeerHash === origin &&
|
|
1466
|
+
expectedPeer !== undefined &&
|
|
1467
|
+
expectedPeer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1 &&
|
|
1468
|
+
this.peers.get(outerPeerHash) === expectedPeer &&
|
|
1469
|
+
lifecycleRevision === this.topicControlPlaneLifecycleRevision &&
|
|
1470
|
+
this.autoTopicRootCandidates &&
|
|
1471
|
+
this.started &&
|
|
1472
|
+
!this.stopping &&
|
|
1473
|
+
!this.topicControlPlaneStopping &&
|
|
1474
|
+
retained?.timestamp === timestamp &&
|
|
1475
|
+
bytesEqual(retained.bytes, rawClaim) &&
|
|
1476
|
+
this.isDepartedTopicRootCandidateClaimSuppressed(origin, retained)
|
|
1477
|
+
) {
|
|
1478
|
+
// These exact bytes were already verified and remain under the original
|
|
1479
|
+
// monotonic acceptance deadline. A current authenticated origin stream is
|
|
1480
|
+
// sufficient liveness evidence; do not advance the floor or lease.
|
|
1481
|
+
this.suppressedDepartedTopicRootCandidateClaims.delete(origin);
|
|
1482
|
+
this.rebuildAutoTopicRootCandidatesFromClaims(now, { immediate: true });
|
|
1483
|
+
return false;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1401
1486
|
if (!this.canAdvanceTopicRootCandidateClaimReplayFloor(origin, timestamp)) {
|
|
1402
1487
|
return false;
|
|
1403
1488
|
}
|
|
1404
1489
|
|
|
1405
|
-
this.rebuildAutoTopicRootCandidatesFromClaims(now);
|
|
1406
1490
|
if (!this.consumeTopicRootCandidateClaimVerifyBudget(outerPeerHash)) {
|
|
1407
1491
|
return false;
|
|
1408
1492
|
}
|
|
@@ -1425,7 +1509,9 @@ export class TopicControlPlane
|
|
|
1425
1509
|
!this.autoTopicRootCandidates ||
|
|
1426
1510
|
!this.started ||
|
|
1427
1511
|
this.stopping ||
|
|
1428
|
-
this.topicControlPlaneStopping
|
|
1512
|
+
this.topicControlPlaneStopping ||
|
|
1513
|
+
(expectedPeer !== undefined &&
|
|
1514
|
+
this.peers.get(outerPeerHash) !== expectedPeer)
|
|
1429
1515
|
) {
|
|
1430
1516
|
return false;
|
|
1431
1517
|
}
|
|
@@ -1499,6 +1585,10 @@ export class TopicControlPlane
|
|
|
1499
1585
|
}
|
|
1500
1586
|
}
|
|
1501
1587
|
const claims = [...this.signedTopicRootCandidateClaims.entries()]
|
|
1588
|
+
.filter(
|
|
1589
|
+
([origin, claim]) =>
|
|
1590
|
+
!this.isDepartedTopicRootCandidateClaimSuppressed(origin, claim),
|
|
1591
|
+
)
|
|
1502
1592
|
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
1503
1593
|
.map(([, claim]) => claim.bytes);
|
|
1504
1594
|
await this.sendSignedTopicRootCandidateClaims(claims, signedStreams);
|
|
@@ -2062,7 +2152,9 @@ export class TopicControlPlane
|
|
|
2062
2152
|
|
|
2063
2153
|
private async resolveTopicRootState(
|
|
2064
2154
|
topic: string,
|
|
2065
|
-
options?: TopicRootResolutionOptions
|
|
2155
|
+
options?: TopicRootResolutionOptions & {
|
|
2156
|
+
queryConnectedPeers?: boolean;
|
|
2157
|
+
},
|
|
2066
2158
|
): Promise<{ root?: string; authoritative: boolean }> {
|
|
2067
2159
|
throwIfAborted(options?.signal);
|
|
2068
2160
|
const tracked = await this.topicRootControlPlane.resolveTrackedTopicRoot(
|
|
@@ -2072,37 +2164,80 @@ export class TopicControlPlane
|
|
|
2072
2164
|
if (tracked) {
|
|
2073
2165
|
return { root: tracked, authoritative: true };
|
|
2074
2166
|
}
|
|
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);
|
|
2167
|
+
if (options?.queryConnectedPeers === false) {
|
|
2168
|
+
return {
|
|
2169
|
+
root: this.topicRootControlPlane.resolveDeterministicTopicRoot(topic),
|
|
2170
|
+
authoritative: false,
|
|
2171
|
+
};
|
|
2087
2172
|
}
|
|
2088
2173
|
|
|
2089
|
-
const
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2174
|
+
const deadlineController = new AbortController();
|
|
2175
|
+
const deadlineTimer = setTimeout(
|
|
2176
|
+
() =>
|
|
2177
|
+
deadlineController.abort(
|
|
2178
|
+
new AbortError("topic root peer-query deadline reached"),
|
|
2179
|
+
),
|
|
2180
|
+
DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS,
|
|
2181
|
+
);
|
|
2182
|
+
deadlineTimer.unref?.();
|
|
2183
|
+
const querySignals = linkAbortSignals([
|
|
2184
|
+
options?.signal,
|
|
2185
|
+
deadlineController.signal,
|
|
2186
|
+
]);
|
|
2187
|
+
try {
|
|
2188
|
+
let attempt = 0;
|
|
2189
|
+
for (;;) {
|
|
2190
|
+
throwIfAborted(querySignals.signal);
|
|
2191
|
+
// Preserve the immediate deterministic fallback when no queryable stream
|
|
2192
|
+
// exists at entry. Once a peer was queryable, keep the bounded readiness
|
|
2193
|
+
// window alive: later rounds re-snapshot streams that can become writable
|
|
2194
|
+
// after an inbound-only dial.
|
|
2195
|
+
if (
|
|
2196
|
+
attempt === 0 &&
|
|
2197
|
+
this.getConnectedTopicRootTrackers().length === 0
|
|
2198
|
+
) {
|
|
2199
|
+
break;
|
|
2200
|
+
}
|
|
2201
|
+
if (attempt > 0) {
|
|
2202
|
+
await withAbort(
|
|
2203
|
+
delay(150 * Math.min(attempt, 2), {
|
|
2204
|
+
signal: querySignals.signal,
|
|
2205
|
+
}),
|
|
2206
|
+
querySignals.signal,
|
|
2207
|
+
);
|
|
2208
|
+
}
|
|
2209
|
+
attempt += 1;
|
|
2210
|
+
const resolvedThroughPeers = await this.resolveTopicRootThroughPeers(
|
|
2211
|
+
topic,
|
|
2212
|
+
{
|
|
2213
|
+
signal: querySignals.signal,
|
|
2214
|
+
timeoutMs: TOPIC_ROOT_QUERY_ROUND_TIMEOUT_MS,
|
|
2215
|
+
},
|
|
2100
2216
|
);
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2217
|
+
if (resolvedThroughPeers !== undefined) {
|
|
2218
|
+
// Unconfigured peer-query replies cannot override the locally
|
|
2219
|
+
// deterministic root for internal shards in auto mode. Roots,
|
|
2220
|
+
// resolvers, and trackers explicitly configured on this control
|
|
2221
|
+
// plane are resolved above and remain authoritative. A leaf relying
|
|
2222
|
+
// on a queried gateway for shard roots must first disable auto mode
|
|
2223
|
+
// with setTopicRootCandidates([]).
|
|
2224
|
+
return this.normalizePeerTopicRootState(topic, resolvedThroughPeers);
|
|
2104
2225
|
}
|
|
2105
2226
|
}
|
|
2227
|
+
} catch (error) {
|
|
2228
|
+
// Candidate, lifecycle, and caller cancellation are terminal to this
|
|
2229
|
+
// invocation and must retain their exact reason. Only the private phase
|
|
2230
|
+
// deadline falls through to the same deterministic result as exhausted
|
|
2231
|
+
// peer queries.
|
|
2232
|
+
if (options?.signal?.aborted) {
|
|
2233
|
+
throw options.signal.reason ?? error;
|
|
2234
|
+
}
|
|
2235
|
+
if (!deadlineController.signal.aborted) {
|
|
2236
|
+
throw error;
|
|
2237
|
+
}
|
|
2238
|
+
} finally {
|
|
2239
|
+
clearTimeout(deadlineTimer);
|
|
2240
|
+
querySignals.clear();
|
|
2106
2241
|
}
|
|
2107
2242
|
|
|
2108
2243
|
return {
|
|
@@ -2128,7 +2263,9 @@ export class TopicControlPlane
|
|
|
2128
2263
|
|
|
2129
2264
|
private async resolveShardRootState(
|
|
2130
2265
|
shardTopic: string,
|
|
2131
|
-
options?: TopicRootResolutionOptions
|
|
2266
|
+
options?: TopicRootResolutionOptions & {
|
|
2267
|
+
queryConnectedPeers?: boolean;
|
|
2268
|
+
},
|
|
2132
2269
|
): Promise<{ root: string; candidateGeneration: string }> {
|
|
2133
2270
|
const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
|
|
2134
2271
|
for (;;) {
|
|
@@ -2143,8 +2280,14 @@ export class TopicControlPlane
|
|
|
2143
2280
|
|
|
2144
2281
|
const candidateGeneration = this.getTopicRootCandidateGeneration();
|
|
2145
2282
|
const hasConnectedTrackers =
|
|
2283
|
+
options?.queryConnectedPeers !== false &&
|
|
2146
2284
|
this.getConnectedTopicRootTrackers().length > 0;
|
|
2147
|
-
|
|
2285
|
+
// A proactive local scan must not inherit an authoritative cache entry that
|
|
2286
|
+
// originated from a connected-peer reply under the same candidate generation.
|
|
2287
|
+
const cached =
|
|
2288
|
+
options?.queryConnectedPeers === false
|
|
2289
|
+
? undefined
|
|
2290
|
+
: this.shardRootCache.get(shardTopic);
|
|
2148
2291
|
if (cached && (cached.authoritative || !hasConnectedTrackers)) {
|
|
2149
2292
|
return { root: cached.root, candidateGeneration };
|
|
2150
2293
|
}
|
|
@@ -2201,6 +2344,7 @@ export class TopicControlPlane
|
|
|
2201
2344
|
private async sendDirectControlMessage(
|
|
2202
2345
|
peer: PeerStreams,
|
|
2203
2346
|
pubsubMessage: PubSubMessage,
|
|
2347
|
+
signal?: AbortSignal,
|
|
2204
2348
|
) {
|
|
2205
2349
|
const embedded = await this.createMessage(
|
|
2206
2350
|
this.encodePubSubMessage(pubsubMessage),
|
|
@@ -2213,7 +2357,13 @@ export class TopicControlPlane
|
|
|
2213
2357
|
skipRecipientValidation: true,
|
|
2214
2358
|
} as any,
|
|
2215
2359
|
);
|
|
2216
|
-
await this.publishMessage(
|
|
2360
|
+
await this.publishMessage(
|
|
2361
|
+
this.publicKey,
|
|
2362
|
+
embedded,
|
|
2363
|
+
[peer],
|
|
2364
|
+
undefined,
|
|
2365
|
+
signal,
|
|
2366
|
+
);
|
|
2217
2367
|
}
|
|
2218
2368
|
|
|
2219
2369
|
private resolvePendingTopicRootQuery(
|
|
@@ -2245,6 +2395,9 @@ export class TopicControlPlane
|
|
|
2245
2395
|
|
|
2246
2396
|
const expectedPeerHash = peer.publicKey.hashcode();
|
|
2247
2397
|
const requestId = this.nextTopicRootRequestIdValue();
|
|
2398
|
+
const queryController = new AbortController();
|
|
2399
|
+
const querySignals = linkAbortSignals([signal, queryController.signal]);
|
|
2400
|
+
let timedOut = false;
|
|
2248
2401
|
const responsePromise = new Promise<string | undefined>((resolve) => {
|
|
2249
2402
|
let settled = false;
|
|
2250
2403
|
const settle = (root: string | undefined) => {
|
|
@@ -2255,14 +2408,16 @@ export class TopicControlPlane
|
|
|
2255
2408
|
this.pendingTopicRootQueries.delete(requestId);
|
|
2256
2409
|
}
|
|
2257
2410
|
clearTimeout(timer);
|
|
2258
|
-
|
|
2259
|
-
signal.removeEventListener("abort", onAbort);
|
|
2260
|
-
}
|
|
2411
|
+
querySignals.signal.removeEventListener("abort", onAbort);
|
|
2261
2412
|
resolve(root);
|
|
2262
2413
|
};
|
|
2263
|
-
const onAbort =
|
|
2414
|
+
const onAbort = () => settle(undefined);
|
|
2264
2415
|
const timer = setTimeout(
|
|
2265
|
-
() =>
|
|
2416
|
+
() => {
|
|
2417
|
+
timedOut = true;
|
|
2418
|
+
settle(undefined);
|
|
2419
|
+
queryController.abort(new AbortError("topic root query timed out"));
|
|
2420
|
+
},
|
|
2266
2421
|
Math.max(1, Math.floor(timeoutMs)),
|
|
2267
2422
|
);
|
|
2268
2423
|
timer.unref?.();
|
|
@@ -2272,31 +2427,45 @@ export class TopicControlPlane
|
|
|
2272
2427
|
resolve: settle,
|
|
2273
2428
|
timer,
|
|
2274
2429
|
});
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
if (signal.aborted) onAbort();
|
|
2278
|
-
}
|
|
2430
|
+
querySignals.signal.addEventListener("abort", onAbort, { once: true });
|
|
2431
|
+
if (querySignals.signal.aborted) onAbort();
|
|
2279
2432
|
});
|
|
2280
2433
|
|
|
2281
2434
|
try {
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2435
|
+
try {
|
|
2436
|
+
const sending = withAbort(
|
|
2437
|
+
this.sendDirectControlMessage(
|
|
2438
|
+
peer,
|
|
2439
|
+
new TopicRootQuery({ requestId, topic }),
|
|
2440
|
+
querySignals.signal,
|
|
2441
|
+
),
|
|
2442
|
+
querySignals.signal,
|
|
2443
|
+
);
|
|
2444
|
+
// A write can finish after the request is already on the wire (or stall
|
|
2445
|
+
// while opening the reverse stream). Let either a response or the same
|
|
2446
|
+
// query timeout release this await instead of extending the timeout around
|
|
2447
|
+
// the outbound send.
|
|
2448
|
+
await Promise.race([
|
|
2449
|
+
sending,
|
|
2450
|
+
responsePromise.then((): undefined => undefined),
|
|
2451
|
+
]);
|
|
2452
|
+
} catch (error) {
|
|
2453
|
+
const pending = this.pendingTopicRootQueries.get(requestId);
|
|
2454
|
+
if (pending && !timedOut) {
|
|
2455
|
+
this.pendingTopicRootQueries.delete(requestId);
|
|
2456
|
+
clearTimeout(pending.timer);
|
|
2457
|
+
pending.resolve(undefined);
|
|
2458
|
+
}
|
|
2459
|
+
if (signal?.aborted) {
|
|
2460
|
+
throw signal.reason ?? error;
|
|
2461
|
+
}
|
|
2295
2462
|
}
|
|
2296
|
-
if (signal?.aborted) throw error;
|
|
2297
|
-
}
|
|
2298
2463
|
|
|
2299
|
-
|
|
2464
|
+
return await withAbort(responsePromise, signal);
|
|
2465
|
+
} finally {
|
|
2466
|
+
queryController.abort(new AbortError("topic root query settled"));
|
|
2467
|
+
querySignals.clear();
|
|
2468
|
+
}
|
|
2300
2469
|
}
|
|
2301
2470
|
|
|
2302
2471
|
private async confirmDirectShardRoot(
|
|
@@ -2390,7 +2559,7 @@ export class TopicControlPlane
|
|
|
2390
2559
|
|
|
2391
2560
|
private async resolveTopicRootThroughPeers(
|
|
2392
2561
|
topic: string,
|
|
2393
|
-
options?: TopicRootResolutionOptions,
|
|
2562
|
+
options?: TopicRootResolutionOptions & { timeoutMs?: number },
|
|
2394
2563
|
): Promise<string | undefined> {
|
|
2395
2564
|
throwIfAborted(options?.signal);
|
|
2396
2565
|
const peers = this.getConnectedTopicRootTrackers();
|
|
@@ -2398,24 +2567,82 @@ export class TopicControlPlane
|
|
|
2398
2567
|
return undefined;
|
|
2399
2568
|
}
|
|
2400
2569
|
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2570
|
+
const roundController = new AbortController();
|
|
2571
|
+
const roundTimeoutMs = Math.max(
|
|
2572
|
+
1,
|
|
2573
|
+
Math.floor(options?.timeoutMs ?? DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS),
|
|
2574
|
+
);
|
|
2575
|
+
let roundTimedOut = false;
|
|
2576
|
+
const roundTimer = setTimeout(() => {
|
|
2577
|
+
roundTimedOut = true;
|
|
2578
|
+
roundController.abort(new AbortError("topic root query round timed out"));
|
|
2579
|
+
}, roundTimeoutMs);
|
|
2580
|
+
roundTimer.unref?.();
|
|
2581
|
+
const roundSignals = linkAbortSignals([
|
|
2582
|
+
options?.signal,
|
|
2583
|
+
roundController.signal,
|
|
2584
|
+
]);
|
|
2585
|
+
const queries = peers.map((peer) =>
|
|
2586
|
+
this.queryTopicRootFromPeer(
|
|
2587
|
+
peer,
|
|
2588
|
+
topic,
|
|
2589
|
+
roundTimeoutMs,
|
|
2590
|
+
roundSignals.signal,
|
|
2591
|
+
),
|
|
2592
|
+
);
|
|
2593
|
+
const unresolved = Symbol("unresolved topic root query");
|
|
2594
|
+
const results: Array<string | undefined | typeof unresolved> = peers.map(
|
|
2595
|
+
() => unresolved,
|
|
2596
|
+
);
|
|
2597
|
+
// All requests start together, but the stable peer ordering remains the
|
|
2598
|
+
// conflict-resolution policy: a later answer is usable only after every
|
|
2599
|
+
// earlier peer has answered undefined or reached its round timeout.
|
|
2600
|
+
const prioritizedResult = new Promise<string | undefined>(
|
|
2601
|
+
(resolve, reject) => {
|
|
2602
|
+
let settled = false;
|
|
2603
|
+
const resolveInPeerOrder = () => {
|
|
2604
|
+
for (const result of results) {
|
|
2605
|
+
if (result === unresolved) return;
|
|
2606
|
+
if (result !== undefined) {
|
|
2607
|
+
settled = true;
|
|
2608
|
+
resolve(result);
|
|
2609
|
+
return;
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
settled = true;
|
|
2613
|
+
resolve(undefined);
|
|
2614
|
+
};
|
|
2615
|
+
queries.forEach((query, index) => {
|
|
2616
|
+
query.then(
|
|
2617
|
+
(root) => {
|
|
2618
|
+
if (settled) return;
|
|
2619
|
+
results[index] = root;
|
|
2620
|
+
resolveInPeerOrder();
|
|
2621
|
+
},
|
|
2622
|
+
(error) => {
|
|
2623
|
+
if (settled) return;
|
|
2624
|
+
if (roundTimedOut && !options?.signal?.aborted) {
|
|
2625
|
+
results[index] = undefined;
|
|
2626
|
+
resolveInPeerOrder();
|
|
2627
|
+
return;
|
|
2628
|
+
}
|
|
2629
|
+
settled = true;
|
|
2630
|
+
reject(error);
|
|
2631
|
+
},
|
|
2632
|
+
);
|
|
2633
|
+
});
|
|
2634
|
+
},
|
|
2635
|
+
);
|
|
2636
|
+
try {
|
|
2637
|
+
return await prioritizedResult;
|
|
2638
|
+
} finally {
|
|
2639
|
+
clearTimeout(roundTimer);
|
|
2640
|
+
// Cancel response timers and in-flight sends for every losing query before
|
|
2641
|
+
// the winning root (or an abort) can leave this round.
|
|
2642
|
+
roundController.abort(new AbortError("topic root query round settled"));
|
|
2643
|
+
await Promise.allSettled(queries);
|
|
2644
|
+
roundSignals.clear();
|
|
2417
2645
|
}
|
|
2418
|
-
return undefined;
|
|
2419
2646
|
}
|
|
2420
2647
|
|
|
2421
2648
|
private async ensureFanoutChannel(
|
|
@@ -2833,25 +3060,77 @@ export class TopicControlPlane
|
|
|
2833
3060
|
if (!this.started) throw new NotStartedError();
|
|
2834
3061
|
const lifecycleSignal = this.topicRootResolutionAbortController.signal;
|
|
2835
3062
|
return this.withTopicRootCandidateResolution(
|
|
2836
|
-
async ({
|
|
3063
|
+
async ({ signal }) => {
|
|
2837
3064
|
const joins: Promise<void>[] = [];
|
|
3065
|
+
const scanController = new AbortController();
|
|
3066
|
+
const scanTimer = setTimeout(() => {
|
|
3067
|
+
scanController.abort(
|
|
3068
|
+
new AbortError(
|
|
3069
|
+
`topic root host scan timed out after ${TOPIC_ROOT_HOST_SCAN_TIMEOUT_MS}ms`,
|
|
3070
|
+
),
|
|
3071
|
+
);
|
|
3072
|
+
}, TOPIC_ROOT_HOST_SCAN_TIMEOUT_MS);
|
|
3073
|
+
scanTimer.unref?.();
|
|
3074
|
+
const scanSignals = linkAbortSignals([signal, scanController.signal]);
|
|
3075
|
+
let nextShard = 0;
|
|
3076
|
+
let scanFailure: { error: unknown } | undefined;
|
|
3077
|
+
const scanWorker = async () => {
|
|
3078
|
+
try {
|
|
3079
|
+
for (;;) {
|
|
3080
|
+
if (scanFailure) return;
|
|
3081
|
+
throwIfAborted(scanSignals.signal);
|
|
3082
|
+
const shard = nextShard++;
|
|
3083
|
+
if (shard >= this.shardCount) return;
|
|
3084
|
+
const shardTopic = `${this.shardTopicPrefix}${shard}`;
|
|
3085
|
+
const resolved = await this.resolveShardRootState(shardTopic, {
|
|
3086
|
+
signal: scanSignals.signal,
|
|
3087
|
+
// Proactive hosting follows the generation-bound local control
|
|
3088
|
+
// plane. Connected-peer queries remain on the lazy open path;
|
|
3089
|
+
// asking every peer for every shard multiplies one stale-peer
|
|
3090
|
+
// timeout by the complete shard count.
|
|
3091
|
+
queryConnectedPeers: false,
|
|
3092
|
+
});
|
|
3093
|
+
throwIfAborted(scanSignals.signal);
|
|
3094
|
+
if (scanFailure || resolved.root !== this.publicKeyHash) continue;
|
|
3095
|
+
const joining = this.ensureFanoutChannel(shardTopic, {
|
|
3096
|
+
pin: true,
|
|
3097
|
+
root: resolved.root,
|
|
3098
|
+
rootCandidateGeneration: resolved.candidateGeneration,
|
|
3099
|
+
// The shared deadline bounds root discovery only. Preserve the
|
|
3100
|
+
// existing contract that joins already started are drained.
|
|
3101
|
+
signal,
|
|
3102
|
+
});
|
|
3103
|
+
void joining.catch(() => {});
|
|
3104
|
+
joins.push(joining);
|
|
3105
|
+
}
|
|
3106
|
+
} catch (error) {
|
|
3107
|
+
if (!scanFailure) {
|
|
3108
|
+
scanFailure = { error };
|
|
3109
|
+
// Do not make a sibling resolver consume the rest of the shared
|
|
3110
|
+
// deadline after this scan already has a terminal failure. Joins use
|
|
3111
|
+
// the outer signal and are intentionally drained below.
|
|
3112
|
+
scanController.abort(error);
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
};
|
|
2838
3116
|
try {
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
3117
|
+
try {
|
|
3118
|
+
await Promise.all(
|
|
3119
|
+
Array.from(
|
|
3120
|
+
{
|
|
3121
|
+
length: Math.min(
|
|
3122
|
+
TOPIC_ROOT_HOST_SCAN_CONCURRENCY,
|
|
3123
|
+
this.shardCount,
|
|
3124
|
+
),
|
|
3125
|
+
},
|
|
3126
|
+
() => scanWorker(),
|
|
3127
|
+
),
|
|
3128
|
+
);
|
|
3129
|
+
} finally {
|
|
3130
|
+
clearTimeout(scanTimer);
|
|
3131
|
+
scanSignals.clear();
|
|
2854
3132
|
}
|
|
3133
|
+
if (scanFailure) throw scanFailure.error;
|
|
2855
3134
|
await Promise.all(joins);
|
|
2856
3135
|
} catch (error) {
|
|
2857
3136
|
await Promise.allSettled(joins);
|
|
@@ -3808,7 +4087,11 @@ export class TopicControlPlane
|
|
|
3808
4087
|
const outerPeerHash = stream.publicKey.hashcode();
|
|
3809
4088
|
for (const claim of pubsubMessage.claims) {
|
|
3810
4089
|
if (
|
|
3811
|
-
await this.importSignedTopicRootCandidateClaim(
|
|
4090
|
+
await this.importSignedTopicRootCandidateClaim(
|
|
4091
|
+
claim,
|
|
4092
|
+
outerPeerHash,
|
|
4093
|
+
stream,
|
|
4094
|
+
)
|
|
3812
4095
|
) {
|
|
3813
4096
|
imported.push(claim);
|
|
3814
4097
|
}
|