@peerbit/pubsub 5.4.8 → 5.4.11
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 +11 -1
- package/dist/src/fanout-tree.d.ts.map +1 -1
- package/dist/src/fanout-tree.js +228 -38
- package/dist/src/fanout-tree.js.map +1 -1
- package/dist/src/index.d.ts +11 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +641 -141
- package/dist/src/index.js.map +1 -1
- package/package.json +5 -5
- package/src/fanout-tree.ts +301 -42
- package/src/index.ts +899 -190
package/dist/src/index.js
CHANGED
|
@@ -144,6 +144,8 @@ const TOPIC_ROOT_QUERY_ROUND_TIMEOUT_MS = 3_000;
|
|
|
144
144
|
const TOPIC_ROOT_HOST_SCAN_TIMEOUT_MS = DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS;
|
|
145
145
|
const TOPIC_ROOT_HOST_SCAN_CONCURRENCY = 8;
|
|
146
146
|
const DIRECT_SHARD_ROOT_CONFIRM_TIMEOUT_MS = 2_000;
|
|
147
|
+
const RELAY_ROOT_PREFLIGHT_TIMEOUT_MS = 2_000;
|
|
148
|
+
const RELAY_ROOT_PREFLIGHT_REUSE_MS = 2_000;
|
|
147
149
|
const DEFAULT_PUBSUB_FANOUT_CHANNEL_OPTIONS = {
|
|
148
150
|
msgRate: 30,
|
|
149
151
|
msgSize: 1024,
|
|
@@ -225,9 +227,9 @@ export class TopicControlPlane extends DirectStream {
|
|
|
225
227
|
hostOwnedShardRootsInFlight;
|
|
226
228
|
hostOwnedShardRootsDirty = false;
|
|
227
229
|
signedTopicRootCandidateClaims = new Map();
|
|
228
|
-
|
|
229
|
-
//
|
|
230
|
-
//
|
|
230
|
+
relayRootProbeCohort;
|
|
231
|
+
// Locally observed unreachability makes only that origin's current lease
|
|
232
|
+
// ineffective locally. This is not a component-wide withdrawal. Keeping the verified
|
|
231
233
|
// bytes + replay floor prevents a relay from resurrecting the departed lease;
|
|
232
234
|
// the map is bounded by the same retained-origin cap as the claim map.
|
|
233
235
|
suppressedDepartedTopicRootCandidateClaims = new Map();
|
|
@@ -245,6 +247,34 @@ export class TopicControlPlane extends DirectStream {
|
|
|
245
247
|
refilledAt: Date.now(),
|
|
246
248
|
};
|
|
247
249
|
_onFanoutPeerUnreachable;
|
|
250
|
+
onFanoutAttached = (ev) => {
|
|
251
|
+
const st = this.fanoutChannels.get(ev.detail.topic);
|
|
252
|
+
if (!st || st.root !== ev.detail.root)
|
|
253
|
+
return;
|
|
254
|
+
// A successful publish before attachment may have had no listeners.
|
|
255
|
+
// Coalesce attachment bursts, retaining one trailing pass during a send.
|
|
256
|
+
st.announceDirty = true;
|
|
257
|
+
if (st.announceTask)
|
|
258
|
+
return;
|
|
259
|
+
const revision = this.topicControlPlaneLifecycleRevision;
|
|
260
|
+
st.announceTask = Promise.resolve()
|
|
261
|
+
.then(async () => {
|
|
262
|
+
await st.join;
|
|
263
|
+
while (st.announceDirty &&
|
|
264
|
+
this.isFanoutChannelCurrent(ev.detail.topic, st.channel, revision)) {
|
|
265
|
+
st.announceDirty = false;
|
|
266
|
+
await this.announceShardSubscriptions(ev.detail.topic, revision);
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
.catch(logErrorIfStarted)
|
|
270
|
+
.finally(() => {
|
|
271
|
+
st.announceTask = undefined;
|
|
272
|
+
if (st.announceDirty &&
|
|
273
|
+
this.isFanoutChannelCurrent(ev.detail.topic, st.channel, revision)) {
|
|
274
|
+
this.onFanoutAttached(ev);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
};
|
|
248
278
|
fanoutChannels = new Map();
|
|
249
279
|
ensureFanoutChannelInFlight = new Map();
|
|
250
280
|
pendingTopicRootQueries = new Map();
|
|
@@ -387,6 +417,8 @@ export class TopicControlPlane extends DirectStream {
|
|
|
387
417
|
this._onFanoutPeerUnreachable =
|
|
388
418
|
this._onFanoutPeerUnreachable || this.onFanoutPeerUnreachable.bind(this);
|
|
389
419
|
await this.fanout.addEventListener("fanout:peer-unreachable", this._onFanoutPeerUnreachable);
|
|
420
|
+
this.fanout.addEventListener("fanout:joined", this.onFanoutAttached);
|
|
421
|
+
this.fanout.addEventListener("fanout:child-joined", this.onFanoutAttached);
|
|
390
422
|
await super.start();
|
|
391
423
|
if (this.autoTopicRootCandidates) {
|
|
392
424
|
let retryDelayMs = 0;
|
|
@@ -427,9 +459,10 @@ export class TopicControlPlane extends DirectStream {
|
|
|
427
459
|
if (this._onFanoutPeerUnreachable) {
|
|
428
460
|
this.fanout.removeEventListener("fanout:peer-unreachable", this._onFanoutPeerUnreachable);
|
|
429
461
|
}
|
|
462
|
+
this.fanout.removeEventListener("fanout:joined", this.onFanoutAttached);
|
|
463
|
+
this.fanout.removeEventListener("fanout:child-joined", this.onFanoutAttached);
|
|
430
464
|
for (const st of this.fanoutChannels.values()) {
|
|
431
|
-
|
|
432
|
-
clearTimeout(st.idleCloseTimeout);
|
|
465
|
+
this.clearFanoutIdleClose(st);
|
|
433
466
|
try {
|
|
434
467
|
st.channel.removeEventListener("data", st.onData);
|
|
435
468
|
}
|
|
@@ -492,22 +525,23 @@ export class TopicControlPlane extends DirectStream {
|
|
|
492
525
|
this.rebuildAutoTopicRootCandidatesFromClaims(BigInt(Date.now()), {
|
|
493
526
|
immediate: hadSignedPeer !== hasSignedPeer,
|
|
494
527
|
});
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
void this.sendAutoTopicRootCandidates([peer]).catch(() => { });
|
|
505
|
-
}
|
|
506
|
-
if (existingPeer !== peer) {
|
|
507
|
-
peer.addEventListener("stream:outbound", sendWhenOutboundReady);
|
|
508
|
-
peer.addEventListener("close", () => peer.removeEventListener("stream:outbound", sendWhenOutboundReady), { once: true });
|
|
509
|
-
}
|
|
528
|
+
}
|
|
529
|
+
const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
|
|
530
|
+
const sendWhenOutboundReady = () => {
|
|
531
|
+
if (!this.isTopicControlPlaneActive(lifecycleRevision) ||
|
|
532
|
+
this.peers.get(publicKey.hashcode()) !== peer)
|
|
533
|
+
return;
|
|
534
|
+
if (this.autoTopicRootCandidates &&
|
|
535
|
+
peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1) {
|
|
536
|
+
void this.sendAutoTopicRootCandidates([peer]).catch(() => { });
|
|
510
537
|
}
|
|
538
|
+
void this.announceDirectSubscriptions([...this.subscriptions.keys()], [peer]).catch(logErrorIfStarted);
|
|
539
|
+
};
|
|
540
|
+
if (peer.isWritable)
|
|
541
|
+
sendWhenOutboundReady();
|
|
542
|
+
if (existingPeer !== peer) {
|
|
543
|
+
peer.addEventListener("stream:outbound", sendWhenOutboundReady);
|
|
544
|
+
peer.addEventListener("close", () => peer.removeEventListener("stream:outbound", sendWhenOutboundReady), { once: true });
|
|
511
545
|
}
|
|
512
546
|
return peer;
|
|
513
547
|
}
|
|
@@ -525,15 +559,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
525
559
|
protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1 &&
|
|
526
560
|
this.signedTopicRootCandidateClaims.has(hash)) {
|
|
527
561
|
const claim = this.signedTopicRootCandidateClaims.get(hash);
|
|
528
|
-
|
|
529
|
-
suppressedDepartedOrigin =
|
|
530
|
-
!suppressed ||
|
|
531
|
-
suppressed.timestamp !== claim.timestamp ||
|
|
532
|
-
!bytesEqual(suppressed.bytes, claim.bytes);
|
|
533
|
-
this.suppressedDepartedTopicRootCandidateClaims.set(hash, {
|
|
534
|
-
bytes: claim.bytes,
|
|
535
|
-
timestamp: claim.timestamp,
|
|
536
|
-
});
|
|
562
|
+
suppressedDepartedOrigin = this.suppressTopicRootCandidateClaim(hash, claim);
|
|
537
563
|
}
|
|
538
564
|
if (this.autoTopicRootCandidates &&
|
|
539
565
|
(protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_0 ||
|
|
@@ -577,6 +603,8 @@ export class TopicControlPlane extends DirectStream {
|
|
|
577
603
|
}
|
|
578
604
|
}
|
|
579
605
|
clearSignedTopicRootCandidateState() {
|
|
606
|
+
this.relayRootProbeCohort?.controller.abort(new AbortError("topic root candidate recovery stopped"));
|
|
607
|
+
this.relayRootProbeCohort = undefined;
|
|
580
608
|
this.clearTopicRootCandidateClaimTimer();
|
|
581
609
|
this.signedTopicRootCandidateClaims.clear();
|
|
582
610
|
this.suppressedDepartedTopicRootCandidateClaims.clear();
|
|
@@ -863,6 +891,264 @@ export class TopicControlPlane extends DirectStream {
|
|
|
863
891
|
return (suppressed?.timestamp === claim.timestamp &&
|
|
864
892
|
bytesEqual(suppressed.bytes, claim.bytes));
|
|
865
893
|
}
|
|
894
|
+
suppressTopicRootCandidateClaim(origin, claim) {
|
|
895
|
+
if (!this.autoTopicRootCandidates ||
|
|
896
|
+
origin === this.publicKeyHash ||
|
|
897
|
+
this.peers.has(origin) ||
|
|
898
|
+
this.signedTopicRootCandidateClaims.get(origin) !== claim ||
|
|
899
|
+
this.isDepartedTopicRootCandidateClaimSuppressed(origin, claim))
|
|
900
|
+
return false;
|
|
901
|
+
this.suppressedDepartedTopicRootCandidateClaims.set(origin, {
|
|
902
|
+
bytes: claim.bytes,
|
|
903
|
+
timestamp: claim.timestamp,
|
|
904
|
+
});
|
|
905
|
+
return true;
|
|
906
|
+
}
|
|
907
|
+
topicRootRouteObservations(origin) {
|
|
908
|
+
return this.routes
|
|
909
|
+
.getRouteHints(this.publicKeyHash, origin)
|
|
910
|
+
.map((hint) => JSON.stringify([hint.nextHop, hint.session, hint.updatedAt]));
|
|
911
|
+
}
|
|
912
|
+
createRelayRootProbeCohort(claims) {
|
|
913
|
+
const previous = this.relayRootProbeCohort?.results;
|
|
914
|
+
const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
|
|
915
|
+
const controller = new AbortController();
|
|
916
|
+
const users = new Set();
|
|
917
|
+
const deadline = new AbortError("relay root reachability deadline reached");
|
|
918
|
+
const timer = setTimeout(() => controller.abort(deadline), RELAY_ROOT_PREFLIGHT_TIMEOUT_MS);
|
|
919
|
+
timer.unref?.();
|
|
920
|
+
let resolveRetry;
|
|
921
|
+
const retryBoundary = new Promise((resolve) => {
|
|
922
|
+
resolveRetry = resolve;
|
|
923
|
+
});
|
|
924
|
+
const retryTimer = setTimeout(resolveRetry, RELAY_ROOT_PREFLIGHT_TIMEOUT_MS / 2);
|
|
925
|
+
retryTimer.unref?.();
|
|
926
|
+
const captured = claims.map(([origin, claim]) => ({
|
|
927
|
+
origin,
|
|
928
|
+
claim,
|
|
929
|
+
routes: this.topicRootRouteObservations(origin),
|
|
930
|
+
}));
|
|
931
|
+
const cohort = {
|
|
932
|
+
controller,
|
|
933
|
+
users,
|
|
934
|
+
lifecycleRevision,
|
|
935
|
+
promise: Promise.resolve()
|
|
936
|
+
.then(async () => {
|
|
937
|
+
const outcomes = await Promise.all(captured.map(async ({ origin, claim, routes }) => {
|
|
938
|
+
const cached = previous?.get(origin);
|
|
939
|
+
if (cached?.claim === claim &&
|
|
940
|
+
cached.status === "alive" &&
|
|
941
|
+
performance.now() - cached.observedAt <
|
|
942
|
+
RELAY_ROOT_PREFLIGHT_REUSE_MS)
|
|
943
|
+
return [origin, { ...cached, routes }];
|
|
944
|
+
const winner = new AbortController();
|
|
945
|
+
const signals = linkAbortSignals([
|
|
946
|
+
controller.signal,
|
|
947
|
+
winner.signal,
|
|
948
|
+
]);
|
|
949
|
+
let alive = false;
|
|
950
|
+
let resolveAlive;
|
|
951
|
+
const firstSuccess = new Promise((resolve) => {
|
|
952
|
+
resolveAlive = resolve;
|
|
953
|
+
});
|
|
954
|
+
const probe = async (retry) => {
|
|
955
|
+
const signal = signals.signal;
|
|
956
|
+
let submitted = false;
|
|
957
|
+
try {
|
|
958
|
+
if (retry)
|
|
959
|
+
await withAbort(retryBoundary, signal);
|
|
960
|
+
throwIfAborted(signal);
|
|
961
|
+
if (alive)
|
|
962
|
+
return "alive";
|
|
963
|
+
const message = await withAbort(this.createMessage(undefined, {
|
|
964
|
+
mode: new AcknowledgeDelivery({
|
|
965
|
+
to: [origin],
|
|
966
|
+
redundancy: 1,
|
|
967
|
+
}),
|
|
968
|
+
priority: 1,
|
|
969
|
+
responsePriority: 1,
|
|
970
|
+
}), signal);
|
|
971
|
+
// Signers may ignore cancellation. Fence the send continuation.
|
|
972
|
+
throwIfAborted(signal);
|
|
973
|
+
if (alive)
|
|
974
|
+
return "alive";
|
|
975
|
+
if (!this.isTopicControlPlaneActive(lifecycleRevision) ||
|
|
976
|
+
![...users].some((current) => current())) {
|
|
977
|
+
return "unknown";
|
|
978
|
+
}
|
|
979
|
+
// No transport neighbor is local readiness, not proof of origin death.
|
|
980
|
+
if (this.peers.size === 0)
|
|
981
|
+
return "unknown";
|
|
982
|
+
submitted = true;
|
|
983
|
+
await withAbort(this.publishMessage(this.publicKey, message, undefined, undefined, signal), signal);
|
|
984
|
+
throwIfAborted(signal);
|
|
985
|
+
alive = true;
|
|
986
|
+
resolveAlive("alive");
|
|
987
|
+
return "alive";
|
|
988
|
+
}
|
|
989
|
+
catch (error) {
|
|
990
|
+
if (submitted &&
|
|
991
|
+
(error === deadline || error instanceof DeliveryError))
|
|
992
|
+
return "failed";
|
|
993
|
+
else if (!signal.aborted)
|
|
994
|
+
throw error;
|
|
995
|
+
return "unknown";
|
|
996
|
+
}
|
|
997
|
+
};
|
|
998
|
+
// Hedge once at the shared midpoint without discarding the first
|
|
999
|
+
// authenticated ACK wait: a 1.5s first reply is still timely.
|
|
1000
|
+
let status;
|
|
1001
|
+
try {
|
|
1002
|
+
status = await Promise.race([
|
|
1003
|
+
firstSuccess,
|
|
1004
|
+
Promise.all([probe(false), probe(true)]).then((results) => results.includes("unknown")
|
|
1005
|
+
? "unknown"
|
|
1006
|
+
: "failed"),
|
|
1007
|
+
]);
|
|
1008
|
+
}
|
|
1009
|
+
finally {
|
|
1010
|
+
winner.abort(new AbortError("origin probe settled"));
|
|
1011
|
+
signals.clear();
|
|
1012
|
+
}
|
|
1013
|
+
return [
|
|
1014
|
+
origin,
|
|
1015
|
+
{ claim, routes, status, observedAt: performance.now() },
|
|
1016
|
+
];
|
|
1017
|
+
}));
|
|
1018
|
+
cohort.results = new Map(outcomes);
|
|
1019
|
+
return cohort.results;
|
|
1020
|
+
})
|
|
1021
|
+
.finally(() => {
|
|
1022
|
+
clearTimeout(timer);
|
|
1023
|
+
clearTimeout(retryTimer);
|
|
1024
|
+
controller.abort(new AbortError("relay root probes settled"));
|
|
1025
|
+
}),
|
|
1026
|
+
};
|
|
1027
|
+
this.relayRootProbeCohort = cohort;
|
|
1028
|
+
return cohort;
|
|
1029
|
+
}
|
|
1030
|
+
async preflightRelayRootCandidates(shardTopic, attempt, signal, isCurrent, localPolicyOnly = false) {
|
|
1031
|
+
throwIfAborted(signal);
|
|
1032
|
+
const revision = this.topicControlPlaneLifecycleRevision;
|
|
1033
|
+
const current = () => isCurrent() && this.isTopicControlPlaneActive(revision);
|
|
1034
|
+
if (!current())
|
|
1035
|
+
throw new AbortError("obsolete relay root recovery");
|
|
1036
|
+
if (!this.autoTopicRootCandidates ||
|
|
1037
|
+
this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured())
|
|
1038
|
+
return false;
|
|
1039
|
+
const claims = [...this.signedTopicRootCandidateClaims.entries()]
|
|
1040
|
+
.filter(([origin, claim]) => origin !== this.publicKeyHash &&
|
|
1041
|
+
!this.peers.has(origin) &&
|
|
1042
|
+
!this.isDepartedTopicRootCandidateClaimSuppressed(origin, claim))
|
|
1043
|
+
.slice(0, TOPIC_ROOT_CANDIDATES_MAX);
|
|
1044
|
+
if (claims.length === 0)
|
|
1045
|
+
return false;
|
|
1046
|
+
const shared = this.relayRootProbeCohort;
|
|
1047
|
+
const cohort = (attempt.cohort ??=
|
|
1048
|
+
shared &&
|
|
1049
|
+
(shared.results
|
|
1050
|
+
? shared.users.size > 0
|
|
1051
|
+
: !shared.controller.signal.aborted) &&
|
|
1052
|
+
shared.lifecycleRevision === revision
|
|
1053
|
+
? shared
|
|
1054
|
+
: this.createRelayRootProbeCohort(claims));
|
|
1055
|
+
cohort.users.add(current);
|
|
1056
|
+
let held = true;
|
|
1057
|
+
const release = () => {
|
|
1058
|
+
if (!held)
|
|
1059
|
+
return;
|
|
1060
|
+
held = false;
|
|
1061
|
+
cohort.users.delete(current);
|
|
1062
|
+
if (cohort.users.size === 0 && !cohort.results) {
|
|
1063
|
+
cohort.controller.abort(new AbortError("relay root recovery cancelled"));
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
signal?.addEventListener("abort", release, { once: true });
|
|
1067
|
+
try {
|
|
1068
|
+
const outcomes = await withAbort(cohort.promise, signal);
|
|
1069
|
+
throwIfAborted(signal);
|
|
1070
|
+
if (!current())
|
|
1071
|
+
throw new AbortError("obsolete relay root recovery");
|
|
1072
|
+
// Policy setters do not share the candidate generation. Re-read configured
|
|
1073
|
+
// roots/resolvers/trackers after observation; a stalled policy is unknown,
|
|
1074
|
+
// never evidence authorizing local suppression.
|
|
1075
|
+
attempt.policyDeadline ??=
|
|
1076
|
+
performance.now() + RELAY_ROOT_PREFLIGHT_TIMEOUT_MS;
|
|
1077
|
+
return await this.withRelayRootPolicyDeadline(attempt, signal, async (policySignal) => {
|
|
1078
|
+
const configured = await withAbort(localPolicyOnly
|
|
1079
|
+
? this.topicRootControlPlane.resolveLocalTopicRoot(shardTopic, {
|
|
1080
|
+
signal: policySignal,
|
|
1081
|
+
})
|
|
1082
|
+
: this.topicRootControlPlane.resolveTrackedTopicRoot(shardTopic, {
|
|
1083
|
+
signal: policySignal,
|
|
1084
|
+
}), policySignal);
|
|
1085
|
+
throwIfAborted(policySignal);
|
|
1086
|
+
if (!current())
|
|
1087
|
+
throw new AbortError("obsolete relay root recovery");
|
|
1088
|
+
this.shardRootCache.delete(shardTopic);
|
|
1089
|
+
if (configured ||
|
|
1090
|
+
this.topicRootControlPlane.getTopicRoot(shardTopic) ||
|
|
1091
|
+
!this.autoTopicRootCandidates ||
|
|
1092
|
+
this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured())
|
|
1093
|
+
return true;
|
|
1094
|
+
let changed = false;
|
|
1095
|
+
let unknown = false;
|
|
1096
|
+
for (const [origin, claim] of claims) {
|
|
1097
|
+
if (this.peers.has(origin) ||
|
|
1098
|
+
this.isDepartedTopicRootCandidateClaimSuppressed(origin, claim))
|
|
1099
|
+
continue;
|
|
1100
|
+
const observed = outcomes.get(origin);
|
|
1101
|
+
if (this.signedTopicRootCandidateClaims.get(origin) !== claim ||
|
|
1102
|
+
observed?.claim !== claim) {
|
|
1103
|
+
unknown = true;
|
|
1104
|
+
continue;
|
|
1105
|
+
}
|
|
1106
|
+
const freshRoute = this.routes.isReachable(this.publicKeyHash, origin) &&
|
|
1107
|
+
this.topicRootRouteObservations(origin).some((route) => !observed.routes.includes(route));
|
|
1108
|
+
if (freshRoute || observed.status === "alive")
|
|
1109
|
+
continue;
|
|
1110
|
+
if (observed.status === "unknown")
|
|
1111
|
+
unknown = true;
|
|
1112
|
+
else
|
|
1113
|
+
changed =
|
|
1114
|
+
this.suppressTopicRootCandidateClaim(origin, claim) || changed;
|
|
1115
|
+
}
|
|
1116
|
+
if (changed)
|
|
1117
|
+
this.rebuildAutoTopicRootCandidatesFromClaims(BigInt(Date.now()), {
|
|
1118
|
+
immediate: true,
|
|
1119
|
+
});
|
|
1120
|
+
if (unknown)
|
|
1121
|
+
throw new DeliveryError("Relay root reachability could not be established");
|
|
1122
|
+
return true;
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
finally {
|
|
1126
|
+
signal?.removeEventListener("abort", release);
|
|
1127
|
+
release();
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
async withRelayRootPolicyDeadline(attempt, signal, resolve) {
|
|
1131
|
+
// Ordinary initial resolution retains its existing contract. Only work
|
|
1132
|
+
// following a probe shares the one additional policy budget, including retries.
|
|
1133
|
+
if (attempt.policyDeadline === undefined)
|
|
1134
|
+
return resolve(signal);
|
|
1135
|
+
throwIfAborted(signal);
|
|
1136
|
+
const remaining = attempt.policyDeadline - performance.now();
|
|
1137
|
+
const deadline = new AbortError("relay root policy recheck deadline reached");
|
|
1138
|
+
if (remaining <= 0)
|
|
1139
|
+
throw deadline;
|
|
1140
|
+
const controller = new AbortController();
|
|
1141
|
+
const timer = setTimeout(() => controller.abort(deadline), remaining);
|
|
1142
|
+
timer.unref?.();
|
|
1143
|
+
const signals = linkAbortSignals([signal, controller.signal]);
|
|
1144
|
+
try {
|
|
1145
|
+
return await withAbort(resolve(signals.signal), signals.signal);
|
|
1146
|
+
}
|
|
1147
|
+
finally {
|
|
1148
|
+
clearTimeout(timer);
|
|
1149
|
+
signals.clear();
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
866
1152
|
canAdvanceTopicRootCandidateClaimReplayFloor(origin, timestamp) {
|
|
867
1153
|
const floor = this.topicRootCandidateClaimReplayFloors.get(origin);
|
|
868
1154
|
if (floor !== undefined) {
|
|
@@ -923,7 +1209,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
923
1209
|
this.topicRootCandidateClaimGlobalVerifyBudget.remaining -= 1;
|
|
924
1210
|
return true;
|
|
925
1211
|
}
|
|
926
|
-
async importSignedTopicRootCandidateClaim(rawClaim, outerPeerHash, expectedPeer) {
|
|
1212
|
+
async importSignedTopicRootCandidateClaim(rawClaim, outerPeerHash, expectedPeer, batchUpdate) {
|
|
927
1213
|
const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
|
|
928
1214
|
if (rawClaim.byteLength === 0 ||
|
|
929
1215
|
rawClaim.byteLength > TOPIC_ROOT_CANDIDATE_CLAIM_MAX_BYTES) {
|
|
@@ -962,7 +1248,13 @@ export class TopicControlPlane extends DirectStream {
|
|
|
962
1248
|
if (acceptanceDeadline === undefined) {
|
|
963
1249
|
return false;
|
|
964
1250
|
}
|
|
965
|
-
|
|
1251
|
+
if (batchUpdate) {
|
|
1252
|
+
if (this.pruneExpiredTopicRootCandidateClaims(now))
|
|
1253
|
+
batchUpdate.immediate = true;
|
|
1254
|
+
}
|
|
1255
|
+
else {
|
|
1256
|
+
this.rebuildAutoTopicRootCandidatesFromClaims(now);
|
|
1257
|
+
}
|
|
966
1258
|
const retained = this.signedTopicRootCandidateClaims.get(origin);
|
|
967
1259
|
if (outerPeerHash === origin &&
|
|
968
1260
|
expectedPeer !== undefined &&
|
|
@@ -980,7 +1272,10 @@ export class TopicControlPlane extends DirectStream {
|
|
|
980
1272
|
// monotonic acceptance deadline. A current authenticated origin stream is
|
|
981
1273
|
// sufficient liveness evidence; do not advance the floor or lease.
|
|
982
1274
|
this.suppressedDepartedTopicRootCandidateClaims.delete(origin);
|
|
983
|
-
|
|
1275
|
+
if (batchUpdate)
|
|
1276
|
+
batchUpdate.immediate = true;
|
|
1277
|
+
else
|
|
1278
|
+
this.rebuildAutoTopicRootCandidatesFromClaims(now, { immediate: true });
|
|
984
1279
|
return false;
|
|
985
1280
|
}
|
|
986
1281
|
if (!this.canAdvanceTopicRootCandidateClaimReplayFloor(origin, timestamp)) {
|
|
@@ -1224,6 +1519,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1224
1519
|
async reconcileShardOverlays() {
|
|
1225
1520
|
if (!this.started || this.topicControlPlaneStopping)
|
|
1226
1521
|
return;
|
|
1522
|
+
const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
|
|
1227
1523
|
const byShard = new Map();
|
|
1228
1524
|
for (const topic of this.subscriptions.keys()) {
|
|
1229
1525
|
const shardTopic = this.getShardTopicForUserTopic(topic);
|
|
@@ -1231,25 +1527,19 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1231
1527
|
}
|
|
1232
1528
|
// Ensure shard overlays are joined using the current root mapping (may
|
|
1233
1529
|
// migrate channels if roots changed), then re-announce subscriptions.
|
|
1234
|
-
await Promise.
|
|
1530
|
+
const results = await Promise.allSettled([...byShard.entries()].map(async ([shardTopic, userTopics]) => {
|
|
1235
1531
|
if (userTopics.length === 0)
|
|
1236
1532
|
return;
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
});
|
|
1242
|
-
const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
|
|
1243
|
-
mode: new AnyWhere(),
|
|
1244
|
-
priority: 1,
|
|
1245
|
-
skipRecipientValidation: true,
|
|
1533
|
+
const owners = userTopics.map((topic) => [topic, this.subscriptions.get(topic)]);
|
|
1534
|
+
await this.ensureFanoutChannel(shardTopic, {
|
|
1535
|
+
ephemeral: false,
|
|
1536
|
+
isCurrent: () => owners.some(([topic, owner]) => owner && this.subscriptions.get(topic) === owner),
|
|
1246
1537
|
});
|
|
1247
|
-
|
|
1248
|
-
if (!st)
|
|
1249
|
-
return;
|
|
1250
|
-
await st.channel.publish(toUint8Array(embedded.bytes()));
|
|
1251
|
-
this.touchFanoutChannel(shardTopic);
|
|
1538
|
+
await this.announceShardSubscriptions(shardTopic, lifecycleRevision);
|
|
1252
1539
|
}));
|
|
1540
|
+
const failure = results.find((result) => result.status === "rejected");
|
|
1541
|
+
if (failure?.status === "rejected")
|
|
1542
|
+
throw failure.reason;
|
|
1253
1543
|
}
|
|
1254
1544
|
isTrackedTopic(topic) {
|
|
1255
1545
|
return this.topics.has(topic);
|
|
@@ -1260,16 +1550,17 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1260
1550
|
untrackTopic(topic) {
|
|
1261
1551
|
const peers = this.topics.get(topic);
|
|
1262
1552
|
this.topics.delete(topic);
|
|
1263
|
-
|
|
1264
|
-
return;
|
|
1265
|
-
for (const peerHash of peers.keys()) {
|
|
1553
|
+
for (const peerHash of peers?.keys() ?? []) {
|
|
1266
1554
|
this.peerToTopic.get(peerHash)?.delete(topic);
|
|
1267
|
-
this.lastSubscriptionMessages.get(peerHash)?.delete(topic);
|
|
1268
1555
|
if (!this.peerToTopic.get(peerHash)?.size) {
|
|
1269
1556
|
this.peerToTopic.delete(peerHash);
|
|
1270
|
-
this.lastSubscriptionMessages.delete(peerHash);
|
|
1271
1557
|
}
|
|
1272
1558
|
}
|
|
1559
|
+
for (const [peerHash, messages] of this.lastSubscriptionMessages) {
|
|
1560
|
+
messages.delete(topic);
|
|
1561
|
+
if (messages.size === 0)
|
|
1562
|
+
this.lastSubscriptionMessages.delete(peerHash);
|
|
1563
|
+
}
|
|
1273
1564
|
}
|
|
1274
1565
|
initializePeer(publicKey) {
|
|
1275
1566
|
// Remote subscribers can be non-neighbours (learned via relayed topic control).
|
|
@@ -1292,10 +1583,26 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1292
1583
|
this.lastSubscriptionMessages.get(oldest)?.delete(topic);
|
|
1293
1584
|
if (!this.peerToTopic.get(oldest)?.size) {
|
|
1294
1585
|
this.peerToTopic.delete(oldest);
|
|
1586
|
+
}
|
|
1587
|
+
if (!this.lastSubscriptionMessages.get(oldest)?.size) {
|
|
1295
1588
|
this.lastSubscriptionMessages.delete(oldest);
|
|
1296
1589
|
}
|
|
1297
1590
|
}
|
|
1298
1591
|
}
|
|
1592
|
+
pruneUnsubscribedWatermarks(topic) {
|
|
1593
|
+
let remaining = this.subscriberCacheMaxEntries;
|
|
1594
|
+
for (const [peerHash, messages] of [
|
|
1595
|
+
...this.lastSubscriptionMessages,
|
|
1596
|
+
].reverse()) {
|
|
1597
|
+
if (!messages.has(topic) || this.topics.get(topic)?.has(peerHash))
|
|
1598
|
+
continue;
|
|
1599
|
+
if (remaining-- > 0)
|
|
1600
|
+
continue;
|
|
1601
|
+
messages.delete(topic);
|
|
1602
|
+
if (messages.size === 0)
|
|
1603
|
+
this.lastSubscriptionMessages.delete(peerHash);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1299
1606
|
getSubscriptionOverlap(topics) {
|
|
1300
1607
|
const subscriptions = [];
|
|
1301
1608
|
if (topics) {
|
|
@@ -1327,24 +1634,35 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1327
1634
|
}
|
|
1328
1635
|
scheduleFanoutIdleClose(topic) {
|
|
1329
1636
|
const st = this.fanoutChannels.get(topic);
|
|
1330
|
-
|
|
1637
|
+
const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
|
|
1638
|
+
if (!st ||
|
|
1639
|
+
!st.ephemeral ||
|
|
1640
|
+
!this.isTopicControlPlaneActive(lifecycleRevision))
|
|
1331
1641
|
return;
|
|
1332
1642
|
this.clearFanoutIdleClose(st);
|
|
1333
1643
|
if (this.fanoutPublishIdleCloseMs <= 0)
|
|
1334
1644
|
return;
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1645
|
+
const timer = setTimeout(() => {
|
|
1646
|
+
// Cancellation can race a queued callback. It must still own this exact
|
|
1647
|
+
// timer, channel state and lifecycle before acting on the topic again.
|
|
1648
|
+
if (!this.isTopicControlPlaneActive(lifecycleRevision) ||
|
|
1649
|
+
this.fanoutChannels.get(topic) !== st ||
|
|
1650
|
+
st.idleCloseTimeout !== timer ||
|
|
1651
|
+
!st.ephemeral)
|
|
1338
1652
|
return;
|
|
1339
|
-
|
|
1653
|
+
st.idleCloseTimeout = undefined;
|
|
1654
|
+
const idleMs = Date.now() - st.lastUsedAt;
|
|
1340
1655
|
if (idleMs >= this.fanoutPublishIdleCloseMs) {
|
|
1341
1656
|
void this.closeFanoutChannel(topic);
|
|
1342
1657
|
return;
|
|
1343
1658
|
}
|
|
1344
1659
|
this.scheduleFanoutIdleClose(topic);
|
|
1345
1660
|
}, this.fanoutPublishIdleCloseMs);
|
|
1661
|
+
st.idleCloseTimeout = timer;
|
|
1346
1662
|
}
|
|
1347
|
-
touchFanoutChannel(topic) {
|
|
1663
|
+
touchFanoutChannel(topic, channel, lifecycleRevision) {
|
|
1664
|
+
if (!this.isFanoutChannelCurrent(topic, channel, lifecycleRevision))
|
|
1665
|
+
return;
|
|
1348
1666
|
const st = this.fanoutChannels.get(topic);
|
|
1349
1667
|
if (!st)
|
|
1350
1668
|
return;
|
|
@@ -1555,20 +1873,27 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1555
1873
|
topic.startsWith(this.shardTopicPrefix) &&
|
|
1556
1874
|
deterministic &&
|
|
1557
1875
|
root !== deterministic) {
|
|
1558
|
-
return { root: deterministic, authoritative: false };
|
|
1876
|
+
return { root: deterministic, authoritative: false, autoCandidate: true };
|
|
1559
1877
|
}
|
|
1560
|
-
return {
|
|
1878
|
+
return {
|
|
1879
|
+
root,
|
|
1880
|
+
authoritative: true,
|
|
1881
|
+
autoCandidate: this.autoTopicRootCandidates &&
|
|
1882
|
+
topic.startsWith(this.shardTopicPrefix) &&
|
|
1883
|
+
root === deterministic,
|
|
1884
|
+
};
|
|
1561
1885
|
}
|
|
1562
1886
|
async resolveTopicRootState(topic, options) {
|
|
1563
1887
|
throwIfAborted(options?.signal);
|
|
1564
1888
|
const tracked = await this.topicRootControlPlane.resolveTrackedTopicRoot(topic, options);
|
|
1565
1889
|
if (tracked) {
|
|
1566
|
-
return { root: tracked, authoritative: true };
|
|
1890
|
+
return { root: tracked, authoritative: true, autoCandidate: false };
|
|
1567
1891
|
}
|
|
1568
1892
|
if (options?.queryConnectedPeers === false) {
|
|
1569
1893
|
return {
|
|
1570
1894
|
root: this.topicRootControlPlane.resolveDeterministicTopicRoot(topic),
|
|
1571
1895
|
authoritative: false,
|
|
1896
|
+
autoCandidate: this.autoTopicRootCandidates,
|
|
1572
1897
|
};
|
|
1573
1898
|
}
|
|
1574
1899
|
const deadlineController = new AbortController();
|
|
@@ -1630,6 +1955,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1630
1955
|
return {
|
|
1631
1956
|
root: this.topicRootControlPlane.resolveDeterministicTopicRoot(topic),
|
|
1632
1957
|
authoritative: false,
|
|
1958
|
+
autoCandidate: this.autoTopicRootCandidates,
|
|
1633
1959
|
};
|
|
1634
1960
|
}
|
|
1635
1961
|
async resolveTopicRoot(topic, options) {
|
|
@@ -1658,7 +1984,11 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1658
1984
|
? undefined
|
|
1659
1985
|
: this.shardRootCache.get(shardTopic);
|
|
1660
1986
|
if (cached && (cached.authoritative || !hasConnectedTrackers)) {
|
|
1661
|
-
return {
|
|
1987
|
+
return {
|
|
1988
|
+
root: cached.root,
|
|
1989
|
+
candidateGeneration,
|
|
1990
|
+
autoCandidate: cached.autoCandidate,
|
|
1991
|
+
};
|
|
1662
1992
|
}
|
|
1663
1993
|
const resolved = await this.resolveTopicRootState(shardTopic, options);
|
|
1664
1994
|
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
@@ -1674,7 +2004,11 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1674
2004
|
else {
|
|
1675
2005
|
this.shardRootCache.delete(shardTopic);
|
|
1676
2006
|
}
|
|
1677
|
-
return {
|
|
2007
|
+
return {
|
|
2008
|
+
root: resolved.root,
|
|
2009
|
+
candidateGeneration,
|
|
2010
|
+
autoCandidate: resolved.autoCandidate,
|
|
2011
|
+
};
|
|
1678
2012
|
}
|
|
1679
2013
|
}
|
|
1680
2014
|
getConnectedTopicRootTrackers() {
|
|
@@ -1698,6 +2032,11 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1698
2032
|
} while (true);
|
|
1699
2033
|
}
|
|
1700
2034
|
async sendDirectControlMessage(peer, pubsubMessage, signal) {
|
|
2035
|
+
const revision = this.topicControlPlaneLifecycleRevision;
|
|
2036
|
+
const isCurrent = () => this.isTopicControlPlaneActive(revision) &&
|
|
2037
|
+
this.peers.get(peer.publicKey.hashcode()) === peer;
|
|
2038
|
+
if (!isCurrent())
|
|
2039
|
+
return;
|
|
1701
2040
|
const embedded = await this.createMessage(this.encodePubSubMessage(pubsubMessage), {
|
|
1702
2041
|
mode: new SilentDelivery({
|
|
1703
2042
|
to: [peer.publicKey.hashcode()],
|
|
@@ -1706,7 +2045,9 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1706
2045
|
priority: 1,
|
|
1707
2046
|
skipRecipientValidation: true,
|
|
1708
2047
|
});
|
|
1709
|
-
|
|
2048
|
+
if (!isCurrent())
|
|
2049
|
+
return;
|
|
2050
|
+
await this.publishMessage(this.publicKey, embedded, [peer], undefined, signal ?? this.topicRootResolutionAbortController.signal);
|
|
1710
2051
|
}
|
|
1711
2052
|
resolvePendingTopicRootQuery(message, responderPeerHash) {
|
|
1712
2053
|
const pending = this.pendingTopicRootQueries.get(message.requestId);
|
|
@@ -1812,20 +2153,43 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1812
2153
|
this.shardRootCache.set(shardTopic, {
|
|
1813
2154
|
root: resolved.root,
|
|
1814
2155
|
authoritative: true,
|
|
2156
|
+
autoCandidate: resolved.autoCandidate,
|
|
1815
2157
|
});
|
|
1816
2158
|
}
|
|
1817
2159
|
return resolved.root;
|
|
1818
2160
|
}
|
|
1819
|
-
async resolveQueryableTopicRoot(topic) {
|
|
2161
|
+
async resolveQueryableTopicRoot(topic, isCurrent = () => true) {
|
|
1820
2162
|
const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
|
|
1821
2163
|
const lifecycleSignal = this.topicRootResolutionAbortController.signal;
|
|
2164
|
+
const recovery = {};
|
|
1822
2165
|
return this.withTopicRootCandidateResolution(async ({ candidateGeneration, signal }) => {
|
|
1823
2166
|
throwIfAborted(signal);
|
|
1824
2167
|
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
1825
|
-
|
|
1826
|
-
|
|
2168
|
+
if (!isCurrent())
|
|
2169
|
+
throw new AbortError("obsolete topic root query");
|
|
2170
|
+
// Incoming queries retain canonical directory semantics: never ask
|
|
2171
|
+
// trackers here, since a tracker may itself query this peer.
|
|
2172
|
+
const resolveCanonical = () => this.withRelayRootPolicyDeadline(recovery, signal, async (policySignal) => {
|
|
2173
|
+
const local = await this.topicRootControlPlane.resolveLocalTopicRoot(topic, {
|
|
2174
|
+
signal: policySignal,
|
|
2175
|
+
});
|
|
2176
|
+
return {
|
|
2177
|
+
root: local ??
|
|
2178
|
+
this.topicRootControlPlane.resolveDeterministicTopicRoot(topic),
|
|
2179
|
+
autoCandidate: !local && this.autoTopicRootCandidates,
|
|
2180
|
+
};
|
|
1827
2181
|
});
|
|
2182
|
+
let resolved = await resolveCanonical();
|
|
2183
|
+
if (resolved.autoCandidate && topic.startsWith(this.shardTopicPrefix)) {
|
|
2184
|
+
if (await this.preflightRelayRootCandidates(topic, recovery, signal, isCurrent, true)) {
|
|
2185
|
+
throwIfAborted(signal);
|
|
2186
|
+
resolved = await resolveCanonical();
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
const root = resolved.root;
|
|
1828
2190
|
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
2191
|
+
if (!isCurrent())
|
|
2192
|
+
throw new AbortError("obsolete topic root query");
|
|
1829
2193
|
if (root !== this.publicKeyHash) {
|
|
1830
2194
|
return root;
|
|
1831
2195
|
}
|
|
@@ -1925,8 +2289,11 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1925
2289
|
async ensureFanoutChannel(shardTopic, options) {
|
|
1926
2290
|
const topic = shardTopic.toString();
|
|
1927
2291
|
const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
|
|
2292
|
+
const recovery = {};
|
|
1928
2293
|
for (;;) {
|
|
1929
2294
|
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
2295
|
+
if (options?.isCurrent?.() === false)
|
|
2296
|
+
throw new AbortError("obsolete fanout channel owner");
|
|
1930
2297
|
if (options?.signal?.aborted) {
|
|
1931
2298
|
throw (options.signal.reason ??
|
|
1932
2299
|
new AbortError("fanout channel opening aborted"));
|
|
@@ -1934,7 +2301,8 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1934
2301
|
const candidateGeneration = this.getTopicRootCandidateGeneration();
|
|
1935
2302
|
const active = this.ensureFanoutChannelInFlight.get(topic);
|
|
1936
2303
|
if (active?.candidateGeneration === candidateGeneration &&
|
|
1937
|
-
active.lifecycleRevision === lifecycleRevision
|
|
2304
|
+
active.lifecycleRevision === lifecycleRevision &&
|
|
2305
|
+
active.isCurrent()) {
|
|
1938
2306
|
try {
|
|
1939
2307
|
await withAbort(active.opening, options?.signal);
|
|
1940
2308
|
}
|
|
@@ -1942,6 +2310,10 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1942
2310
|
if (options?.signal?.aborted)
|
|
1943
2311
|
throw error;
|
|
1944
2312
|
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
2313
|
+
if (candidateGeneration === this.getTopicRootCandidateGeneration() &&
|
|
2314
|
+
!active.abortController.signal.aborted &&
|
|
2315
|
+
active.isCurrent())
|
|
2316
|
+
throw error;
|
|
1945
2317
|
}
|
|
1946
2318
|
await withAbort(active.settled, options?.signal);
|
|
1947
2319
|
if (this.ensureFanoutChannelInFlight.get(topic) === active) {
|
|
@@ -1970,13 +2342,19 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1970
2342
|
once: true,
|
|
1971
2343
|
});
|
|
1972
2344
|
}
|
|
1973
|
-
|
|
2345
|
+
// Publish the opening identity before even a synchronously resolved root
|
|
2346
|
+
// can enter its guarded recovery/creation continuation.
|
|
2347
|
+
const opening = Promise.resolve().then(() => this.ensureFanoutChannelOnce(topic, { ...options, signal: abortController.signal }, lifecycleRevision, candidateGeneration, recovery, () => this.ensureFanoutChannelInFlight.get(topic)?.abortController ===
|
|
2348
|
+
abortController &&
|
|
2349
|
+
!abortController.signal.aborted &&
|
|
2350
|
+
options?.isCurrent?.() !== false));
|
|
1974
2351
|
let resolveSettled;
|
|
1975
2352
|
const settled = new Promise((resolve) => {
|
|
1976
2353
|
resolveSettled = resolve;
|
|
1977
2354
|
});
|
|
1978
2355
|
const inFlight = {
|
|
1979
2356
|
abortController,
|
|
2357
|
+
isCurrent: () => options?.isCurrent?.() !== false,
|
|
1980
2358
|
candidateGeneration,
|
|
1981
2359
|
lifecycleRevision,
|
|
1982
2360
|
opening,
|
|
@@ -2019,7 +2397,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
2019
2397
|
}
|
|
2020
2398
|
}
|
|
2021
2399
|
}
|
|
2022
|
-
async ensureFanoutChannelOnce(shardTopic, options, lifecycleRevision, candidateGeneration) {
|
|
2400
|
+
async ensureFanoutChannelOnce(shardTopic, options, lifecycleRevision, candidateGeneration, recovery = {}, isOpeningCurrent = () => true) {
|
|
2023
2401
|
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
2024
2402
|
if (candidateGeneration !== this.getTopicRootCandidateGeneration())
|
|
2025
2403
|
return;
|
|
@@ -2038,15 +2416,16 @@ export class TopicControlPlane extends DirectStream {
|
|
|
2038
2416
|
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
2039
2417
|
let resolvedGeneration = candidateGeneration;
|
|
2040
2418
|
let root = suppliedRoot;
|
|
2419
|
+
let autoCandidate = false;
|
|
2420
|
+
const resolveRoot = () => this.withRelayRootPolicyDeadline(recovery, options?.signal, (signal) => this.resolveShardRootState(t, { signal }));
|
|
2041
2421
|
const existing = this.fanoutChannels.get(t);
|
|
2042
2422
|
if (existing) {
|
|
2043
2423
|
if (!root) {
|
|
2044
|
-
const resolved = await
|
|
2045
|
-
signal: options?.signal,
|
|
2046
|
-
});
|
|
2424
|
+
const resolved = await resolveRoot();
|
|
2047
2425
|
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
2048
2426
|
root = resolved.root;
|
|
2049
2427
|
resolvedGeneration = resolved.candidateGeneration;
|
|
2428
|
+
autoCandidate = resolved.autoCandidate;
|
|
2050
2429
|
}
|
|
2051
2430
|
if (resolvedGeneration !== candidateGeneration ||
|
|
2052
2431
|
candidateGeneration !== this.getTopicRootCandidateGeneration()) {
|
|
@@ -2080,15 +2459,30 @@ export class TopicControlPlane extends DirectStream {
|
|
|
2080
2459
|
}
|
|
2081
2460
|
}
|
|
2082
2461
|
if (!root) {
|
|
2083
|
-
const resolved = await
|
|
2084
|
-
signal: options?.signal,
|
|
2085
|
-
});
|
|
2462
|
+
const resolved = await resolveRoot();
|
|
2086
2463
|
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
2087
2464
|
root = resolved.root;
|
|
2088
2465
|
resolvedGeneration = resolved.candidateGeneration;
|
|
2466
|
+
autoCandidate = resolved.autoCandidate;
|
|
2467
|
+
}
|
|
2468
|
+
if (autoCandidate &&
|
|
2469
|
+
(await this.preflightRelayRootCandidates(t, recovery, options?.signal, isOpeningCurrent))) {
|
|
2470
|
+
throwIfAborted(options?.signal);
|
|
2471
|
+
if (candidateGeneration !== this.getTopicRootCandidateGeneration())
|
|
2472
|
+
return;
|
|
2473
|
+
const resolved = await resolveRoot();
|
|
2474
|
+
root = resolved.root;
|
|
2475
|
+
resolvedGeneration = resolved.candidateGeneration;
|
|
2476
|
+
autoCandidate = resolved.autoCandidate;
|
|
2477
|
+
}
|
|
2478
|
+
if (suppliedRoot !== undefined ||
|
|
2479
|
+
!this.autoTopicRootCandidates ||
|
|
2480
|
+
autoCandidate) {
|
|
2481
|
+
root = await this.confirmDirectShardRoot(t, root, resolvedGeneration, lifecycleRevision, options?.signal);
|
|
2089
2482
|
}
|
|
2090
|
-
root = await this.confirmDirectShardRoot(t, root, resolvedGeneration, lifecycleRevision, options?.signal);
|
|
2091
2483
|
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
2484
|
+
if (!isOpeningCurrent())
|
|
2485
|
+
throw new AbortError("obsolete fanout channel owner");
|
|
2092
2486
|
if (resolvedGeneration !== candidateGeneration ||
|
|
2093
2487
|
candidateGeneration !== this.getTopicRootCandidateGeneration()) {
|
|
2094
2488
|
return;
|
|
@@ -2272,7 +2666,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
2272
2666
|
join
|
|
2273
2667
|
.then(() => {
|
|
2274
2668
|
const st = this.fanoutChannels.get(t);
|
|
2275
|
-
if (st === channelState && st.ephemeral) {
|
|
2669
|
+
if (st === channelState && st.ephemeral && isCurrent()) {
|
|
2276
2670
|
this.scheduleFanoutIdleClose(t);
|
|
2277
2671
|
}
|
|
2278
2672
|
})
|
|
@@ -2409,6 +2803,78 @@ export class TopicControlPlane extends DirectStream {
|
|
|
2409
2803
|
this.initializeTopic(topic);
|
|
2410
2804
|
return this.debounceSubscribeAggregator.add({ key: topic });
|
|
2411
2805
|
}
|
|
2806
|
+
async announceShardSubscriptions(shardTopic, lifecycleRevision) {
|
|
2807
|
+
const st = this.fanoutChannels.get(shardTopic);
|
|
2808
|
+
if (!st)
|
|
2809
|
+
return;
|
|
2810
|
+
let topics = [...this.subscriptions.entries()].filter(([topic]) => this.getShardTopicForUserTopic(topic) === shardTopic);
|
|
2811
|
+
while (topics.length > 0 &&
|
|
2812
|
+
this.isFanoutChannelCurrent(shardTopic, st.channel, lifecycleRevision)) {
|
|
2813
|
+
const embedded = await this.createMessage(this.encodePubSubMessage(new Subscribe({
|
|
2814
|
+
topics: topics.map(([topic]) => topic),
|
|
2815
|
+
requestSubscribers: true,
|
|
2816
|
+
})), {
|
|
2817
|
+
mode: new AnyWhere(),
|
|
2818
|
+
priority: 1,
|
|
2819
|
+
skipRecipientValidation: true,
|
|
2820
|
+
});
|
|
2821
|
+
if (!this.isFanoutChannelCurrent(shardTopic, st.channel, lifecycleRevision))
|
|
2822
|
+
return;
|
|
2823
|
+
const current = topics.filter(([topic, sub]) => this.subscriptions.get(topic) === sub);
|
|
2824
|
+
if (current.length !== topics.length) {
|
|
2825
|
+
// Never announce a subscription removed while signing. This retry
|
|
2826
|
+
// only shrinks the original batch, so churn cannot extend it forever.
|
|
2827
|
+
topics = current;
|
|
2828
|
+
continue;
|
|
2829
|
+
}
|
|
2830
|
+
await st.channel.publish(toUint8Array(embedded.bytes()));
|
|
2831
|
+
if (this.isFanoutChannelCurrent(shardTopic, st.channel, lifecycleRevision)) {
|
|
2832
|
+
this.touchFanoutChannel(shardTopic, st.channel, lifecycleRevision);
|
|
2833
|
+
}
|
|
2834
|
+
return;
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
async announceDirectSubscriptions(topics, peers = [...this.peers.values()], subscribed = true) {
|
|
2838
|
+
const revision = this.topicControlPlaneLifecycleRevision;
|
|
2839
|
+
const signal = this.topicRootResolutionAbortController.signal;
|
|
2840
|
+
const subscriptions = new Map(topics.map((topic) => [topic, this.subscriptions.get(topic)]));
|
|
2841
|
+
const isCurrentTopic = (topic) => subscribed
|
|
2842
|
+
? subscriptions.get(topic) != null &&
|
|
2843
|
+
this.subscriptions.get(topic) === subscriptions.get(topic)
|
|
2844
|
+
: !this.subscriptions.has(topic);
|
|
2845
|
+
await Promise.all(peers
|
|
2846
|
+
.map(async (peer) => {
|
|
2847
|
+
const isCurrent = () => this.isTopicControlPlaneActive(revision) &&
|
|
2848
|
+
this.peers.get(peer.publicKey.hashcode()) === peer &&
|
|
2849
|
+
peer.isWritable;
|
|
2850
|
+
let currentTopics = topics.filter(isCurrentTopic);
|
|
2851
|
+
while (isCurrent() && currentTopics.length > 0) {
|
|
2852
|
+
const embedded = await this.createMessage(this.encodePubSubMessage(subscribed
|
|
2853
|
+
? new Subscribe({
|
|
2854
|
+
topics: currentTopics,
|
|
2855
|
+
requestSubscribers: true,
|
|
2856
|
+
})
|
|
2857
|
+
: new Unsubscribe({ topics: currentTopics })), {
|
|
2858
|
+
mode: new SilentDelivery({
|
|
2859
|
+
to: [peer.publicKey.hashcode()],
|
|
2860
|
+
redundancy: 1,
|
|
2861
|
+
}),
|
|
2862
|
+
priority: 1,
|
|
2863
|
+
skipRecipientValidation: true,
|
|
2864
|
+
});
|
|
2865
|
+
if (!isCurrent())
|
|
2866
|
+
return;
|
|
2867
|
+
const remaining = currentTopics.filter(isCurrentTopic);
|
|
2868
|
+
if (remaining.length !== currentTopics.length) {
|
|
2869
|
+
currentTopics = remaining;
|
|
2870
|
+
continue;
|
|
2871
|
+
}
|
|
2872
|
+
await this.publishMessage(this.publicKey, embedded, [peer], undefined, signal);
|
|
2873
|
+
return;
|
|
2874
|
+
}
|
|
2875
|
+
})
|
|
2876
|
+
.map((task) => task.catch(logErrorIfStarted)));
|
|
2877
|
+
}
|
|
2412
2878
|
async _subscribe(topics, lifecycleRevision = this.topicControlPlaneLifecycleRevision) {
|
|
2413
2879
|
if (!this.started)
|
|
2414
2880
|
throw new NotStartedError();
|
|
@@ -2417,7 +2883,6 @@ export class TopicControlPlane extends DirectStream {
|
|
|
2417
2883
|
if (topics.length === 0)
|
|
2418
2884
|
return;
|
|
2419
2885
|
const byShard = new Map();
|
|
2420
|
-
const joins = [];
|
|
2421
2886
|
for (const { key: topic, counter } of topics) {
|
|
2422
2887
|
let prev = this.subscriptions.get(topic);
|
|
2423
2888
|
if (prev) {
|
|
@@ -2431,33 +2896,25 @@ export class TopicControlPlane extends DirectStream {
|
|
|
2431
2896
|
const shardTopic = this.getShardTopicForUserTopic(topic);
|
|
2432
2897
|
byShard.set(shardTopic, [...(byShard.get(shardTopic) ?? []), topic]);
|
|
2433
2898
|
this.shardRefCounts.set(shardTopic, (this.shardRefCounts.get(shardTopic) ?? 0) + 1);
|
|
2434
|
-
joins.push(this.ensureFanoutChannel(shardTopic));
|
|
2435
2899
|
}
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
throw new Error(`Fanout channel missing for shard: ${shardTopic}`);
|
|
2446
|
-
const msg = new Subscribe({
|
|
2447
|
-
topics: userTopics,
|
|
2448
|
-
requestSubscribers: true,
|
|
2449
|
-
});
|
|
2450
|
-
const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
|
|
2451
|
-
mode: new AnyWhere(),
|
|
2452
|
-
priority: 1,
|
|
2453
|
-
skipRecipientValidation: true,
|
|
2900
|
+
// Neighbours can use different root policies. Discover them independently
|
|
2901
|
+
// of shard joins, without adopting or overriding either peer's root policy.
|
|
2902
|
+
void this.announceDirectSubscriptions([...byShard.values()].flat()).catch(logErrorIfStarted);
|
|
2903
|
+
const results = await Promise.allSettled([...byShard.keys()].map(async (shardTopic) => {
|
|
2904
|
+
const owners = byShard
|
|
2905
|
+
.get(shardTopic)
|
|
2906
|
+
.map((topic) => [topic, this.subscriptions.get(topic)]);
|
|
2907
|
+
await this.ensureFanoutChannel(shardTopic, {
|
|
2908
|
+
isCurrent: () => owners.some(([topic, owner]) => owner && this.subscriptions.get(topic) === owner),
|
|
2454
2909
|
});
|
|
2455
|
-
|
|
2456
|
-
return;
|
|
2457
|
-
await st.channel.publish(toUint8Array(embedded.bytes()));
|
|
2458
|
-
if (this.isFanoutChannelCurrent(shardTopic, st.channel, lifecycleRevision))
|
|
2459
|
-
this.touchFanoutChannel(shardTopic);
|
|
2910
|
+
await this.announceShardSubscriptions(shardTopic, lifecycleRevision);
|
|
2460
2911
|
}));
|
|
2912
|
+
const failure = results.find((result) => result.status === "rejected");
|
|
2913
|
+
if (failure?.status === "rejected") {
|
|
2914
|
+
if (this.isTopicControlPlaneActive(lifecycleRevision))
|
|
2915
|
+
this.scheduleReconcileShardOverlays();
|
|
2916
|
+
throw failure.reason;
|
|
2917
|
+
}
|
|
2461
2918
|
}
|
|
2462
2919
|
async unsubscribe(topic, options) {
|
|
2463
2920
|
this.pendingSubscriptions.delete(topic);
|
|
@@ -2489,6 +2946,10 @@ export class TopicControlPlane extends DirectStream {
|
|
|
2489
2946
|
const next = (this.shardRefCounts.get(shardTopic) ?? 0) - 1;
|
|
2490
2947
|
if (next <= 0) {
|
|
2491
2948
|
this.shardRefCounts.delete(shardTopic);
|
|
2949
|
+
const opening = this.ensureFanoutChannelInFlight.get(shardTopic);
|
|
2950
|
+
if (opening && !opening.isCurrent()) {
|
|
2951
|
+
opening.abortController.abort(new AbortError("last shard subscription removed"));
|
|
2952
|
+
}
|
|
2492
2953
|
}
|
|
2493
2954
|
else {
|
|
2494
2955
|
this.shardRefCounts.set(shardTopic, next);
|
|
@@ -2506,12 +2967,14 @@ export class TopicControlPlane extends DirectStream {
|
|
|
2506
2967
|
return;
|
|
2507
2968
|
const byShard = new Map();
|
|
2508
2969
|
for (const { key: topic } of topics) {
|
|
2509
|
-
//
|
|
2970
|
+
// A pending reopen must not erase the previous departure: its Subscribe
|
|
2971
|
+
// shares the transport session and would otherwise only refresh the cache.
|
|
2510
2972
|
if (this.subscriptions.has(topic))
|
|
2511
2973
|
continue;
|
|
2512
2974
|
const shardTopic = this.getShardTopicForUserTopic(topic);
|
|
2513
2975
|
byShard.set(shardTopic, [...(byShard.get(shardTopic) ?? []), topic]);
|
|
2514
2976
|
}
|
|
2977
|
+
void this.announceDirectSubscriptions([...byShard.values()].flat(), undefined, false).catch(logErrorIfStarted);
|
|
2515
2978
|
await Promise.all([...byShard.entries()].map(async ([shardTopic, userTopics]) => {
|
|
2516
2979
|
if (userTopics.length === 0)
|
|
2517
2980
|
return;
|
|
@@ -2532,7 +2995,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
2532
2995
|
.publish(toUint8Array(embedded.bytes()))
|
|
2533
2996
|
.catch(() => { });
|
|
2534
2997
|
if (this.isFanoutChannelCurrent(shardTopic, st.channel, lifecycleRevision))
|
|
2535
|
-
this.touchFanoutChannel(shardTopic);
|
|
2998
|
+
this.touchFanoutChannel(shardTopic, st.channel, lifecycleRevision);
|
|
2536
2999
|
}
|
|
2537
3000
|
}
|
|
2538
3001
|
catch {
|
|
@@ -2554,6 +3017,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
2554
3017
|
async announcePeerUnavailable(publicKeyHash, batches) {
|
|
2555
3018
|
if (!this.started)
|
|
2556
3019
|
throw new NotStartedError();
|
|
3020
|
+
const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
|
|
2557
3021
|
const byShard = new Map();
|
|
2558
3022
|
for (const batch of batches) {
|
|
2559
3023
|
for (const topic of batch.topics) {
|
|
@@ -2596,7 +3060,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
2596
3060
|
const st = this.fanoutChannels.get(shardTopic);
|
|
2597
3061
|
if (st) {
|
|
2598
3062
|
void st.channel.publishMaybe(toUint8Array(embedded.bytes()));
|
|
2599
|
-
this.touchFanoutChannel(shardTopic);
|
|
3063
|
+
this.touchFanoutChannel(shardTopic, st.channel, lifecycleRevision);
|
|
2600
3064
|
}
|
|
2601
3065
|
}
|
|
2602
3066
|
catch {
|
|
@@ -2607,6 +3071,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
2607
3071
|
async announcePeerUnavailableOnShard(publicKeyHash, shardTopic) {
|
|
2608
3072
|
if (!this.started)
|
|
2609
3073
|
throw new NotStartedError();
|
|
3074
|
+
const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
|
|
2610
3075
|
try {
|
|
2611
3076
|
const msg = new PeerUnavailable({
|
|
2612
3077
|
publicKeyHash,
|
|
@@ -2623,7 +3088,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
2623
3088
|
const st = this.fanoutChannels.get(shardTopic);
|
|
2624
3089
|
if (st) {
|
|
2625
3090
|
void st.channel.publishMaybe(toUint8Array(embedded.bytes()));
|
|
2626
|
-
this.touchFanoutChannel(shardTopic);
|
|
3091
|
+
this.touchFanoutChannel(shardTopic, st.channel, lifecycleRevision);
|
|
2627
3092
|
}
|
|
2628
3093
|
}
|
|
2629
3094
|
catch {
|
|
@@ -2675,6 +3140,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
2675
3140
|
async requestSubscribers(topic, to) {
|
|
2676
3141
|
if (!this.started)
|
|
2677
3142
|
throw new NotStartedError();
|
|
3143
|
+
const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
|
|
2678
3144
|
if (topic == null)
|
|
2679
3145
|
throw new Error("ERR_NOT_VALID_TOPIC");
|
|
2680
3146
|
if (topic.length === 0)
|
|
@@ -2718,12 +3184,13 @@ export class TopicControlPlane extends DirectStream {
|
|
|
2718
3184
|
else {
|
|
2719
3185
|
await st.channel.publish(payload);
|
|
2720
3186
|
}
|
|
2721
|
-
this.touchFanoutChannel(shardTopic);
|
|
3187
|
+
this.touchFanoutChannel(shardTopic, st.channel, lifecycleRevision);
|
|
2722
3188
|
}));
|
|
2723
3189
|
}
|
|
2724
3190
|
async publish(data, options) {
|
|
2725
3191
|
if (!this.started)
|
|
2726
3192
|
throw new NotStartedError();
|
|
3193
|
+
const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
|
|
2727
3194
|
const topicsAll = options.topics?.map((x) => x.toString()) || [];
|
|
2728
3195
|
const hasExplicitTOs = options?.mode && deliveryModeHasReceiver(options.mode);
|
|
2729
3196
|
// Explicit recipients: use DirectStream delivery (no shard broadcast).
|
|
@@ -2802,7 +3269,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
2802
3269
|
throw new Error(`Fanout channel missing for shard: ${shardTopic}`);
|
|
2803
3270
|
}
|
|
2804
3271
|
await withAbort(st.channel.publish(payload), options?.signal);
|
|
2805
|
-
this.touchFanoutChannel(shardTopic);
|
|
3272
|
+
this.touchFanoutChannel(shardTopic, st.channel, lifecycleRevision);
|
|
2806
3273
|
}));
|
|
2807
3274
|
if (this.fanoutPublishIdleCloseMs == 0 ||
|
|
2808
3275
|
this.fanoutPublishMaxEphemeralChannels == 0) {
|
|
@@ -2866,6 +3333,16 @@ export class TopicControlPlane extends DirectStream {
|
|
|
2866
3333
|
}
|
|
2867
3334
|
onPeerUnreachable(publicKeyHash) {
|
|
2868
3335
|
super.onPeerUnreachable(publicKeyHash);
|
|
3336
|
+
const claim = this.signedTopicRootCandidateClaims.get(publicKeyHash);
|
|
3337
|
+
if (this.isTopicControlPlaneActive(this.topicControlPlaneLifecycleRevision) &&
|
|
3338
|
+
!this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured() &&
|
|
3339
|
+
claim &&
|
|
3340
|
+
!this.routes.isReachable(this.publicKeyHash, publicKeyHash) &&
|
|
3341
|
+
this.suppressTopicRootCandidateClaim(publicKeyHash, claim)) {
|
|
3342
|
+
this.rebuildAutoTopicRootCandidatesFromClaims(BigInt(Date.now()), {
|
|
3343
|
+
immediate: true,
|
|
3344
|
+
});
|
|
3345
|
+
}
|
|
2869
3346
|
const key = this.peerKeyHashToPublicKey.get(publicKeyHash);
|
|
2870
3347
|
if (!key) {
|
|
2871
3348
|
return;
|
|
@@ -3149,14 +3626,20 @@ export class TopicControlPlane extends DirectStream {
|
|
|
3149
3626
|
}
|
|
3150
3627
|
async processDirectPubSubMessage(input) {
|
|
3151
3628
|
const { pubsubMessage, message, from, stream } = input;
|
|
3152
|
-
|
|
3629
|
+
if ((pubsubMessage instanceof Subscribe ||
|
|
3630
|
+
pubsubMessage instanceof Unsubscribe) &&
|
|
3631
|
+
this.peers.get(stream.publicKey.hashcode()) !== stream)
|
|
3632
|
+
return;
|
|
3633
|
+
const isBoundDirectControl = pubsubMessage instanceof TopicRootCandidates ||
|
|
3153
3634
|
pubsubMessage instanceof TopicRootCandidateClaims ||
|
|
3154
3635
|
pubsubMessage instanceof TopicRootQuery ||
|
|
3155
|
-
pubsubMessage instanceof TopicRootQueryResponse
|
|
3156
|
-
|
|
3636
|
+
pubsubMessage instanceof TopicRootQueryResponse ||
|
|
3637
|
+
pubsubMessage instanceof Subscribe ||
|
|
3638
|
+
pubsubMessage instanceof Unsubscribe;
|
|
3639
|
+
const directRootSigner = isBoundDirectControl
|
|
3157
3640
|
? message.header.signatures?.publicKeys[0]
|
|
3158
3641
|
: undefined;
|
|
3159
|
-
if (
|
|
3642
|
+
if (isBoundDirectControl &&
|
|
3160
3643
|
(!directRootSigner || !directRootSigner.equals(stream.publicKey))) {
|
|
3161
3644
|
return;
|
|
3162
3645
|
}
|
|
@@ -3174,14 +3657,21 @@ export class TopicControlPlane extends DirectStream {
|
|
|
3174
3657
|
return;
|
|
3175
3658
|
}
|
|
3176
3659
|
const imported = [];
|
|
3660
|
+
const batchUpdate = { immediate: false };
|
|
3661
|
+
const revision = this.topicControlPlaneLifecycleRevision;
|
|
3177
3662
|
const outerPeerHash = stream.publicKey.hashcode();
|
|
3178
3663
|
for (const claim of pubsubMessage.claims) {
|
|
3179
|
-
if (await this.importSignedTopicRootCandidateClaim(claim, outerPeerHash, stream)) {
|
|
3664
|
+
if (await this.importSignedTopicRootCandidateClaim(claim, outerPeerHash, stream, batchUpdate)) {
|
|
3180
3665
|
imported.push(claim);
|
|
3181
3666
|
}
|
|
3182
3667
|
}
|
|
3668
|
+
if (!this.isTopicControlPlaneActive(revision) ||
|
|
3669
|
+
this.peers.get(outerPeerHash) !== stream)
|
|
3670
|
+
return;
|
|
3671
|
+
if (imported.length > 0 || batchUpdate.immediate) {
|
|
3672
|
+
this.rebuildAutoTopicRootCandidatesFromClaims(BigInt(Date.now()), batchUpdate);
|
|
3673
|
+
}
|
|
3183
3674
|
if (imported.length > 0) {
|
|
3184
|
-
this.rebuildAutoTopicRootCandidatesFromClaims();
|
|
3185
3675
|
// Relay the exact nested claims; no relay signature is substituted for
|
|
3186
3676
|
// their origin signatures.
|
|
3187
3677
|
void this.sendSignedTopicRootCandidateClaims(imported, [...this.peers.values()].filter((peer) => peer.publicKey.hashcode() !== outerPeerHash)).catch(() => { });
|
|
@@ -3189,7 +3679,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
3189
3679
|
return;
|
|
3190
3680
|
}
|
|
3191
3681
|
if (pubsubMessage instanceof TopicRootQuery) {
|
|
3192
|
-
const root = await this.resolveQueryableTopicRoot(pubsubMessage.topic);
|
|
3682
|
+
const root = await this.resolveQueryableTopicRoot(pubsubMessage.topic, () => this.peers.get(from.hashcode()) === stream);
|
|
3193
3683
|
await this.sendDirectControlMessage(stream, new TopicRootQueryResponse({
|
|
3194
3684
|
requestId: pubsubMessage.requestId,
|
|
3195
3685
|
topic: pubsubMessage.topic,
|
|
@@ -3250,6 +3740,10 @@ export class TopicControlPlane extends DirectStream {
|
|
|
3250
3740
|
}
|
|
3251
3741
|
return;
|
|
3252
3742
|
}
|
|
3743
|
+
if (pubsubMessage instanceof Unsubscribe) {
|
|
3744
|
+
this.processUnsubscribeMessage(message, pubsubMessage, from);
|
|
3745
|
+
return;
|
|
3746
|
+
}
|
|
3253
3747
|
if (pubsubMessage instanceof GetSubscribers) {
|
|
3254
3748
|
const overlap = this.getSubscriptionOverlap(pubsubMessage.topics);
|
|
3255
3749
|
if (overlap.length === 0)
|
|
@@ -3270,6 +3764,35 @@ export class TopicControlPlane extends DirectStream {
|
|
|
3270
3764
|
return;
|
|
3271
3765
|
}
|
|
3272
3766
|
}
|
|
3767
|
+
processUnsubscribeMessage(message, unsubscribe, from) {
|
|
3768
|
+
const senderKey = from.hashcode();
|
|
3769
|
+
const relevantTopics = unsubscribe.topics.filter((topic) => this.isTrackedTopic(topic));
|
|
3770
|
+
if (!relevantTopics.length ||
|
|
3771
|
+
!this.subscriptionMessageIsLatest(message, unsubscribe, relevantTopics))
|
|
3772
|
+
return;
|
|
3773
|
+
const changed = [];
|
|
3774
|
+
for (const topic of relevantTopics) {
|
|
3775
|
+
if (this.topics.get(topic)?.delete(senderKey)) {
|
|
3776
|
+
changed.push(topic);
|
|
3777
|
+
this.peerToTopic.get(senderKey)?.delete(topic);
|
|
3778
|
+
}
|
|
3779
|
+
}
|
|
3780
|
+
if (!this.peerToTopic.get(senderKey)?.size) {
|
|
3781
|
+
this.peerToTopic.delete(senderKey);
|
|
3782
|
+
}
|
|
3783
|
+
// Independent direct/shard paths may deliver an older Subscribe later.
|
|
3784
|
+
// Retain recent unsubscribe floors, bounded separately from live subscribers.
|
|
3785
|
+
const messages = this.lastSubscriptionMessages.get(senderKey);
|
|
3786
|
+
this.lastSubscriptionMessages.delete(senderKey);
|
|
3787
|
+
this.lastSubscriptionMessages.set(senderKey, messages);
|
|
3788
|
+
for (const topic of relevantTopics)
|
|
3789
|
+
this.pruneUnsubscribedWatermarks(topic);
|
|
3790
|
+
if (changed.length > 0) {
|
|
3791
|
+
this.dispatchEvent(new CustomEvent("unsubscribe", {
|
|
3792
|
+
detail: new UnsubcriptionEvent(from, changed, "remote-unsubscribe"),
|
|
3793
|
+
}));
|
|
3794
|
+
}
|
|
3795
|
+
}
|
|
3273
3796
|
async processShardPubSubMessage(input) {
|
|
3274
3797
|
const { pubsubMessage, message, from, shardTopic, isCurrent } = input;
|
|
3275
3798
|
if (isCurrent?.() === false)
|
|
@@ -3331,31 +3854,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
3331
3854
|
return;
|
|
3332
3855
|
}
|
|
3333
3856
|
if (pubsubMessage instanceof Unsubscribe) {
|
|
3334
|
-
|
|
3335
|
-
const senderKey = sender.hashcode();
|
|
3336
|
-
const relevantTopics = pubsubMessage.topics.filter((t) => this.isTrackedTopic(t));
|
|
3337
|
-
if (relevantTopics.length > 0 &&
|
|
3338
|
-
this.subscriptionMessageIsLatest(message, pubsubMessage, relevantTopics)) {
|
|
3339
|
-
const changed = [];
|
|
3340
|
-
for (const topic of relevantTopics) {
|
|
3341
|
-
const peers = this.topics.get(topic);
|
|
3342
|
-
if (!peers)
|
|
3343
|
-
continue;
|
|
3344
|
-
if (peers.delete(senderKey)) {
|
|
3345
|
-
changed.push(topic);
|
|
3346
|
-
this.peerToTopic.get(senderKey)?.delete(topic);
|
|
3347
|
-
}
|
|
3348
|
-
}
|
|
3349
|
-
if (!this.peerToTopic.get(senderKey)?.size) {
|
|
3350
|
-
this.peerToTopic.delete(senderKey);
|
|
3351
|
-
this.lastSubscriptionMessages.delete(senderKey);
|
|
3352
|
-
}
|
|
3353
|
-
if (changed.length > 0) {
|
|
3354
|
-
this.dispatchEvent(new CustomEvent("unsubscribe", {
|
|
3355
|
-
detail: new UnsubcriptionEvent(sender, changed, "remote-unsubscribe"),
|
|
3356
|
-
}));
|
|
3357
|
-
}
|
|
3358
|
-
}
|
|
3857
|
+
this.processUnsubscribeMessage(message, pubsubMessage, from);
|
|
3359
3858
|
return;
|
|
3360
3859
|
}
|
|
3361
3860
|
if (pubsubMessage instanceof PeerUnavailable) {
|
|
@@ -3448,6 +3947,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
3448
3947
|
!(pubsubMessage instanceof TopicRootQuery) &&
|
|
3449
3948
|
!(pubsubMessage instanceof GetSubscribers) &&
|
|
3450
3949
|
!(pubsubMessage instanceof Subscribe) &&
|
|
3950
|
+
!(pubsubMessage instanceof Unsubscribe) &&
|
|
3451
3951
|
!(pubsubMessage instanceof TopicRootQueryResponse)) {
|
|
3452
3952
|
return true;
|
|
3453
3953
|
}
|