@peerbit/pubsub 5.3.11 → 5.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/index.d.ts +31 -13
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +622 -145
- package/dist/src/index.js.map +1 -1
- package/package.json +8 -7
- package/src/index.ts +914 -216
- package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.d.ts +0 -3
- package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.d.ts.map +0 -1
- package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.js +0 -90
- package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.js.map +0 -1
- package/dist/benchmark/fanout-tree-parent-upgrade-eval.d.ts +0 -2
- package/dist/benchmark/fanout-tree-parent-upgrade-eval.d.ts.map +0 -1
- package/dist/benchmark/fanout-tree-parent-upgrade-eval.js +0 -712
- package/dist/benchmark/fanout-tree-parent-upgrade-eval.js.map +0 -1
- package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.d.ts +0 -2
- package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.d.ts.map +0 -1
- package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.js +0 -1591
- package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.js.map +0 -1
- package/dist/benchmark/fanout-tree-parent-upgrade-prepush.d.ts +0 -2
- package/dist/benchmark/fanout-tree-parent-upgrade-prepush.d.ts.map +0 -1
- package/dist/benchmark/fanout-tree-parent-upgrade-prepush.js +0 -297
- package/dist/benchmark/fanout-tree-parent-upgrade-prepush.js.map +0 -1
- package/dist/benchmark/fanout-tree-parent-upgrade-preset.d.ts +0 -73
- package/dist/benchmark/fanout-tree-parent-upgrade-preset.d.ts.map +0 -1
- package/dist/benchmark/fanout-tree-parent-upgrade-preset.js +0 -183
- package/dist/benchmark/fanout-tree-parent-upgrade-preset.js.map +0 -1
- package/dist/benchmark/fanout-tree-sim-lib.d.ts +0 -303
- package/dist/benchmark/fanout-tree-sim-lib.d.ts.map +0 -1
- package/dist/benchmark/fanout-tree-sim-lib.js +0 -1752
- package/dist/benchmark/fanout-tree-sim-lib.js.map +0 -1
- package/dist/benchmark/fanout-tree-sim.d.ts +0 -2
- package/dist/benchmark/fanout-tree-sim.d.ts.map +0 -1
- package/dist/benchmark/fanout-tree-sim.js +0 -870
- package/dist/benchmark/fanout-tree-sim.js.map +0 -1
- package/dist/benchmark/index.d.ts +0 -8
- package/dist/benchmark/index.d.ts.map +0 -1
- package/dist/benchmark/index.js +0 -62
- package/dist/benchmark/index.js.map +0 -1
- package/dist/benchmark/pubsub-topic-sim-lib.d.ts +0 -86
- package/dist/benchmark/pubsub-topic-sim-lib.d.ts.map +0 -1
- package/dist/benchmark/pubsub-topic-sim-lib.js +0 -680
- package/dist/benchmark/pubsub-topic-sim-lib.js.map +0 -1
- package/dist/benchmark/pubsub-topic-sim.d.ts +0 -9
- package/dist/benchmark/pubsub-topic-sim.d.ts.map +0 -1
- package/dist/benchmark/pubsub-topic-sim.js +0 -134
- package/dist/benchmark/pubsub-topic-sim.js.map +0 -1
- package/dist/benchmark/sim/bench-utils.d.ts +0 -25
- package/dist/benchmark/sim/bench-utils.d.ts.map +0 -1
- package/dist/benchmark/sim/bench-utils.js +0 -141
- package/dist/benchmark/sim/bench-utils.js.map +0 -1
package/dist/src/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import {} from "@libp2p/interface";
|
|
2
|
-
import { PublicSignKey, getPublicKeyFromPeerId } from "@peerbit/crypto";
|
|
2
|
+
import { PublicSignKey, getPublicKeyFromPeerId, sha256Sync, } from "@peerbit/crypto";
|
|
3
3
|
import { logger as loggerFn } from "@peerbit/logger";
|
|
4
|
-
import {
|
|
4
|
+
import { DataEvent, GetSubscribers, PeerUnavailable, PubSubData, PubSubMessage, PublishEvent, Subscribe, SubscriptionData, SubscriptionEvent, TOPIC_ROOT_CANDIDATES_MAX, TOPIC_ROOT_CANDIDATE_CLAIMS_MAX, TOPIC_ROOT_CANDIDATE_CLAIM_MAX_BYTES, TopicRootCandidateClaims, TopicRootCandidates, TopicRootQuery, TopicRootQueryResponse, UnsubcriptionEvent, Unsubscribe, assertCanonicalTopicRootCandidates, assertTopicRootCandidatesFrame, isCanonicalTopicRootCandidate, } from "@peerbit/pubsub-interface";
|
|
5
5
|
import { DirectStream, } from "@peerbit/stream";
|
|
6
6
|
import { AcknowledgeAnyWhere, AcknowledgeDelivery, AnyWhere, DataMessage, DeliveryError, MessageHeader, NotStartedError, SilentDelivery, deliveryModeHasReceiver, getMsgId, isAcknowledgeAnyWhereDeliveryMode, isAcknowledgeDeliveryMode, isAnyWhereDeliveryMode, isSilentDeliveryMode, } from "@peerbit/stream-interface";
|
|
7
7
|
import { AbortError, TimeoutError, delay } from "@peerbit/time";
|
|
8
8
|
import { Uint8ArrayList } from "uint8arraylist";
|
|
9
|
+
import { equals as bytesEqual } from "uint8arrays";
|
|
9
10
|
import { toUint8Array } from "./bytes.js";
|
|
10
11
|
import { debouncedAccumulatorSetCounter, } from "./debounced-set.js";
|
|
11
12
|
import { FanoutChannel } from "./fanout-channel.js";
|
|
@@ -112,6 +113,25 @@ const DEFAULT_PUBSUB_SHARD_COUNT = 256;
|
|
|
112
113
|
const PUBSUB_SHARD_COUNT_HARD_CAP = 16_384;
|
|
113
114
|
const DEFAULT_PUBSUB_SHARD_TOPIC_PREFIX = "/peerbit/pubsub-shard/1/";
|
|
114
115
|
const AUTO_TOPIC_ROOT_CANDIDATE_UPDATE_COOLDOWN_MS = 2_000;
|
|
116
|
+
const TOPIC_CONTROL_PLANE_PROTOCOL_V2_1 = "/peerbit/topic-control-plane/2.1.0";
|
|
117
|
+
const TOPIC_CONTROL_PLANE_PROTOCOL_V2_0 = "/peerbit/topic-control-plane/2.0.0";
|
|
118
|
+
const TOPIC_ROOT_CANDIDATE_CLAIM_DOMAIN = utf8Encoder.encode("peerbit/topic-root-candidate-claim/v1\0");
|
|
119
|
+
const TOPIC_ROOT_CANDIDATE_SCOPE_DOMAIN = "peerbit/topic-root-candidate-scope/v1";
|
|
120
|
+
const TOPIC_ROOT_CANDIDATE_CLAIM_LIFETIME_MS = 90_000;
|
|
121
|
+
const TOPIC_ROOT_CANDIDATE_CLAIM_FUTURE_SKEW_MS = 30_000;
|
|
122
|
+
const TOPIC_ROOT_CANDIDATE_CLAIM_RECEIVER_MAX_LIFETIME_MS = TOPIC_ROOT_CANDIDATE_CLAIM_LIFETIME_MS +
|
|
123
|
+
TOPIC_ROOT_CANDIDATE_CLAIM_FUTURE_SKEW_MS;
|
|
124
|
+
const TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_MS = 30_000;
|
|
125
|
+
const TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_JITTER_MS = 5_000;
|
|
126
|
+
const TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_RETRY_MS = 1_000;
|
|
127
|
+
const TOPIC_ROOT_CANDIDATE_CLAIM_ADVERTISEMENT_TIMEOUT_MS = 10_000;
|
|
128
|
+
// A sparse leaf can receive a 64-origin snapshot plus two refresh waves through
|
|
129
|
+
// one relay in the first minute. Keep per-relay headroom for that honest burst;
|
|
130
|
+
// the separate global cap still bounds total signature work.
|
|
131
|
+
const TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_BURST = 256;
|
|
132
|
+
const TOPIC_ROOT_CANDIDATE_CLAIM_GLOBAL_VERIFY_BURST = 512;
|
|
133
|
+
const TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_REFILL_MS = 60_000;
|
|
134
|
+
const TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_PEERS_MAX = 4_096;
|
|
115
135
|
const sameCandidates = (left, right) => left.length === right.length &&
|
|
116
136
|
left.every((candidate, index) => candidate === right[index]);
|
|
117
137
|
// Topic-root queries may need to wait for the responder to finish opening an
|
|
@@ -169,6 +189,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
169
189
|
debounceUnsubscribeAggregator;
|
|
170
190
|
shardCount;
|
|
171
191
|
shardTopicPrefix;
|
|
192
|
+
topicRootCandidateClaimData;
|
|
172
193
|
hostShards;
|
|
173
194
|
shardRootCache = new Map();
|
|
174
195
|
shardTopicCache = new Map();
|
|
@@ -196,16 +217,27 @@ export class TopicControlPlane extends DirectStream {
|
|
|
196
217
|
topicRootCandidateResolutionAbortController = new AbortController();
|
|
197
218
|
hostOwnedShardRootsInFlight;
|
|
198
219
|
hostOwnedShardRootsDirty = false;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
220
|
+
signedTopicRootCandidateClaims = new Map();
|
|
221
|
+
// Active claims expire, but their per-origin timestamp floors survive for the
|
|
222
|
+
// auto-mode lifecycle so a frozen wall clock cannot renew a replayed lease.
|
|
223
|
+
// Retaining only the deterministic lowest origins keeps this state hard-bounded.
|
|
224
|
+
topicRootCandidateClaimReplayFloors = new Map();
|
|
225
|
+
localSignedTopicRootCandidateClaim;
|
|
226
|
+
topicRootCandidateClaimMaintenanceTimer;
|
|
227
|
+
topicRootCandidateClaimRefreshNotBefore;
|
|
228
|
+
topicRootCandidateClaimMaintenanceRefreshInFlight;
|
|
229
|
+
topicRootCandidateClaimVerifyBudgets = new Map();
|
|
230
|
+
topicRootCandidateClaimGlobalVerifyBudget = {
|
|
231
|
+
remaining: TOPIC_ROOT_CANDIDATE_CLAIM_GLOBAL_VERIFY_BURST,
|
|
232
|
+
refilledAt: Date.now(),
|
|
233
|
+
};
|
|
202
234
|
_onFanoutPeerUnreachable;
|
|
203
235
|
fanoutChannels = new Map();
|
|
204
236
|
ensureFanoutChannelInFlight = new Map();
|
|
205
237
|
pendingTopicRootQueries = new Map();
|
|
206
238
|
nextTopicRootQueryId = 1;
|
|
207
239
|
constructor(components, props) {
|
|
208
|
-
super(components, [
|
|
240
|
+
super(components, [TOPIC_CONTROL_PLANE_PROTOCOL_V2_1, TOPIC_CONTROL_PLANE_PROTOCOL_V2_0], props);
|
|
209
241
|
this.subscriptions = new Map();
|
|
210
242
|
this.pendingSubscriptions = new Set();
|
|
211
243
|
this.topics = new Map();
|
|
@@ -223,8 +255,8 @@ export class TopicControlPlane extends DirectStream {
|
|
|
223
255
|
}
|
|
224
256
|
this.fanout = props.fanout;
|
|
225
257
|
// Default to a local-only shard-root candidate set so standalone peers can
|
|
226
|
-
// subscribe/publish without explicit bootstraps.
|
|
227
|
-
// opportunistically as
|
|
258
|
+
// subscribe/publish without explicit bootstraps. Signed peer claims expand
|
|
259
|
+
// the set opportunistically as the ad-hoc overlay connects.
|
|
228
260
|
if (this.topicRootControlPlane.getTopicRootCandidates().length === 0) {
|
|
229
261
|
this.autoTopicRootCandidates = true;
|
|
230
262
|
this.autoTopicRootCandidateSet = new Set([this.publicKeyHash]);
|
|
@@ -234,6 +266,14 @@ export class TopicControlPlane extends DirectStream {
|
|
|
234
266
|
this.shardCount = Math.min(PUBSUB_SHARD_COUNT_HARD_CAP, Math.max(1, Math.floor(requestedShardCount)));
|
|
235
267
|
const prefix = props?.shardTopicPrefix ?? DEFAULT_PUBSUB_SHARD_TOPIC_PREFIX;
|
|
236
268
|
this.shardTopicPrefix = prefix.endsWith("/") ? prefix : prefix + "/";
|
|
269
|
+
const scopeDigest = sha256Sync(utf8Encoder.encode(JSON.stringify([
|
|
270
|
+
TOPIC_ROOT_CANDIDATE_SCOPE_DOMAIN,
|
|
271
|
+
this.shardTopicPrefix,
|
|
272
|
+
this.shardCount,
|
|
273
|
+
])));
|
|
274
|
+
this.topicRootCandidateClaimData = new Uint8Array(TOPIC_ROOT_CANDIDATE_CLAIM_DOMAIN.byteLength + scopeDigest.byteLength);
|
|
275
|
+
this.topicRootCandidateClaimData.set(TOPIC_ROOT_CANDIDATE_CLAIM_DOMAIN, 0);
|
|
276
|
+
this.topicRootCandidateClaimData.set(scopeDigest, TOPIC_ROOT_CANDIDATE_CLAIM_DOMAIN.byteLength);
|
|
237
277
|
this.hostShards = props?.hostShards ?? false;
|
|
238
278
|
const baseFanoutChannelOptions = {
|
|
239
279
|
...DEFAULT_PUBSUB_FANOUT_CHANNEL_OPTIONS,
|
|
@@ -282,25 +322,20 @@ export class TopicControlPlane extends DirectStream {
|
|
|
282
322
|
* candidate mode.
|
|
283
323
|
*
|
|
284
324
|
* Auto mode is a convenience for small ad-hoc networks where no bootstraps/
|
|
285
|
-
* routers are configured.
|
|
286
|
-
*
|
|
325
|
+
* routers are configured. Automatic claims authenticate key ownership and
|
|
326
|
+
* freshness, not liveness, admission, or Sybil resistance; hostile or isolated
|
|
327
|
+
* deployments should configure explicit candidates. When an explicit candidate
|
|
328
|
+
* set is provided (e.g. from bootstraps or a test harness), we stop mutating the
|
|
287
329
|
* candidate set; otherwise shard root resolution can diverge and overlays can
|
|
288
330
|
* partition (especially in sparse graphs).
|
|
289
331
|
*/
|
|
290
332
|
setTopicRootCandidates(candidates) {
|
|
291
333
|
this.topicRootControlPlane.setTopicRootCandidates(candidates);
|
|
292
|
-
// Disable auto mode and
|
|
334
|
+
// Disable auto mode and clear its signed-claim state/timers.
|
|
293
335
|
this.autoTopicRootCandidates = false;
|
|
294
336
|
this.autoTopicRootCandidateSet = undefined;
|
|
337
|
+
this.clearSignedTopicRootCandidateState();
|
|
295
338
|
this.clearAutoTopicRootCandidateUpdateSchedule();
|
|
296
|
-
for (const t of this.autoCandidatesBroadcastTimers)
|
|
297
|
-
clearTimeout(t);
|
|
298
|
-
this.autoCandidatesBroadcastTimers = [];
|
|
299
|
-
if (this.autoCandidatesGossipInterval) {
|
|
300
|
-
clearInterval(this.autoCandidatesGossipInterval);
|
|
301
|
-
this.autoCandidatesGossipInterval = undefined;
|
|
302
|
-
}
|
|
303
|
-
this.autoCandidatesGossipUntil = 0;
|
|
304
339
|
// Re-resolve roots under the new mapping.
|
|
305
340
|
this.shardRootCache.clear();
|
|
306
341
|
// Only candidates can become deterministic roots. Avoid doing a full shard
|
|
@@ -311,16 +346,39 @@ export class TopicControlPlane extends DirectStream {
|
|
|
311
346
|
this.scheduleReconcileShardOverlays();
|
|
312
347
|
}
|
|
313
348
|
async start() {
|
|
349
|
+
// Match DirectStream's idempotent start guard before mutating automatic
|
|
350
|
+
// candidates. In particular, a repeated start must not restore unsigned
|
|
351
|
+
// self while a signed-protocol peer is connected.
|
|
352
|
+
if (this.started)
|
|
353
|
+
return;
|
|
314
354
|
this.topicControlPlaneStopping = false;
|
|
355
|
+
if (this.autoTopicRootCandidates &&
|
|
356
|
+
!this.localSignedTopicRootCandidateClaim) {
|
|
357
|
+
this.autoTopicRootCandidateSet = new Set([this.publicKeyHash]);
|
|
358
|
+
this.topicRootControlPlane.setTopicRootCandidates([this.publicKeyHash]);
|
|
359
|
+
this.shardRootCache.clear();
|
|
360
|
+
}
|
|
315
361
|
if (this.topicRootResolutionAbortController.signal.aborted) {
|
|
316
362
|
this.topicRootResolutionAbortController = new AbortController();
|
|
317
363
|
}
|
|
318
364
|
await this.fanout.start();
|
|
319
365
|
this._onFanoutPeerUnreachable =
|
|
320
|
-
this._onFanoutPeerUnreachable ||
|
|
321
|
-
this.onFanoutPeerUnreachable.bind(this);
|
|
366
|
+
this._onFanoutPeerUnreachable || this.onFanoutPeerUnreachable.bind(this);
|
|
322
367
|
await this.fanout.addEventListener("fanout:peer-unreachable", this._onFanoutPeerUnreachable);
|
|
323
368
|
await super.start();
|
|
369
|
+
if (this.autoTopicRootCandidates) {
|
|
370
|
+
let retryDelayMs = 0;
|
|
371
|
+
try {
|
|
372
|
+
if (!(await this.refreshLocalTopicRootCandidateClaim())) {
|
|
373
|
+
retryDelayMs = TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_RETRY_MS;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
logErrorIfStarted(error);
|
|
378
|
+
retryDelayMs = TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_RETRY_MS;
|
|
379
|
+
}
|
|
380
|
+
this.scheduleTopicRootCandidateClaimMaintenance(retryDelayMs);
|
|
381
|
+
}
|
|
324
382
|
if (this.hostShards) {
|
|
325
383
|
await this.hostShardRootsNow();
|
|
326
384
|
}
|
|
@@ -335,6 +393,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
335
393
|
this.topicControlPlaneStopping = true;
|
|
336
394
|
this.topicRootResolutionAbortController.abort(new AbortError("topic control plane stopped"));
|
|
337
395
|
this.clearAutoTopicRootCandidateUpdateSchedule();
|
|
396
|
+
this.clearSignedTopicRootCandidateState();
|
|
338
397
|
this.topicControlPlaneLifecycleRevision += 1;
|
|
339
398
|
this.reconcileShardOverlaysDirty = false;
|
|
340
399
|
for (const opening of this.ensureFanoutChannelInFlight.values()) {
|
|
@@ -373,14 +432,6 @@ export class TopicControlPlane extends DirectStream {
|
|
|
373
432
|
}
|
|
374
433
|
}
|
|
375
434
|
this.fanoutChannels.clear();
|
|
376
|
-
for (const t of this.autoCandidatesBroadcastTimers)
|
|
377
|
-
clearTimeout(t);
|
|
378
|
-
this.autoCandidatesBroadcastTimers = [];
|
|
379
|
-
if (this.autoCandidatesGossipInterval) {
|
|
380
|
-
clearInterval(this.autoCandidatesGossipInterval);
|
|
381
|
-
this.autoCandidatesGossipInterval = undefined;
|
|
382
|
-
}
|
|
383
|
-
this.autoCandidatesGossipUntil = 0;
|
|
384
435
|
this.hostOwnedShardRootsDirty = false;
|
|
385
436
|
this.subscriptions.clear();
|
|
386
437
|
this.pendingSubscriptions.clear();
|
|
@@ -405,33 +456,54 @@ export class TopicControlPlane extends DirectStream {
|
|
|
405
456
|
await super.stop();
|
|
406
457
|
this.reconcileShardOverlaysDirty = false;
|
|
407
458
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
// If we're in auto-candidate mode, expand the deterministic shard-root
|
|
411
|
-
// candidate set as neighbours connect, then reconcile shard overlays and
|
|
412
|
-
// re-announce subscriptions so membership knowledge converges.
|
|
413
|
-
if (!this.autoTopicRootCandidates)
|
|
414
|
-
return;
|
|
415
|
-
let peerHash;
|
|
416
|
-
try {
|
|
417
|
-
peerHash = getPublicKeyFromPeerId(peerId).hashcode();
|
|
418
|
-
}
|
|
419
|
-
catch {
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
|
-
void this.maybeUpdateAutoTopicRootCandidates(peerHash);
|
|
423
|
-
}
|
|
459
|
+
// Candidate trust starts only after protocol negotiation in `addPeer()`;
|
|
460
|
+
// a transport connection alone cannot bypass signed 2.1 self-claims.
|
|
424
461
|
// Ensure auto-candidate mode converges even when libp2p topology callbacks
|
|
425
462
|
// are delayed or only fire for one side of a connection. `addPeer()` runs for
|
|
426
463
|
// both inbound + outbound protocol streams once the remote public key is known.
|
|
427
464
|
addPeer(peerId, publicKey, protocol, connId) {
|
|
465
|
+
const hadSignedPeer = [...this.peers.values()].some((peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1);
|
|
466
|
+
const existingPeer = this.peers.get(publicKey.hashcode());
|
|
428
467
|
const peer = super.addPeer(peerId, publicKey, protocol, connId);
|
|
429
468
|
if (this.autoTopicRootCandidates) {
|
|
430
|
-
|
|
431
|
-
this.
|
|
469
|
+
const hasSignedPeer = [...this.peers.values()].some((candidate) => candidate.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1);
|
|
470
|
+
this.rebuildAutoTopicRootCandidatesFromClaims(BigInt(Date.now()), {
|
|
471
|
+
immediate: hadSignedPeer !== hasSignedPeer,
|
|
472
|
+
});
|
|
473
|
+
if (peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1) {
|
|
474
|
+
const sendWhenOutboundReady = () => {
|
|
475
|
+
if (!this.autoTopicRootCandidates ||
|
|
476
|
+
this.peers.get(publicKey.hashcode()) !== peer) {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
void this.sendAutoTopicRootCandidates([peer]).catch(() => { });
|
|
480
|
+
};
|
|
481
|
+
if (peer.isWritable) {
|
|
482
|
+
void this.sendAutoTopicRootCandidates([peer]).catch(() => { });
|
|
483
|
+
}
|
|
484
|
+
if (!existingPeer) {
|
|
485
|
+
peer.addEventListener("stream:outbound", sendWhenOutboundReady);
|
|
486
|
+
peer.addEventListener("close", () => peer.removeEventListener("stream:outbound", sendWhenOutboundReady), { once: true });
|
|
487
|
+
}
|
|
488
|
+
}
|
|
432
489
|
}
|
|
433
490
|
return peer;
|
|
434
491
|
}
|
|
492
|
+
async _removePeer(publicKey) {
|
|
493
|
+
const hash = publicKey.hashcode();
|
|
494
|
+
const protocol = this.peers.get(hash)?.protocol;
|
|
495
|
+
const hadSignedPeer = [...this.peers.values()].some((peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1);
|
|
496
|
+
const removed = await super._removePeer(publicKey);
|
|
497
|
+
if (this.autoTopicRootCandidates &&
|
|
498
|
+
(protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_0 ||
|
|
499
|
+
protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1)) {
|
|
500
|
+
const hasSignedPeer = [...this.peers.values()].some((peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1);
|
|
501
|
+
this.rebuildAutoTopicRootCandidatesFromClaims(BigInt(Date.now()), {
|
|
502
|
+
immediate: hadSignedPeer !== hasSignedPeer,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
return removed;
|
|
506
|
+
}
|
|
435
507
|
maybeDisableAutoTopicRootCandidatesIfExternallyConfigured() {
|
|
436
508
|
if (!this.autoTopicRootCandidates)
|
|
437
509
|
return false;
|
|
@@ -446,6 +518,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
446
518
|
// intact and reconcile shard overlays under the new mapping.
|
|
447
519
|
this.autoTopicRootCandidates = false;
|
|
448
520
|
this.autoTopicRootCandidateSet = undefined;
|
|
521
|
+
this.clearSignedTopicRootCandidateState();
|
|
449
522
|
this.clearAutoTopicRootCandidateUpdateSchedule();
|
|
450
523
|
this.shardRootCache.clear();
|
|
451
524
|
// Ensure we host any shard roots we're now responsible for. This is important
|
|
@@ -456,114 +529,493 @@ export class TopicControlPlane extends DirectStream {
|
|
|
456
529
|
this.scheduleReconcileShardOverlays();
|
|
457
530
|
return true;
|
|
458
531
|
}
|
|
459
|
-
|
|
460
|
-
if (
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
532
|
+
clearTopicRootCandidateClaimTimer() {
|
|
533
|
+
if (this.topicRootCandidateClaimMaintenanceTimer) {
|
|
534
|
+
clearTimeout(this.topicRootCandidateClaimMaintenanceTimer);
|
|
535
|
+
this.topicRootCandidateClaimMaintenanceTimer = undefined;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
clearSignedTopicRootCandidateState() {
|
|
539
|
+
this.clearTopicRootCandidateClaimTimer();
|
|
540
|
+
this.signedTopicRootCandidateClaims.clear();
|
|
541
|
+
this.topicRootCandidateClaimReplayFloors.clear();
|
|
542
|
+
this.localSignedTopicRootCandidateClaim = undefined;
|
|
543
|
+
this.topicRootCandidateClaimRefreshNotBefore = undefined;
|
|
544
|
+
this.topicRootCandidateClaimMaintenanceRefreshInFlight = undefined;
|
|
545
|
+
this.topicRootCandidateClaimVerifyBudgets.clear();
|
|
546
|
+
this.topicRootCandidateClaimGlobalVerifyBudget = {
|
|
547
|
+
remaining: TOPIC_ROOT_CANDIDATE_CLAIM_GLOBAL_VERIFY_BURST,
|
|
548
|
+
refilledAt: Date.now(),
|
|
549
|
+
};
|
|
466
550
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
551
|
+
isTopicRootCandidateClaimTimeValid(timestamp, expires, now) {
|
|
552
|
+
return (expires > now &&
|
|
553
|
+
timestamp <= now + BigInt(TOPIC_ROOT_CANDIDATE_CLAIM_FUTURE_SKEW_MS) &&
|
|
554
|
+
expires - timestamp === BigInt(TOPIC_ROOT_CANDIDATE_CLAIM_LIFETIME_MS));
|
|
555
|
+
}
|
|
556
|
+
topicRootCandidateClaimAcceptanceDeadline(timestamp, expires, wallNow, monotonicNow, maxRemainingMs) {
|
|
557
|
+
if (!this.isTopicRootCandidateClaimTimeValid(timestamp, expires, wallNow)) {
|
|
558
|
+
return undefined;
|
|
559
|
+
}
|
|
560
|
+
return (monotonicNow +
|
|
561
|
+
Math.min(Number(expires - wallNow), Math.max(0, maxRemainingMs)));
|
|
562
|
+
}
|
|
563
|
+
async refreshLocalTopicRootCandidateClaim() {
|
|
564
|
+
if (!this.autoTopicRootCandidates ||
|
|
565
|
+
!this.started ||
|
|
566
|
+
this.stopping ||
|
|
567
|
+
this.topicControlPlaneStopping) {
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
if (!this.canRetainTopicRootCandidateClaimOrigin(this.publicKeyHash)) {
|
|
571
|
+
return false;
|
|
471
572
|
}
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
573
|
+
const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
|
|
574
|
+
const claim = await this.createMessage(this.topicRootCandidateClaimData, {
|
|
575
|
+
mode: new AnyWhere(),
|
|
576
|
+
priority: 1,
|
|
577
|
+
expiresInMs: TOPIC_ROOT_CANDIDATE_CLAIM_LIFETIME_MS,
|
|
578
|
+
skipRecipientValidation: true,
|
|
579
|
+
});
|
|
580
|
+
const bytes = toUint8Array(claim.bytes());
|
|
581
|
+
if (bytes.byteLength > TOPIC_ROOT_CANDIDATE_CLAIM_MAX_BYTES) {
|
|
582
|
+
throw new Error(`Local topic-root candidate claim exceeds ${TOPIC_ROOT_CANDIDATE_CLAIM_MAX_BYTES} bytes`);
|
|
475
583
|
}
|
|
476
|
-
if (
|
|
477
|
-
|
|
584
|
+
if (lifecycleRevision !== this.topicControlPlaneLifecycleRevision ||
|
|
585
|
+
!this.autoTopicRootCandidates ||
|
|
586
|
+
!this.started ||
|
|
587
|
+
this.stopping ||
|
|
588
|
+
this.topicControlPlaneStopping) {
|
|
589
|
+
return false;
|
|
478
590
|
}
|
|
479
|
-
const
|
|
480
|
-
|
|
591
|
+
const timestamp = claim.header.timestamp;
|
|
592
|
+
const expires = claim.header.expires;
|
|
593
|
+
const acceptanceDeadline = this.topicRootCandidateClaimAcceptanceDeadline(timestamp, expires, BigInt(Date.now()), performance.now(), TOPIC_ROOT_CANDIDATE_CLAIM_LIFETIME_MS);
|
|
594
|
+
if (acceptanceDeadline === undefined) {
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
const record = {
|
|
598
|
+
bytes,
|
|
599
|
+
timestamp,
|
|
600
|
+
expires,
|
|
601
|
+
acceptUntil: acceptanceDeadline,
|
|
602
|
+
};
|
|
603
|
+
const current = this.localSignedTopicRootCandidateClaim;
|
|
604
|
+
if (current && record.timestamp <= current.timestamp) {
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
if (!this.retainSignedTopicRootCandidateClaim(this.publicKeyHash, record)) {
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
this.localSignedTopicRootCandidateClaim = record;
|
|
611
|
+
this.topicRootCandidateClaimRefreshNotBefore = undefined;
|
|
612
|
+
this.rebuildAutoTopicRootCandidatesFromClaims();
|
|
613
|
+
return this.localSignedTopicRootCandidateClaim === record;
|
|
481
614
|
}
|
|
482
|
-
|
|
483
|
-
if (!this.autoTopicRootCandidates
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
if (targets && targets.length > 0) {
|
|
488
|
-
void this.sendAutoTopicRootCandidates(targets).catch(() => { });
|
|
615
|
+
scheduleTopicRootCandidateClaimMaintenance(minDelayMs = 0) {
|
|
616
|
+
if (!this.autoTopicRootCandidates ||
|
|
617
|
+
!this.started ||
|
|
618
|
+
this.stopping ||
|
|
619
|
+
this.topicControlPlaneStopping) {
|
|
489
620
|
return;
|
|
490
621
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
const
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
622
|
+
this.clearTopicRootCandidateClaimTimer();
|
|
623
|
+
const now = Date.now();
|
|
624
|
+
const monotonicNow = performance.now();
|
|
625
|
+
const local = this.localSignedTopicRootCandidateClaim;
|
|
626
|
+
const localOriginEligible = this.canRetainTopicRootCandidateClaimOrigin(this.publicKeyHash);
|
|
627
|
+
if (localOriginEligible && minDelayMs > 0) {
|
|
628
|
+
this.topicRootCandidateClaimRefreshNotBefore = Math.max(this.topicRootCandidateClaimRefreshNotBefore ?? 0, monotonicNow + minDelayMs);
|
|
629
|
+
}
|
|
630
|
+
else if (!localOriginEligible) {
|
|
631
|
+
this.topicRootCandidateClaimRefreshNotBefore = undefined;
|
|
632
|
+
}
|
|
633
|
+
const canScheduleLocalRefresh = localOriginEligible &&
|
|
634
|
+
!this.topicRootCandidateClaimMaintenanceRefreshInFlight;
|
|
635
|
+
const refreshDueAt = local
|
|
636
|
+
? Number(local.timestamp) +
|
|
637
|
+
TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_MS +
|
|
638
|
+
Math.floor(Math.random() * TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_JITTER_MS)
|
|
639
|
+
: canScheduleLocalRefresh
|
|
640
|
+
? now
|
|
641
|
+
: Number.POSITIVE_INFINITY;
|
|
642
|
+
let expiryDelayMs = Number.POSITIVE_INFINITY;
|
|
643
|
+
for (const claim of this.signedTopicRootCandidateClaims.values()) {
|
|
644
|
+
expiryDelayMs = Math.min(expiryDelayMs, Number(claim.expires) - now, claim.acceptUntil - monotonicNow);
|
|
645
|
+
}
|
|
646
|
+
if (local) {
|
|
647
|
+
expiryDelayMs = Math.min(expiryDelayMs, Number(local.expires) - now, local.acceptUntil - monotonicNow);
|
|
648
|
+
}
|
|
649
|
+
const refreshDelayMs = canScheduleLocalRefresh
|
|
650
|
+
? Math.max(refreshDueAt - now, (this.topicRootCandidateClaimRefreshNotBefore ?? monotonicNow) -
|
|
651
|
+
monotonicNow, 0)
|
|
652
|
+
: Number.POSITIVE_INFINITY;
|
|
653
|
+
expiryDelayMs = Math.max(Math.ceil(expiryDelayMs), 0);
|
|
654
|
+
const delayMs = Math.min(2_147_483_647, refreshDelayMs, expiryDelayMs);
|
|
655
|
+
const timer = setTimeout(() => {
|
|
656
|
+
if (this.topicRootCandidateClaimMaintenanceTimer !== timer)
|
|
517
657
|
return;
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
658
|
+
this.topicRootCandidateClaimMaintenanceTimer = undefined;
|
|
659
|
+
let retryDelayMs = 0;
|
|
660
|
+
try {
|
|
661
|
+
this.rebuildAutoTopicRootCandidatesFromClaims();
|
|
662
|
+
const currentLocal = this.localSignedTopicRootCandidateClaim;
|
|
663
|
+
const refreshDue = !this.topicRootCandidateClaimMaintenanceRefreshInFlight &&
|
|
664
|
+
this.canRetainTopicRootCandidateClaimOrigin(this.publicKeyHash) &&
|
|
665
|
+
performance.now() >=
|
|
666
|
+
(this.topicRootCandidateClaimRefreshNotBefore ?? 0) &&
|
|
667
|
+
(!currentLocal ||
|
|
668
|
+
Date.now() - Number(currentLocal.timestamp) >=
|
|
669
|
+
TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_MS);
|
|
670
|
+
if (refreshDue) {
|
|
671
|
+
this.startTopicRootCandidateClaimMaintenanceRefresh();
|
|
523
672
|
}
|
|
524
|
-
return;
|
|
525
673
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
674
|
+
catch (error) {
|
|
675
|
+
logErrorIfStarted(error);
|
|
676
|
+
retryDelayMs = TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_RETRY_MS;
|
|
677
|
+
}
|
|
678
|
+
if (!this.topicRootCandidateClaimMaintenanceTimer) {
|
|
679
|
+
// Re-arm immediately. While refresh is in flight, its due time is
|
|
680
|
+
// treated as infinity but independent claim expiries remain scheduled.
|
|
681
|
+
this.scheduleTopicRootCandidateClaimMaintenance(retryDelayMs);
|
|
682
|
+
}
|
|
683
|
+
}, delayMs);
|
|
684
|
+
timer.unref?.();
|
|
685
|
+
this.topicRootCandidateClaimMaintenanceTimer = timer;
|
|
529
686
|
}
|
|
530
|
-
|
|
531
|
-
if (
|
|
532
|
-
throw new NotStartedError();
|
|
533
|
-
const streams = targets ?? [...this.peers.values()];
|
|
534
|
-
if (streams.length === 0)
|
|
687
|
+
startTopicRootCandidateClaimMaintenanceRefresh() {
|
|
688
|
+
if (this.topicRootCandidateClaimMaintenanceRefreshInFlight)
|
|
535
689
|
return;
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
690
|
+
const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
|
|
691
|
+
let task;
|
|
692
|
+
task = (async () => {
|
|
693
|
+
try {
|
|
694
|
+
const refreshed = await this.refreshLocalTopicRootCandidateClaim();
|
|
695
|
+
if (this.topicRootCandidateClaimMaintenanceRefreshInFlight !== task ||
|
|
696
|
+
lifecycleRevision !== this.topicControlPlaneLifecycleRevision) {
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
const local = this.localSignedTopicRootCandidateClaim;
|
|
700
|
+
if (!refreshed || !local) {
|
|
701
|
+
if (this.canRetainTopicRootCandidateClaimOrigin(this.publicKeyHash)) {
|
|
702
|
+
this.topicRootCandidateClaimRefreshNotBefore =
|
|
703
|
+
performance.now() + TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_RETRY_MS;
|
|
704
|
+
}
|
|
705
|
+
else {
|
|
706
|
+
this.topicRootCandidateClaimRefreshNotBefore = undefined;
|
|
707
|
+
}
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
this.topicRootCandidateClaimRefreshNotBefore = undefined;
|
|
711
|
+
// Arm the new local lease's expiry before outbound I/O can stall.
|
|
712
|
+
this.scheduleTopicRootCandidateClaimMaintenance();
|
|
713
|
+
try {
|
|
714
|
+
await this.sendSignedTopicRootCandidateClaims([local.bytes], undefined, AbortSignal.timeout(TOPIC_ROOT_CANDIDATE_CLAIM_ADVERTISEMENT_TIMEOUT_MS));
|
|
715
|
+
}
|
|
716
|
+
catch (error) {
|
|
717
|
+
// A later renewal or outbound-stream snapshot retries this
|
|
718
|
+
// best-effort advertisement; the signed lease itself is valid.
|
|
719
|
+
logErrorIfStarted(error);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
catch (error) {
|
|
723
|
+
if (this.topicRootCandidateClaimMaintenanceRefreshInFlight === task &&
|
|
724
|
+
lifecycleRevision === this.topicControlPlaneLifecycleRevision) {
|
|
725
|
+
logErrorIfStarted(error);
|
|
726
|
+
this.topicRootCandidateClaimRefreshNotBefore =
|
|
727
|
+
this.canRetainTopicRootCandidateClaimOrigin(this.publicKeyHash)
|
|
728
|
+
? performance.now() + TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_RETRY_MS
|
|
729
|
+
: undefined;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
finally {
|
|
733
|
+
if (this.topicRootCandidateClaimMaintenanceRefreshInFlight === task &&
|
|
734
|
+
lifecycleRevision === this.topicControlPlaneLifecycleRevision) {
|
|
735
|
+
this.topicRootCandidateClaimMaintenanceRefreshInFlight = undefined;
|
|
736
|
+
this.scheduleTopicRootCandidateClaimMaintenance();
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
})();
|
|
740
|
+
this.topicRootCandidateClaimMaintenanceRefreshInFlight = task;
|
|
741
|
+
}
|
|
742
|
+
pruneExpiredTopicRootCandidateClaims(now = BigInt(Date.now()), monotonicNow = performance.now()) {
|
|
743
|
+
const localClaim = this.localSignedTopicRootCandidateClaim;
|
|
744
|
+
if (localClaim &&
|
|
745
|
+
(localClaim.expires <= now || localClaim.acceptUntil <= monotonicNow)) {
|
|
746
|
+
this.localSignedTopicRootCandidateClaim = undefined;
|
|
747
|
+
}
|
|
748
|
+
let changed = false;
|
|
749
|
+
for (const [origin, claim] of this.signedTopicRootCandidateClaims) {
|
|
750
|
+
if (claim.expires <= now || claim.acceptUntil <= monotonicNow) {
|
|
751
|
+
this.signedTopicRootCandidateClaims.delete(origin);
|
|
752
|
+
changed = true;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return changed;
|
|
546
756
|
}
|
|
547
|
-
|
|
757
|
+
rebuildAutoTopicRootCandidatesFromClaims(now = BigInt(Date.now()), options) {
|
|
548
758
|
if (!this.autoTopicRootCandidates)
|
|
549
759
|
return false;
|
|
550
|
-
|
|
760
|
+
const removedExpired = this.pruneExpiredTopicRootCandidateClaims(now);
|
|
761
|
+
const legacyDirectCandidates = [...this.peers.values()]
|
|
762
|
+
.filter((peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_0)
|
|
763
|
+
.map((peer) => peer.publicKey.hashcode());
|
|
764
|
+
const hasSignedPeer = [...this.peers.values()].some((peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1);
|
|
765
|
+
const next = [
|
|
766
|
+
...new Set([
|
|
767
|
+
...(this.localSignedTopicRootCandidateClaim || hasSignedPeer
|
|
768
|
+
? []
|
|
769
|
+
: [this.publicKeyHash]),
|
|
770
|
+
...legacyDirectCandidates,
|
|
771
|
+
...this.signedTopicRootCandidateClaims.keys(),
|
|
772
|
+
]),
|
|
773
|
+
]
|
|
774
|
+
.filter(isCanonicalTopicRootCandidate)
|
|
775
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
776
|
+
.slice(0, TOPIC_ROOT_CANDIDATES_MAX);
|
|
777
|
+
return this.setAutoTopicRootCandidateSnapshot(next, {
|
|
778
|
+
// A cooldown may coalesce additions, but it must never extend an expired
|
|
779
|
+
// candidate's effective lease. The full snapshot is authoritative, so any
|
|
780
|
+
// queued pre-expiry snapshot can be discarded safely here.
|
|
781
|
+
immediate: removedExpired || options?.immediate,
|
|
782
|
+
});
|
|
551
783
|
}
|
|
552
|
-
|
|
784
|
+
retainSignedTopicRootCandidateClaim(origin, claim) {
|
|
785
|
+
if (!this.canAdvanceTopicRootCandidateClaimReplayFloor(origin, claim.timestamp)) {
|
|
786
|
+
return false;
|
|
787
|
+
}
|
|
788
|
+
const existing = this.signedTopicRootCandidateClaims.get(origin);
|
|
789
|
+
if (existing) {
|
|
790
|
+
if (claim.timestamp <= existing.timestamp) {
|
|
791
|
+
return false;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
if (!this.topicRootCandidateClaimReplayFloors.has(origin)) {
|
|
795
|
+
if (this.topicRootCandidateClaimReplayFloors.size >=
|
|
796
|
+
TOPIC_ROOT_CANDIDATES_MAX) {
|
|
797
|
+
const highest = [...this.topicRootCandidateClaimReplayFloors.keys()]
|
|
798
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
799
|
+
.at(-1);
|
|
800
|
+
// `canAdvance...` admitted a lower origin. Removing the displaced
|
|
801
|
+
// origin's live/local state preserves the same lowest-origin invariant;
|
|
802
|
+
// because the retained maximum only decreases, it cannot re-enter.
|
|
803
|
+
this.topicRootCandidateClaimReplayFloors.delete(highest);
|
|
804
|
+
this.signedTopicRootCandidateClaims.delete(highest);
|
|
805
|
+
if (highest === this.publicKeyHash) {
|
|
806
|
+
this.localSignedTopicRootCandidateClaim = undefined;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
this.topicRootCandidateClaimReplayFloors.set(origin, claim.timestamp);
|
|
811
|
+
this.signedTopicRootCandidateClaims.set(origin, claim);
|
|
812
|
+
return true;
|
|
813
|
+
}
|
|
814
|
+
canAdvanceTopicRootCandidateClaimReplayFloor(origin, timestamp) {
|
|
815
|
+
const floor = this.topicRootCandidateClaimReplayFloors.get(origin);
|
|
816
|
+
if (floor !== undefined) {
|
|
817
|
+
return timestamp > floor;
|
|
818
|
+
}
|
|
819
|
+
return this.canRetainTopicRootCandidateClaimOrigin(origin);
|
|
820
|
+
}
|
|
821
|
+
canRetainTopicRootCandidateClaimOrigin(origin) {
|
|
822
|
+
if (this.topicRootCandidateClaimReplayFloors.has(origin)) {
|
|
823
|
+
return true;
|
|
824
|
+
}
|
|
825
|
+
if (this.topicRootCandidateClaimReplayFloors.size < TOPIC_ROOT_CANDIDATES_MAX) {
|
|
826
|
+
return true;
|
|
827
|
+
}
|
|
828
|
+
const highest = [...this.topicRootCandidateClaimReplayFloors.keys()]
|
|
829
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
830
|
+
.at(-1);
|
|
831
|
+
return origin < highest;
|
|
832
|
+
}
|
|
833
|
+
consumeTopicRootCandidateClaimVerifyBudget(peerHash) {
|
|
834
|
+
const now = Date.now();
|
|
835
|
+
if (now - this.topicRootCandidateClaimGlobalVerifyBudget.refilledAt >=
|
|
836
|
+
TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_REFILL_MS) {
|
|
837
|
+
this.topicRootCandidateClaimGlobalVerifyBudget = {
|
|
838
|
+
remaining: TOPIC_ROOT_CANDIDATE_CLAIM_GLOBAL_VERIFY_BURST,
|
|
839
|
+
refilledAt: now,
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
if (this.topicRootCandidateClaimGlobalVerifyBudget.remaining <= 0) {
|
|
843
|
+
return false;
|
|
844
|
+
}
|
|
845
|
+
let budget = this.topicRootCandidateClaimVerifyBudgets.get(peerHash);
|
|
846
|
+
if (!budget) {
|
|
847
|
+
if (this.topicRootCandidateClaimVerifyBudgets.size >=
|
|
848
|
+
TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_PEERS_MAX) {
|
|
849
|
+
const oldest = this.topicRootCandidateClaimVerifyBudgets.keys().next()
|
|
850
|
+
.value;
|
|
851
|
+
if (oldest)
|
|
852
|
+
this.topicRootCandidateClaimVerifyBudgets.delete(oldest);
|
|
853
|
+
}
|
|
854
|
+
budget = {
|
|
855
|
+
remaining: TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_BURST,
|
|
856
|
+
refilledAt: now,
|
|
857
|
+
};
|
|
858
|
+
this.topicRootCandidateClaimVerifyBudgets.set(peerHash, budget);
|
|
859
|
+
}
|
|
860
|
+
else if (now - budget.refilledAt >=
|
|
861
|
+
TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_REFILL_MS) {
|
|
862
|
+
budget.remaining = TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_BURST;
|
|
863
|
+
budget.refilledAt = now;
|
|
864
|
+
// Refresh insertion order so bounded eviction remains O(1) and LRU-like.
|
|
865
|
+
this.topicRootCandidateClaimVerifyBudgets.delete(peerHash);
|
|
866
|
+
this.topicRootCandidateClaimVerifyBudgets.set(peerHash, budget);
|
|
867
|
+
}
|
|
868
|
+
if (budget.remaining <= 0)
|
|
869
|
+
return false;
|
|
870
|
+
budget.remaining -= 1;
|
|
871
|
+
this.topicRootCandidateClaimGlobalVerifyBudget.remaining -= 1;
|
|
872
|
+
return true;
|
|
873
|
+
}
|
|
874
|
+
async importSignedTopicRootCandidateClaim(rawClaim, outerPeerHash) {
|
|
875
|
+
const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
|
|
876
|
+
if (rawClaim.byteLength === 0 ||
|
|
877
|
+
rawClaim.byteLength > TOPIC_ROOT_CANDIDATE_CLAIM_MAX_BYTES) {
|
|
878
|
+
return false;
|
|
879
|
+
}
|
|
880
|
+
let claim;
|
|
881
|
+
try {
|
|
882
|
+
claim = DataMessage.from(new Uint8ArrayList(rawClaim));
|
|
883
|
+
}
|
|
884
|
+
catch {
|
|
885
|
+
return false;
|
|
886
|
+
}
|
|
887
|
+
const canonicalBytes = toUint8Array(claim.bytes());
|
|
888
|
+
if (!bytesEqual(canonicalBytes, rawClaim))
|
|
889
|
+
return false;
|
|
890
|
+
if (!claim.data ||
|
|
891
|
+
!bytesEqual(claim.data, this.topicRootCandidateClaimData)) {
|
|
892
|
+
return false;
|
|
893
|
+
}
|
|
894
|
+
const signatures = claim.header.signatures?.signatures;
|
|
895
|
+
if (!signatures || signatures.length !== 1)
|
|
896
|
+
return false;
|
|
897
|
+
const signer = signatures[0]?.publicKey;
|
|
898
|
+
if (!signer)
|
|
899
|
+
return false;
|
|
900
|
+
const origin = signer.hashcode();
|
|
901
|
+
if (!isCanonicalTopicRootCandidate(origin))
|
|
902
|
+
return false;
|
|
903
|
+
if (origin === this.publicKeyHash)
|
|
904
|
+
return false;
|
|
905
|
+
const now = BigInt(Date.now());
|
|
906
|
+
const monotonicNow = performance.now();
|
|
907
|
+
const timestamp = claim.header.timestamp;
|
|
908
|
+
const expires = claim.header.expires;
|
|
909
|
+
const acceptanceDeadline = this.topicRootCandidateClaimAcceptanceDeadline(timestamp, expires, now, monotonicNow, TOPIC_ROOT_CANDIDATE_CLAIM_RECEIVER_MAX_LIFETIME_MS);
|
|
910
|
+
if (acceptanceDeadline === undefined) {
|
|
911
|
+
return false;
|
|
912
|
+
}
|
|
913
|
+
if (!this.canAdvanceTopicRootCandidateClaimReplayFloor(origin, timestamp)) {
|
|
914
|
+
return false;
|
|
915
|
+
}
|
|
916
|
+
this.rebuildAutoTopicRootCandidatesFromClaims(now);
|
|
917
|
+
if (!this.consumeTopicRootCandidateClaimVerifyBudget(outerPeerHash)) {
|
|
918
|
+
return false;
|
|
919
|
+
}
|
|
920
|
+
this.seedNativeWireVerification(claim, rawClaim);
|
|
921
|
+
let verified = false;
|
|
922
|
+
try {
|
|
923
|
+
verified = await claim.verify(true);
|
|
924
|
+
}
|
|
925
|
+
catch {
|
|
926
|
+
return false;
|
|
927
|
+
}
|
|
928
|
+
if (!verified)
|
|
929
|
+
return false;
|
|
930
|
+
if (lifecycleRevision !== this.topicControlPlaneLifecycleRevision ||
|
|
931
|
+
!this.isTopicRootCandidateClaimTimeValid(timestamp, expires, BigInt(Date.now())) ||
|
|
932
|
+
performance.now() >= acceptanceDeadline ||
|
|
933
|
+
!this.autoTopicRootCandidates ||
|
|
934
|
+
!this.started ||
|
|
935
|
+
this.stopping ||
|
|
936
|
+
this.topicControlPlaneStopping) {
|
|
937
|
+
return false;
|
|
938
|
+
}
|
|
939
|
+
if (!this.retainSignedTopicRootCandidateClaim(origin, {
|
|
940
|
+
bytes: rawClaim.slice(),
|
|
941
|
+
timestamp,
|
|
942
|
+
expires,
|
|
943
|
+
acceptUntil: acceptanceDeadline,
|
|
944
|
+
})) {
|
|
945
|
+
return false;
|
|
946
|
+
}
|
|
947
|
+
this.scheduleTopicRootCandidateClaimMaintenance();
|
|
948
|
+
return true;
|
|
949
|
+
}
|
|
950
|
+
async sendSignedTopicRootCandidateClaims(claims, targets, signal) {
|
|
951
|
+
if (claims.length === 0)
|
|
952
|
+
return;
|
|
953
|
+
const streams = (targets ?? [...this.peers.values()]).filter((stream) => stream.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1);
|
|
954
|
+
if (streams.length === 0)
|
|
955
|
+
return;
|
|
956
|
+
for (let offset = 0; offset < claims.length; offset += TOPIC_ROOT_CANDIDATE_CLAIMS_MAX) {
|
|
957
|
+
throwIfAborted(signal);
|
|
958
|
+
const message = new TopicRootCandidateClaims({
|
|
959
|
+
claims: claims.slice(offset, offset + TOPIC_ROOT_CANDIDATE_CLAIMS_MAX),
|
|
960
|
+
});
|
|
961
|
+
const embedded = await withAbort(this.createMessage(this.encodePubSubMessage(message), {
|
|
962
|
+
mode: new AnyWhere(),
|
|
963
|
+
priority: 1,
|
|
964
|
+
skipRecipientValidation: true,
|
|
965
|
+
}), signal);
|
|
966
|
+
throwIfAborted(signal);
|
|
967
|
+
await this.publishMessageMaybe(this.publicKey, embedded, streams, undefined, signal);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
async sendAutoTopicRootCandidates(streams) {
|
|
971
|
+
if (!this.started)
|
|
972
|
+
throw new NotStartedError();
|
|
973
|
+
const signedStreams = streams.filter((stream) => stream.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1);
|
|
974
|
+
if (signedStreams.length === 0)
|
|
975
|
+
return;
|
|
976
|
+
this.rebuildAutoTopicRootCandidatesFromClaims();
|
|
977
|
+
if (!this.localSignedTopicRootCandidateClaim) {
|
|
978
|
+
try {
|
|
979
|
+
await this.refreshLocalTopicRootCandidateClaim();
|
|
980
|
+
}
|
|
981
|
+
catch (error) {
|
|
982
|
+
// Failure to refresh the nested local claim must not suppress
|
|
983
|
+
// authenticated remote claims; outer-envelope signing is still tried.
|
|
984
|
+
logErrorIfStarted(error);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
const claims = [...this.signedTopicRootCandidateClaims.entries()]
|
|
988
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
989
|
+
.map(([, claim]) => claim.bytes);
|
|
990
|
+
await this.sendSignedTopicRootCandidateClaims(claims, signedStreams);
|
|
991
|
+
const localClaim = this.localSignedTopicRootCandidateClaim;
|
|
992
|
+
if (localClaim &&
|
|
993
|
+
!this.signedTopicRootCandidateClaims.has(this.publicKeyHash)) {
|
|
994
|
+
await this.sendSignedTopicRootCandidateClaims([localClaim.bytes], signedStreams);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
setAutoTopicRootCandidateSnapshot(next, options) {
|
|
553
998
|
if (!this.autoTopicRootCandidates ||
|
|
554
999
|
this.stopping ||
|
|
555
|
-
this.topicControlPlaneStopping)
|
|
1000
|
+
this.topicControlPlaneStopping) {
|
|
556
1001
|
return false;
|
|
557
|
-
|
|
1002
|
+
}
|
|
1003
|
+
if (this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured()) {
|
|
558
1004
|
return false;
|
|
1005
|
+
}
|
|
559
1006
|
const managed = this.autoTopicRootCandidateSet;
|
|
560
1007
|
if (!managed)
|
|
561
1008
|
return false;
|
|
1009
|
+
if (options?.immediate) {
|
|
1010
|
+
this.clearAutoTopicRootCandidateUpdateSchedule();
|
|
1011
|
+
const current = [...managed];
|
|
1012
|
+
if (sameCandidates(current, next))
|
|
1013
|
+
return false;
|
|
1014
|
+
this.scheduleAutoTopicRootCandidateUpdateCooldown();
|
|
1015
|
+
this.applyAutoTopicRootCandidates(next);
|
|
1016
|
+
return true;
|
|
1017
|
+
}
|
|
562
1018
|
const before = this.pendingAutoTopicRootCandidates ?? [...managed];
|
|
563
|
-
const next = this.normalizeAutoTopicRootCandidates([
|
|
564
|
-
...before,
|
|
565
|
-
...candidates,
|
|
566
|
-
]);
|
|
567
1019
|
if (sameCandidates(before, next))
|
|
568
1020
|
return false;
|
|
569
1021
|
if (this.autoTopicRootCandidateUpdateTimer) {
|
|
@@ -580,7 +1032,6 @@ export class TopicControlPlane extends DirectStream {
|
|
|
580
1032
|
this.shardRootCache.clear();
|
|
581
1033
|
this.scheduleReconcileShardOverlays();
|
|
582
1034
|
this.scheduleHostOwnedShardRoots();
|
|
583
|
-
this.scheduleAutoTopicRootCandidatesBroadcast();
|
|
584
1035
|
}
|
|
585
1036
|
scheduleAutoTopicRootCandidateUpdateCooldown() {
|
|
586
1037
|
if (this.autoTopicRootCandidateUpdateTimer)
|
|
@@ -906,6 +1357,11 @@ export class TopicControlPlane extends DirectStream {
|
|
|
906
1357
|
if (bytes[0] === 4) {
|
|
907
1358
|
assertTopicRootCandidatesFrame(bytes);
|
|
908
1359
|
}
|
|
1360
|
+
// Signed candidate claims are low-frequency opaque host-side frames. Keep
|
|
1361
|
+
// this explicit seam ahead of the native variants 0-7 codec.
|
|
1362
|
+
if (bytes[0] === 8) {
|
|
1363
|
+
return TopicRootCandidateClaims.from(bytes);
|
|
1364
|
+
}
|
|
909
1365
|
const native = this.nativeTopicControl;
|
|
910
1366
|
if (native) {
|
|
911
1367
|
const decoded = native.decodePubSubMessage(bytes);
|
|
@@ -1106,7 +1562,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1106
1562
|
let requestId = this.nextTopicRootQueryId >>> 0;
|
|
1107
1563
|
do {
|
|
1108
1564
|
requestId = requestId === 0 ? 1 : requestId;
|
|
1109
|
-
this.nextTopicRootQueryId = (
|
|
1565
|
+
this.nextTopicRootQueryId = (requestId + 1) >>> 0 || 1;
|
|
1110
1566
|
if (!this.pendingTopicRootQueries.has(requestId)) {
|
|
1111
1567
|
return requestId;
|
|
1112
1568
|
}
|
|
@@ -1764,7 +2220,9 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1764
2220
|
this.shardRefCounts.set(shardTopic, next);
|
|
1765
2221
|
}
|
|
1766
2222
|
// Best-effort: do not block callers on network I/O (can hang under teardown).
|
|
1767
|
-
void this.debounceUnsubscribeAggregator
|
|
2223
|
+
void this.debounceUnsubscribeAggregator
|
|
2224
|
+
.add({ key: topic })
|
|
2225
|
+
.catch(logErrorIfStarted);
|
|
1768
2226
|
return true;
|
|
1769
2227
|
}
|
|
1770
2228
|
async _announceUnsubscribe(topics) {
|
|
@@ -1893,9 +2351,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
1893
2351
|
}
|
|
1894
2352
|
}
|
|
1895
2353
|
onFanoutPeerUnreachable(ev) {
|
|
1896
|
-
void this
|
|
1897
|
-
.announcePeerUnavailableOnShard(ev.detail.publicKeyHash, ev.detail.topic)
|
|
1898
|
-
.catch(logErrorIfStarted);
|
|
2354
|
+
void this.announcePeerUnavailableOnShard(ev.detail.publicKeyHash, ev.detail.topic).catch(logErrorIfStarted);
|
|
1899
2355
|
}
|
|
1900
2356
|
getSubscribers(topic) {
|
|
1901
2357
|
const t = topic.toString();
|
|
@@ -2147,9 +2603,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
2147
2603
|
}))
|
|
2148
2604
|
.filter((batch) => batch.topics.length > 0);
|
|
2149
2605
|
if (batches.length > 0) {
|
|
2150
|
-
void this
|
|
2151
|
-
.announcePeerUnavailable(publicKeyHash, batches)
|
|
2152
|
-
.catch(logErrorIfStarted);
|
|
2606
|
+
void this.announcePeerUnavailable(publicKeyHash, batches).catch(logErrorIfStarted);
|
|
2153
2607
|
}
|
|
2154
2608
|
}
|
|
2155
2609
|
collectSubscriptionState(peerHash) {
|
|
@@ -2266,9 +2720,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
2266
2720
|
if (!this.lastSubscriptionMessages.has(subscriberKey)) {
|
|
2267
2721
|
this.lastSubscriptionMessages.set(subscriberKey, new Map());
|
|
2268
2722
|
}
|
|
2269
|
-
this.lastSubscriptionMessages
|
|
2270
|
-
.get(subscriberKey)
|
|
2271
|
-
.set(topic, {
|
|
2723
|
+
this.lastSubscriptionMessages.get(subscriberKey).set(topic, {
|
|
2272
2724
|
session,
|
|
2273
2725
|
timestamp,
|
|
2274
2726
|
});
|
|
@@ -2378,6 +2830,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
2378
2830
|
async processDirectPubSubMessage(input) {
|
|
2379
2831
|
const { pubsubMessage, message, from, stream } = input;
|
|
2380
2832
|
const isDirectRootControl = pubsubMessage instanceof TopicRootCandidates ||
|
|
2833
|
+
pubsubMessage instanceof TopicRootCandidateClaims ||
|
|
2381
2834
|
pubsubMessage instanceof TopicRootQuery ||
|
|
2382
2835
|
pubsubMessage instanceof TopicRootQueryResponse;
|
|
2383
2836
|
const directRootSigner = isDirectRootControl
|
|
@@ -2388,8 +2841,31 @@ export class TopicControlPlane extends DirectStream {
|
|
|
2388
2841
|
return;
|
|
2389
2842
|
}
|
|
2390
2843
|
if (pubsubMessage instanceof TopicRootCandidates) {
|
|
2391
|
-
|
|
2392
|
-
|
|
2844
|
+
if (stream.protocol !== TOPIC_CONTROL_PLANE_PROTOCOL_V2_0) {
|
|
2845
|
+
return;
|
|
2846
|
+
}
|
|
2847
|
+
// `addPeer()` already admitted the authenticated direct stream identity.
|
|
2848
|
+
// The advertised list is intentionally ignored.
|
|
2849
|
+
return;
|
|
2850
|
+
}
|
|
2851
|
+
if (pubsubMessage instanceof TopicRootCandidateClaims) {
|
|
2852
|
+
if (stream.protocol !== TOPIC_CONTROL_PLANE_PROTOCOL_V2_1 ||
|
|
2853
|
+
!this.autoTopicRootCandidates) {
|
|
2854
|
+
return;
|
|
2855
|
+
}
|
|
2856
|
+
const imported = [];
|
|
2857
|
+
const outerPeerHash = stream.publicKey.hashcode();
|
|
2858
|
+
for (const claim of pubsubMessage.claims) {
|
|
2859
|
+
if (await this.importSignedTopicRootCandidateClaim(claim, outerPeerHash)) {
|
|
2860
|
+
imported.push(claim);
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
2863
|
+
if (imported.length > 0) {
|
|
2864
|
+
this.rebuildAutoTopicRootCandidatesFromClaims();
|
|
2865
|
+
// Relay the exact nested claims; no relay signature is substituted for
|
|
2866
|
+
// their origin signatures.
|
|
2867
|
+
void this.sendSignedTopicRootCandidateClaims(imported, [...this.peers.values()].filter((peer) => peer.publicKey.hashcode() !== outerPeerHash)).catch(() => { });
|
|
2868
|
+
}
|
|
2393
2869
|
return;
|
|
2394
2870
|
}
|
|
2395
2871
|
if (pubsubMessage instanceof TopicRootQuery) {
|
|
@@ -2642,6 +3118,7 @@ export class TopicControlPlane extends DirectStream {
|
|
|
2642
3118
|
// messages used for topic-root discovery and direct subscriber snapshots.
|
|
2643
3119
|
if (!(pubsubMessage instanceof PubSubData) &&
|
|
2644
3120
|
!(pubsubMessage instanceof TopicRootCandidates) &&
|
|
3121
|
+
!(pubsubMessage instanceof TopicRootCandidateClaims) &&
|
|
2645
3122
|
!(pubsubMessage instanceof TopicRootQuery) &&
|
|
2646
3123
|
!(pubsubMessage instanceof GetSubscribers) &&
|
|
2647
3124
|
!(pubsubMessage instanceof Subscribe) &&
|