@peerbit/shared-log 16.0.9 → 16.0.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/index.js CHANGED
@@ -405,6 +405,8 @@ const RECALCULATE_PARTICIPATION_MIN_RELATIVE_CHANGE_WITH_CPU_LIMIT = 0.005;
405
405
  const RECALCULATE_PARTICIPATION_MIN_RELATIVE_CHANGE_WITH_MEMORY_LIMIT = 0.001;
406
406
  const RECALCULATE_PARTICIPATION_RELATIVE_DENOMINATOR_FLOOR = 1e-3;
407
407
  const TOPIC_SUBSCRIBERS_CACHE_TTL_MS = 250;
408
+ const LOCAL_REACHABLE_PEERS_CACHE_TTL_MS = 250;
409
+ const LOCAL_REACHABLE_PEERS_MAX = 64;
408
410
  const LEADER_SELECTION_CONTEXT_CACHE_TTL_MS = 50;
409
411
  // Leader-plan results are invalidated structurally (replication:change,
410
412
  // replicator:mature, subscriber changes). The TTL bounds wall-clock maturity
@@ -427,6 +429,8 @@ const ADAPTIVE_REBALANCE_MIN_IDLE_AFTER_LOCAL_APPEND_MS = 10_000;
427
429
  // raw exchange message size the receive path is tuned for.
428
430
  const LIVE_RAW_GOSSIP_MAX_ENTRIES = 256;
429
431
  const LIVE_RAW_GOSSIP_MAX_BYTES = 128 * 1024;
432
+ const GID_PEER_HISTORY_CLEANUP_BATCH_SIZE = 256;
433
+ const GID_PEER_HISTORY_CLEANUP_PENDING_CAPACITY = 4096;
430
434
  const DEFAULT_DISTRIBUTION_DEBOUNCE_TIME = 500;
431
435
  const RECENT_REPAIR_DISPATCH_TTL_MS = 5_000;
432
436
  const REPAIR_SWEEP_ENTRY_BATCH_SIZE = 1_000;
@@ -678,17 +682,19 @@ let SharedLog = (() => {
678
682
  // GROWTH SHAPE. Rows are released by `deleteGidPeerHistory` on the two prune
679
683
  // paths, by `removePeerFromGidPeerHistory` once a gid's last peer drops (the
680
684
  // routine disconnect outcome), by `rebalanceAll({ clearCache: true })`, and
681
- // wholesale on close/reset. Nothing on the TRIM path releases a row, so a
682
- // node that bounds its log with trim rather than prune accumulates one row
683
- // per distinct gid it has ever held. A gid names a graph, not an entry: an
685
+ // wholesale on close/reset. Trim now carries compact distinct removed-gid
686
+ // facts beside its hash-only result. After the lower mutation commits, a
687
+ // coalesced cleanup checks authoritative local-head liveness and drops a row
688
+ // only when no sibling remains. A gid names a graph, not an entry: an
684
689
  // entry with `meta.next` inherits `min(next.meta.gid)` (see
685
690
  // packages/log/src/entry-v0.ts), so document updates fold into the gid of
686
691
  // the first put and the row count tracks distinct chain roots -- distinct
687
692
  // document ids -- rather than entry count. Insert-only workloads mint a
688
- // fresh gid per append and so do grow one row per entry. Merges are a
689
- // smaller second source: when a join links two graphs the losing entries
690
- // keep their own `meta.gid` on disk, and shared-log does not subscribe to
691
- // the log's `onGidRemoved`, so the shadowed gid's row lingers too.
693
+ // fresh gid per append and so do grow one row per entry. Merge-shadowed gids
694
+ // are a separate, smaller source that still needs a fast-path-compatible
695
+ // post-commit metadata seam. Registering the lower log's `onGidRemoved` hook
696
+ // here would disable native prepared, commit-only, and batch append paths, so
697
+ // this bounded slice deliberately addresses trim only.
692
698
  //
693
699
  // WHY TRIM DOES NOT SIMPLY CALL `deleteGidPeerHistory` AS WELL. Both prune
694
700
  // callers delete a whole row from a single entry's gid, and under the
@@ -706,10 +712,11 @@ let SharedLog = (() => {
706
712
  // on the common path, paying for the freed memory in repeated re-delivery of
707
713
  // entries that are still here. That trade is not worth it.
708
714
  //
709
- // Bounding this correctly requires a per-gid count of locally held entries,
710
- // dropping the row only when it reaches zero -- a real reverse index, not a
711
- // one-line delete. Deliberately not built: the growth is bounded by distinct
712
- // gids, and the cheap version is a bandwidth regression.
715
+ // Cleanup work is coalesced, checked in bounded batches, and capped at 4096
716
+ // pending gids. Saturation forgets the overflowing suppression row
717
+ // immediately. That bounded fallback can cause redundant delivery for a live
718
+ // sibling, but missing history is deliberately correctness-safe as described
719
+ // above; it cannot affect ownership, quorum, or retained data.
713
720
  //
714
721
  // Existing, deliberate imprecision: under the time domain the coordinate is
715
722
  // `meta.clock.timestamp.wallTime` (replication-domain-time.ts) and is
@@ -717,6 +724,7 @@ let SharedLog = (() => {
717
724
  // and prune's whole-row delete is already over-eager there. The cost is the
718
725
  // same bounded extra traffic, never a wrong prune.
719
726
  _gidPeersHistory;
727
+ _gidPeerHistoryCleanupState;
720
728
  _onSubscriptionFn;
721
729
  _onUnsubscriptionFn;
722
730
  _subscriptionChangeCallbacks;
@@ -1729,6 +1737,7 @@ let SharedLog = (() => {
1729
1737
  _appendBackfillPendingByTarget;
1730
1738
  _repairMetrics;
1731
1739
  _topicSubscribersCache;
1740
+ _localReachablePeerHashesCache;
1732
1741
  _leaderSelectionContextCache;
1733
1742
  _leaderPlanCache;
1734
1743
  // Sync capability bits advertised by peers (SyncCapabilitiesMessage), keyed
@@ -1960,6 +1969,7 @@ let SharedLog = (() => {
1960
1969
  this._receiveHandlerDrainByPeer = new Map();
1961
1970
  this._openingSyncCapabilitiesByPeer = new Map();
1962
1971
  this._gidPeersHistory = new Map();
1972
+ this.resetGidPeerHistoryCleanupState();
1963
1973
  this._repairRetryTimers = new Set();
1964
1974
  this._recentRepairDispatch = new Map();
1965
1975
  this._repairSweepRunning = false;
@@ -1979,6 +1989,7 @@ let SharedLog = (() => {
1979
1989
  this._joinAuthoritativeRepairPeersByDelay = new Map();
1980
1990
  this._appendBackfillPendingByTarget = new Map();
1981
1991
  this._topicSubscribersCache = new Map();
1992
+ this._localReachablePeerHashesCache = new Map();
1982
1993
  this._peerSyncCapabilities = new Map();
1983
1994
  this._peerSyncCapabilitySessions = new Map();
1984
1995
  this._peerSyncCapabilityTimestamps = new Map();
@@ -3045,6 +3056,88 @@ let SharedLog = (() => {
3045
3056
  return (fanoutService?.getPublicKey?.(hash) ??
3046
3057
  this.node.services.pubsub.getPublicKey(hash));
3047
3058
  }
3059
+ async _getLocalReachablePeerHashes(topic) {
3060
+ const cache = (this._localReachablePeerHashesCache ??= new Map());
3061
+ const cached = cache.get(topic);
3062
+ if (cached?.hashes && cached.expiresAt > Date.now()) {
3063
+ return cached.hashes.slice();
3064
+ }
3065
+ if (cached?.inFlight) {
3066
+ return (await cached.inFlight).slice();
3067
+ }
3068
+ const entry = { expiresAt: 0 };
3069
+ const inFlight = (async () => {
3070
+ const selfHash = this.node.identity.publicKey.hashcode();
3071
+ const hashes = [];
3072
+ const seen = new Set();
3073
+ const addHash = (hash) => {
3074
+ if (typeof hash !== "string" ||
3075
+ hash.length === 0 ||
3076
+ hash === selfHash ||
3077
+ seen.has(hash) ||
3078
+ hashes.length >= LOCAL_REACHABLE_PEERS_MAX) {
3079
+ return;
3080
+ }
3081
+ seen.add(hash);
3082
+ hashes.push(hash);
3083
+ };
3084
+ const pubsub = this.node.services.pubsub;
3085
+ const peerMap = pubsub?.peers;
3086
+ if (peerMap?.entries) {
3087
+ for (const [hash, peer] of peerMap.entries()) {
3088
+ addHash(peer?.publicKey?.hashcode?.());
3089
+ addHash(hash);
3090
+ if (hashes.length >= LOCAL_REACHABLE_PEERS_MAX)
3091
+ break;
3092
+ }
3093
+ }
3094
+ if (hashes.length < LOCAL_REACHABLE_PEERS_MAX) {
3095
+ const connectionManager = pubsub?.components?.connectionManager;
3096
+ for (const conn of connectionManager?.getConnections?.() ?? []) {
3097
+ const peerId = conn?.remotePeer;
3098
+ if (!peerId)
3099
+ continue;
3100
+ try {
3101
+ addHash(getPublicKeyFromPeerId(peerId).hashcode());
3102
+ }
3103
+ catch {
3104
+ // Best-effort only.
3105
+ }
3106
+ if (hashes.length >= LOCAL_REACHABLE_PEERS_MAX)
3107
+ break;
3108
+ }
3109
+ }
3110
+ if (hashes.length < LOCAL_REACHABLE_PEERS_MAX) {
3111
+ try {
3112
+ for (const subscriber of (await pubsub.getSubscribers(topic)) ?? []) {
3113
+ addHash(subscriber?.hashcode?.());
3114
+ if (hashes.length >= LOCAL_REACHABLE_PEERS_MAX)
3115
+ break;
3116
+ }
3117
+ }
3118
+ catch {
3119
+ // Local reachability is best-effort.
3120
+ }
3121
+ }
3122
+ return hashes;
3123
+ })();
3124
+ entry.inFlight = inFlight;
3125
+ cache.set(topic, entry);
3126
+ try {
3127
+ const hashes = await inFlight;
3128
+ if (cache.get(topic) === entry) {
3129
+ entry.hashes = hashes;
3130
+ entry.expiresAt = Date.now() + LOCAL_REACHABLE_PEERS_CACHE_TTL_MS;
3131
+ entry.inFlight = undefined;
3132
+ }
3133
+ return hashes.slice();
3134
+ }
3135
+ catch (error) {
3136
+ if (cache.get(topic) === entry)
3137
+ cache.delete(topic);
3138
+ throw error;
3139
+ }
3140
+ }
3048
3141
  async _getTopicSubscribers(topic) {
3049
3142
  const cached = this._topicSubscribersCache.get(topic);
3050
3143
  if (cached && cached.expiresAt > Date.now()) {
@@ -3158,6 +3251,7 @@ let SharedLog = (() => {
3158
3251
  if (!topic)
3159
3252
  continue;
3160
3253
  this._topicSubscribersCache.delete(topic);
3254
+ this._localReachablePeerHashesCache?.delete(topic);
3161
3255
  }
3162
3256
  this.invalidateLeaderSelectionContextCache();
3163
3257
  }
@@ -6728,6 +6822,8 @@ let SharedLog = (() => {
6728
6822
  return {
6729
6823
  entry: result.entry,
6730
6824
  removed: result.removed,
6825
+ removedHashes: result.removedHashes,
6826
+ removedGids: result.removedGids,
6731
6827
  appendCommit: nativePreparedCommit,
6732
6828
  };
6733
6829
  }
@@ -6777,6 +6873,8 @@ let SharedLog = (() => {
6777
6873
  return {
6778
6874
  entry: result.entry,
6779
6875
  removed: result.removed,
6876
+ removedHashes: result.removedHashes,
6877
+ removedGids: result.removedGids,
6780
6878
  appendCommit: this.createPreparedLocalAppendCommitFromFacts(result.appendFacts, nativeAppendPlan),
6781
6879
  };
6782
6880
  }
@@ -6792,6 +6890,7 @@ let SharedLog = (() => {
6792
6890
  return undefined;
6793
6891
  }
6794
6892
  const plannedCoordinateDeleteHashes = result.change?.removed.map((entry) => entry.hash) ??
6893
+ result.removedHashes ??
6795
6894
  result.removed.map((entry) => entry.hash);
6796
6895
  const nativeAppendPlan = await this.planNativeLocalAppendFacts(result.appendFacts, properties.minReplicasValue, {
6797
6896
  deleteHashes: plannedCoordinateDeleteHashes.length > 0
@@ -6811,6 +6910,8 @@ let SharedLog = (() => {
6811
6910
  })
6812
6911
  : this.applyPreparedAppendFactsWithDeferredCoordinateDeletes(result.appendFacts, result.removed, () => this.materializePreparedAppendResultEntry(result), {
6813
6912
  forgetNativeCoordinates: !nativeAppendPlan.committedNativeCoordinateDeletes,
6913
+ removedHashes: result.removedHashes,
6914
+ removedGids: result.removedGids,
6814
6915
  ownershipLifecycleController,
6815
6916
  });
6816
6917
  await this._coordinates.persistPreparedCoordinate({
@@ -7286,6 +7387,7 @@ let SharedLog = (() => {
7286
7387
  },
7287
7388
  removed: prepared.removed,
7288
7389
  removedHashes: prepared.removedHashes,
7390
+ removedGids: prepared.removedGids,
7289
7391
  appendCommit,
7290
7392
  };
7291
7393
  };
@@ -7308,7 +7410,10 @@ let SharedLog = (() => {
7308
7410
  // `rollback` can no longer fire. Nothing downstream rolls back
7309
7411
  // (the retire below only warns), so the token is terminal here.
7310
7412
  this._coordinates.settleResidentCoordinateSnapshot(lowerPublicationRollback?.coordinateEntries);
7311
- this.applyPreparedAppendFactsWithDeferredCoordinateDeletes(prepared.appendFacts, prepared.removed, prepared.materializeEntry, { removedHashes: prepared.removedHashes });
7413
+ this.applyPreparedAppendFactsWithDeferredCoordinateDeletes(prepared.appendFacts, prepared.removed, prepared.materializeEntry, {
7414
+ removedHashes: prepared.removedHashes,
7415
+ removedGids: prepared.removedGids,
7416
+ });
7312
7417
  try {
7313
7418
  await this.completeNativeStrictDurableTransaction(nativeStrictTransaction);
7314
7419
  }
@@ -7451,6 +7556,10 @@ let SharedLog = (() => {
7451
7556
  const useTrimmedHashesOnly = properties?.resolveTrimmedEntries === false;
7452
7557
  const nativeTrimmedHashes = backboneAppend.trimmedHashes ??
7453
7558
  backboneAppend.trimmed.map((entry) => entry.hash);
7559
+ const nativeTrimmedGids = backboneAppend.trimmedGids ??
7560
+ (backboneAppend.trimmed.length > 0
7561
+ ? backboneAppend.trimmed.map((entry) => entry.gid)
7562
+ : undefined);
7454
7563
  const committedHash = backboneAppend.entry.cid ?? backboneAppend.entry.hash;
7455
7564
  const committedNext = backboneAppend.entry.next ?? next;
7456
7565
  nativeCoordinateRollback =
@@ -7511,6 +7620,9 @@ let SharedLog = (() => {
7511
7620
  trimmedEntryHashes: useTrimmedHashesOnly
7512
7621
  ? backboneAppend.trimmedHashes
7513
7622
  : undefined,
7623
+ trimmedEntryGids: useTrimmedHashesOnly
7624
+ ? nativeTrimmedGids
7625
+ : undefined,
7514
7626
  nativeBlocksDeleted: commitBlocksInBackbone,
7515
7627
  nativeDeleteCleanupToken,
7516
7628
  documentPreviousContext: backboneAppend.documentPreviousContext,
@@ -7659,6 +7771,7 @@ let SharedLog = (() => {
7659
7771
  },
7660
7772
  removed: prepared.removed,
7661
7773
  removedHashes: prepared.removedHashes,
7774
+ removedGids: prepared.removedGids,
7662
7775
  appendCommit,
7663
7776
  };
7664
7777
  };
@@ -7751,6 +7864,7 @@ let SharedLog = (() => {
7751
7864
  this.applyPreparedAppendFactsWithDeferredCoordinateDeletes(prepared.appendFacts, prepared.removed, prepared.materializeEntry, {
7752
7865
  forgetNativeCoordinates: false,
7753
7866
  removedHashes: prepared.removedHashes,
7867
+ removedGids: prepared.removedGids,
7754
7868
  });
7755
7869
  try {
7756
7870
  await this.completeNativeStrictDurableTransaction(nativeStrictTransaction);
@@ -7789,6 +7903,7 @@ let SharedLog = (() => {
7789
7903
  if (this.shouldDeferHeadCoordinatePersistence(options)) {
7790
7904
  const deferredCoordinateDeleteHashes = this.applyPreparedAppendFactsWithDeferredCoordinateDeletes(result.appendFacts, result.removed, result.materializeEntry, {
7791
7905
  removedHashes: result.removedHashes,
7906
+ removedGids: result.removedGids,
7792
7907
  ownershipLifecycleController,
7793
7908
  });
7794
7909
  const deleteHashes = deferredCoordinateDeleteHashes &&
@@ -7809,6 +7924,7 @@ let SharedLog = (() => {
7809
7924
  },
7810
7925
  removed: result.removed,
7811
7926
  removedHashes: result.removedHashes,
7927
+ removedGids: result.removedGids,
7812
7928
  appendCommit: this.createPreparedLocalAppendCommitFromFacts(result.appendFacts),
7813
7929
  };
7814
7930
  });
@@ -7819,6 +7935,7 @@ let SharedLog = (() => {
7819
7935
  },
7820
7936
  removed: result.removed,
7821
7937
  removedHashes: result.removedHashes,
7938
+ removedGids: result.removedGids,
7822
7939
  appendCommit: this.createPreparedLocalAppendCommitFromFacts(result.appendFacts),
7823
7940
  };
7824
7941
  }
@@ -7867,6 +7984,7 @@ let SharedLog = (() => {
7867
7984
  },
7868
7985
  removed: result.removed,
7869
7986
  removedHashes: result.removedHashes,
7987
+ removedGids: result.removedGids,
7870
7988
  appendCommit: nativePreparedCommit,
7871
7989
  };
7872
7990
  }
@@ -7894,6 +8012,7 @@ let SharedLog = (() => {
7894
8012
  : this.applyPreparedAppendFactsWithDeferredCoordinateDeletes(result.appendFacts, result.removed, () => this.materializePreparedAppendResultEntry(result), {
7895
8013
  forgetNativeCoordinates: !nativeAppendPlan.committedNativeCoordinateDeletes,
7896
8014
  removedHashes: result.removedHashes,
8015
+ removedGids: result.removedGids,
7897
8016
  ownershipLifecycleController,
7898
8017
  });
7899
8018
  await this._coordinates.persistPreparedCoordinateNativeTransaction({
@@ -7949,6 +8068,8 @@ let SharedLog = (() => {
7949
8068
  return result.entry;
7950
8069
  },
7951
8070
  removed: result.removed,
8071
+ removedHashes: result.removedHashes,
8072
+ removedGids: result.removedGids,
7952
8073
  appendCommit: nativePreparedCommit,
7953
8074
  };
7954
8075
  }
@@ -7958,6 +8079,7 @@ let SharedLog = (() => {
7958
8079
  deferredCoordinateDeleteHashes =
7959
8080
  this.applyPreparedAppendFactsWithDeferredCoordinateDeletes(result.appendFacts, result.removed, result.materializeEntry, {
7960
8081
  removedHashes: result.removedHashes,
8082
+ removedGids: result.removedGids,
7961
8083
  ownershipLifecycleController,
7962
8084
  });
7963
8085
  nativeAppendPlan = await this.planNativeLocalAppendFacts(result.appendFacts, minReplicasValue, undefined, ownershipLifecycleController);
@@ -8000,6 +8122,7 @@ let SharedLog = (() => {
8000
8122
  },
8001
8123
  removed: result.removed,
8002
8124
  removedHashes: result.removedHashes,
8125
+ removedGids: result.removedGids,
8003
8126
  appendCommit: this.createPreparedLocalAppendCommitFromFacts(result.appendFacts, nativeAppendPlan),
8004
8127
  };
8005
8128
  }
@@ -8198,6 +8321,10 @@ let SharedLog = (() => {
8198
8321
  getBytes: (hash) => backbone.blocks.get(hash),
8199
8322
  nativeIndexMutationLockOwner: nativeStrictTransaction?.lowerHashMutationLockOwner,
8200
8323
  trimmedEntryHashes: append.trimmedHashes,
8324
+ trimmedEntryGids: append.trimmedGids ??
8325
+ (append.trimmed.length > 0
8326
+ ? append.trimmed.map((entry) => entry.gid)
8327
+ : undefined),
8201
8328
  nativeBlocksDeleted: true,
8202
8329
  nativeDeleteCleanupToken,
8203
8330
  documentTrimmedHeadsProcessed: append.documentTrimmedHeadsProcessed,
@@ -8428,6 +8555,10 @@ let SharedLog = (() => {
8428
8555
  this.applyPreparedAppendFactsWithDeferredCoordinateDeletes(facts, [], appended.materializeEntries[i], {
8429
8556
  forgetNativeCoordinates: false,
8430
8557
  removedHashes: plannedCoordinateDeleteHashes,
8558
+ removedGids: backboneAppend.trimmedGids ??
8559
+ (backboneAppend.trimmed.length > 0
8560
+ ? backboneAppend.trimmed.map((entry) => entry.gid)
8561
+ : undefined),
8431
8562
  });
8432
8563
  if (!runtimeOnlyCoordinates && this.remoteBlocks.hasNotifyStoredHook()) {
8433
8564
  this.remoteBlocks.notifyStoredDeferred(facts.hash);
@@ -9362,6 +9493,7 @@ let SharedLog = (() => {
9362
9493
  this._appendBackfillPendingByTarget = new Map();
9363
9494
  this._repairMetrics = createRepairMetrics();
9364
9495
  this._topicSubscribersCache = new Map();
9496
+ this._localReachablePeerHashesCache = new Map();
9365
9497
  this._leaderSelectionContextCache = undefined;
9366
9498
  this._leaderPlanCache = new LeaderPlanCache({
9367
9499
  max: LEADER_PLAN_CACHE_MAX,
@@ -9515,27 +9647,57 @@ let SharedLog = (() => {
9515
9647
  // compatible eager path with bounded validation and storage budgets.
9516
9648
  eagerBlocks: options?.eagerBlocks ?? false,
9517
9649
  resolveProviders: async (cid, opts) => {
9518
- // 1) tracker-backed provider directory (best-effort, bounded)
9650
+ const maxPeers = 8;
9651
+ const localCandidates = (await this.resolveCandidatePeersForHash(cid, {
9652
+ signal: opts?.signal,
9653
+ maxPeers,
9654
+ })) ?? [];
9655
+ if (opts?.signal?.aborted)
9656
+ return [];
9657
+ const locallyReachable = new Set(await this._getLocalReachablePeerHashes(this.topic));
9658
+ const confirmed = this._checkedPrune.getConfirmedReplicators(cid);
9659
+ const contacted = this._checkedPrune.getContactedReplicators(cid);
9660
+ const hasLiveCandidate = localCandidates.some((peer) => locallyReachable.has(peer) &&
9661
+ (confirmed?.has(peer) ||
9662
+ contacted?.has(peer) ||
9663
+ this.uniqueReplicators.has(peer)));
9664
+ // Only reachability corroborated by provider/replicator evidence may
9665
+ // bypass the initial CID lookup. Arbitrary bootstrap connections are
9666
+ // useful fallbacks, but are not evidence that they hold this block.
9667
+ if (hasLiveCandidate && !opts?.refresh) {
9668
+ return localCandidates;
9669
+ }
9670
+ let directoryProviders = [];
9519
9671
  try {
9520
- const providers = await fanoutService?.queryProviders(blockProviderNamespace(cid), {
9521
- want: 8,
9522
- timeoutMs: 2_000,
9523
- queryTimeoutMs: 500,
9524
- bootstrapMaxPeers: 2,
9525
- signal: opts?.signal,
9526
- });
9527
- if (providers && providers.length > 0)
9528
- return providers;
9672
+ directoryProviders =
9673
+ (await fanoutService?.queryProviders(blockProviderNamespace(cid), {
9674
+ want: maxPeers,
9675
+ timeoutMs: 2_000,
9676
+ queryTimeoutMs: 500,
9677
+ bootstrapMaxPeers: 2,
9678
+ signal: opts?.signal,
9679
+ })) ?? [];
9529
9680
  }
9530
9681
  catch {
9531
- // ignore discovery failures
9682
+ // Ignore discovery failures; local evidence remains usable.
9683
+ }
9684
+ const selected = [];
9685
+ const selectedSet = new Set();
9686
+ const add = (peer) => {
9687
+ if (!peer ||
9688
+ selectedSet.has(peer) ||
9689
+ selected.length >= maxPeers) {
9690
+ return;
9691
+ }
9692
+ selectedSet.add(peer);
9693
+ selected.push(peer);
9694
+ };
9695
+ for (let index = 0; selected.length < maxPeers &&
9696
+ (index < localCandidates.length || index < directoryProviders.length); index++) {
9697
+ add(localCandidates[index]);
9698
+ add(directoryProviders[index]);
9532
9699
  }
9533
- // 2) reuse the same per-hash / replicator / subscriber fallback used by
9534
- // entry loads so block retries can widen beyond stale explicit hints.
9535
- return ((await this.resolveCandidatePeersForHash(cid, {
9536
- signal: opts?.signal,
9537
- maxPeers: 8,
9538
- })) ?? []);
9700
+ return selected;
9539
9701
  },
9540
9702
  watchProviders: fanoutService
9541
9703
  ? (cid, opts) => fanoutService.watchProviders(blockProviderNamespace(cid), {
@@ -9572,6 +9734,7 @@ let SharedLog = (() => {
9572
9734
  ],
9573
9735
  })) > 0;
9574
9736
  this._gidPeersHistory = new Map();
9737
+ this.resetGidPeerHistoryCleanupState();
9575
9738
  const replicationChangeOwnershipLifecycleController = this.captureReplicationOwnershipLifecycle();
9576
9739
  let replicationChangeDebounceFn;
9577
9740
  replicationChangeDebounceFn = debounceAggregationChanges(async (change) => {
@@ -10566,70 +10729,82 @@ let SharedLog = (() => {
10566
10729
  if (options?.signal?.aborted)
10567
10730
  return undefined;
10568
10731
  const maxPeers = options?.maxPeers ?? 8;
10732
+ if (maxPeers <= 0)
10733
+ return undefined;
10569
10734
  const self = this.node.identity.publicKey.hashcode();
10570
10735
  const seed = hashToSeed32(hash);
10571
- const hinted = this._checkedPrune.getConfirmedReplicators(hash);
10572
- if (hinted && hinted.size > 0) {
10573
- const peers = [...hinted].filter((p) => p !== self);
10574
- return peers.length > 0
10575
- ? pickDeterministicSubset(peers, seed, maxPeers)
10576
- : undefined;
10577
- }
10736
+ // Replication/provider knowledge deliberately outlives a transport session.
10737
+ // That is useful for repair, but it must not fill the bounded fetch target set
10738
+ // with departed peers while a reachable holder is available. The local
10739
+ // snapshot performs no tracker/provider discovery or key resolution.
10740
+ const reachable = await this._getLocalReachablePeerHashes(this.topic);
10741
+ if (options?.signal?.aborted)
10742
+ return undefined;
10743
+ const reachableSet = new Set(reachable);
10744
+ const confirmed = this._checkedPrune.getConfirmedReplicators(hash);
10578
10745
  const contacted = this._checkedPrune.getContactedReplicators(hash);
10579
- if (contacted && contacted.size > 0) {
10580
- const peers = [...contacted].filter((p) => p !== self);
10581
- return peers.length > 0
10582
- ? pickDeterministicSubset(peers, seed, maxPeers)
10583
- : undefined;
10584
- }
10585
- let candidates;
10586
- const replicatorCandidates = [...this.uniqueReplicators].filter((p) => p !== self);
10587
- if (replicatorCandidates.length > 0) {
10588
- candidates = replicatorCandidates;
10746
+ const evidenceTiers = [
10747
+ confirmed ?? [],
10748
+ contacted ?? [],
10749
+ this.uniqueReplicators,
10750
+ ];
10751
+ // Peers supported by both current transport state and provider history are
10752
+ // the strongest signal. Afterwards alternate live-only and historical-only
10753
+ // evidence so either source can widen a saturated bounded set. Derive the
10754
+ // live intersection from the bounded reachability snapshot; never clone or
10755
+ // scan an unbounded historical replicator set per missing block.
10756
+ const liveEvidenceTiers = [[], [], []];
10757
+ for (const peer of reachable) {
10758
+ if (confirmed?.has(peer))
10759
+ liveEvidenceTiers[0].push(peer);
10760
+ else if (contacted?.has(peer))
10761
+ liveEvidenceTiers[1].push(peer);
10762
+ else if (this.uniqueReplicators.has(peer))
10763
+ liveEvidenceTiers[2].push(peer);
10764
+ }
10765
+ const liveKnown = [];
10766
+ for (let tier = 0; tier < liveEvidenceTiers.length; tier++) {
10767
+ liveKnown.push(...pickDeterministicSubset(liveEvidenceTiers[tier], (seed ^ Math.imul(tier + 1, 0x9e3779b1)) >>> 0, maxPeers - liveKnown.length));
10768
+ if (liveKnown.length >= maxPeers)
10769
+ break;
10589
10770
  }
10590
- else {
10591
- try {
10592
- const subscribers = await this._getTopicSubscribers(this.topic);
10593
- const subscriberCandidates = subscribers?.map((k) => k.hashcode()).filter((p) => p !== self) ?? [];
10594
- candidates =
10595
- subscriberCandidates.length > 0 ? subscriberCandidates : undefined;
10596
- }
10597
- catch {
10598
- // Best-effort only.
10599
- }
10600
- if (!candidates || candidates.length === 0) {
10601
- const peerMap = this.node.services.pubsub?.peers;
10602
- if (peerMap?.keys) {
10603
- candidates = [...peerMap.keys()];
10771
+ const liveKnownSet = new Set(liveKnown);
10772
+ const liveOnly = pickDeterministicSubset(reachable.filter((peer) => !liveKnownSet.has(peer)), (seed ^ 0x85ebca6b) >>> 0, maxPeers);
10773
+ const historicalOnly = [];
10774
+ const historicalSeen = new Set();
10775
+ for (let tier = 0; tier < evidenceTiers.length; tier++) {
10776
+ const available = [];
10777
+ let inspected = 0;
10778
+ for (const peer of evidenceTiers[tier]) {
10779
+ if (inspected++ >= LOCAL_REACHABLE_PEERS_MAX)
10780
+ break;
10781
+ if (!peer ||
10782
+ peer === self ||
10783
+ reachableSet.has(peer) ||
10784
+ historicalSeen.has(peer)) {
10785
+ continue;
10604
10786
  }
10787
+ historicalSeen.add(peer);
10788
+ available.push(peer);
10605
10789
  }
10606
- if (!candidates || candidates.length === 0) {
10607
- const connectionManager = this.node.services.pubsub?.components
10608
- ?.connectionManager;
10609
- const connections = connectionManager?.getConnections?.() ?? [];
10610
- const connectionHashes = [];
10611
- for (const conn of connections) {
10612
- const peerId = conn?.remotePeer;
10613
- if (!peerId)
10614
- continue;
10615
- try {
10616
- connectionHashes.push(getPublicKeyFromPeerId(peerId).hashcode());
10617
- }
10618
- catch {
10619
- // Best-effort only.
10620
- }
10621
- }
10622
- if (connectionHashes.length > 0) {
10623
- candidates = connectionHashes;
10624
- }
10790
+ historicalOnly.push(...pickDeterministicSubset(available, (seed ^ Math.imul(tier + 5, 0x9e3779b1)) >>> 0, maxPeers - historicalOnly.length));
10791
+ if (historicalOnly.length >= maxPeers)
10792
+ break;
10793
+ }
10794
+ const selected = liveKnown.slice(0, maxPeers);
10795
+ let liveIndex = 0;
10796
+ let historicalIndex = 0;
10797
+ while (selected.length < maxPeers &&
10798
+ (liveIndex < liveOnly.length || historicalIndex < historicalOnly.length)) {
10799
+ if (liveIndex < liveOnly.length) {
10800
+ selected.push(liveOnly[liveIndex++]);
10801
+ }
10802
+ if (selected.length < maxPeers &&
10803
+ historicalIndex < historicalOnly.length) {
10804
+ selected.push(historicalOnly[historicalIndex++]);
10625
10805
  }
10626
10806
  }
10627
- if (!candidates || candidates.length === 0)
10628
- return undefined;
10629
- const peers = candidates.filter((p) => p !== self);
10630
- if (peers.length === 0)
10631
- return undefined;
10632
- return pickDeterministicSubset(peers, seed, maxPeers);
10807
+ return selected.length > 0 ? selected : undefined;
10633
10808
  }
10634
10809
  async getMemoryUsage() {
10635
10810
  return this.log.blocks.size();
@@ -10953,6 +11128,7 @@ let SharedLog = (() => {
10953
11128
  }
10954
11129
  const deferredCoordinateDeleteHashes = change.removed.map((removed) => removed.hash);
10955
11130
  this.onEntryRemovedHashes(deferredCoordinateDeleteHashes);
11131
+ this.scheduleDeadGidPeerHistoryReclaim(change.removed.map((removed) => removed.meta.gid));
10956
11132
  if (options?.forgetNativeCoordinates === false) {
10957
11133
  this._coordinates.forgetResidentCoordinateStateForHashes(deferredCoordinateDeleteHashes);
10958
11134
  }
@@ -10982,6 +11158,9 @@ let SharedLog = (() => {
10982
11158
  ? normalizedHashValues(removedHashes)
10983
11159
  : removed.map((entry) => entry.hash);
10984
11160
  this.onEntryRemovedHashes(deferredCoordinateDeleteHashes);
11161
+ this.scheduleDeadGidPeerHistoryReclaim(removed.length > 0
11162
+ ? removed.map((entry) => entry.meta.gid)
11163
+ : options?.removedGids);
10985
11164
  if (options?.forgetNativeCoordinates === false) {
10986
11165
  this._coordinates.forgetResidentCoordinateStateForHashes(deferredCoordinateDeleteHashes);
10987
11166
  }
@@ -11001,8 +11180,128 @@ let SharedLog = (() => {
11001
11180
  }
11002
11181
  this.onEntryRemoved(removed.hash);
11003
11182
  }
11183
+ this.scheduleDeadGidPeerHistoryReclaim(removedEntries.map((removed) => removed.meta.gid));
11004
11184
  return undefined;
11005
11185
  }
11186
+ async reclaimDeadGidPeerHistory(gids, history = this._gidPeersHistory) {
11187
+ if (!gids)
11188
+ return;
11189
+ const candidates = [...new Set(gids)].filter((gid) => history === this._gidPeersHistory && !!gid && history.has(gid));
11190
+ if (candidates.length === 0)
11191
+ return;
11192
+ const hasHeads = await this.hasAnyHeadForGidSets(candidates.map((gid) => [gid]));
11193
+ if (history !== this._gidPeersHistory)
11194
+ return;
11195
+ for (let index = 0; index < candidates.length; index++) {
11196
+ const gid = candidates[index];
11197
+ if (!hasHeads[index] && history.has(gid)) {
11198
+ this.deleteGidPeerHistory(gid);
11199
+ }
11200
+ }
11201
+ }
11202
+ resetGidPeerHistoryCleanupState() {
11203
+ this._gidPeerHistoryCleanupState = {
11204
+ history: this._gidPeersHistory,
11205
+ tail: Promise.resolve(),
11206
+ pending: new Set(),
11207
+ draining: false,
11208
+ highWater: 0,
11209
+ };
11210
+ }
11211
+ scheduleDeadGidPeerHistoryReclaim(gids) {
11212
+ if (!gids)
11213
+ return;
11214
+ const state = this._gidPeerHistoryCleanupState;
11215
+ const history = state.history;
11216
+ for (const gid of gids) {
11217
+ if (!gid || !history.has(gid))
11218
+ continue;
11219
+ if (state.pending.has(gid))
11220
+ continue;
11221
+ if (state.pending.size >= GID_PEER_HISTORY_CLEANUP_PENDING_CAPACITY) {
11222
+ // History is only a suppression memo. Under sustained synchronous
11223
+ // production, forgetting an overflow row is the bounded, safe fallback:
11224
+ // it can cause redundant delivery but cannot affect ownership or data.
11225
+ this.deleteGidPeerHistory(gid);
11226
+ continue;
11227
+ }
11228
+ state.pending.add(gid);
11229
+ }
11230
+ state.highWater = Math.max(state.highWater, state.pending.size);
11231
+ if (state.pending.size === 0)
11232
+ return;
11233
+ // A macrotask gate lets sequential awaited appends coalesce; a Promise
11234
+ // microtask runs before the caller can enqueue the next trimmed gid. The
11235
+ // fixed early-wake threshold also amortizes latest-only (retain=1) logs,
11236
+ // while the separate pending cap remains the absolute memory bound.
11237
+ const wakeThreshold = GID_PEER_HISTORY_CLEANUP_BATCH_SIZE;
11238
+ if (state.draining) {
11239
+ if (state.pending.size >= wakeThreshold)
11240
+ state.wake?.();
11241
+ return;
11242
+ }
11243
+ state.draining = true;
11244
+ let wake;
11245
+ const batchReady = new Promise((resolve) => {
11246
+ let settled = false;
11247
+ let cancel = () => { };
11248
+ wake = () => {
11249
+ if (settled)
11250
+ return;
11251
+ settled = true;
11252
+ cancel();
11253
+ if (state.wake === wake)
11254
+ state.wake = undefined;
11255
+ resolve();
11256
+ };
11257
+ state.wake = wake;
11258
+ if (typeof setImmediate === "function") {
11259
+ const handle = setImmediate(wake);
11260
+ cancel = () => clearImmediate(handle);
11261
+ }
11262
+ else {
11263
+ const handle = setTimeout(wake, 0);
11264
+ cancel = () => clearTimeout(handle);
11265
+ }
11266
+ });
11267
+ if (state.pending.size >= wakeThreshold)
11268
+ wake();
11269
+ const drain = state.tail
11270
+ .then(async () => {
11271
+ await batchReady;
11272
+ while (history === this._gidPeersHistory) {
11273
+ const batch = [...state.pending].slice(0, GID_PEER_HISTORY_CLEANUP_BATCH_SIZE);
11274
+ if (batch.length === 0)
11275
+ break;
11276
+ for (const gid of batch)
11277
+ state.pending.delete(gid);
11278
+ try {
11279
+ await this.reclaimDeadGidPeerHistory(batch, history);
11280
+ }
11281
+ catch (error) {
11282
+ warn(`Failed to reclaim gid peer history: ${String(error)}`);
11283
+ if (history === this._gidPeersHistory) {
11284
+ for (const gid of batch)
11285
+ this.deleteGidPeerHistory(gid);
11286
+ }
11287
+ }
11288
+ }
11289
+ })
11290
+ .catch((error) => {
11291
+ warn(`Failed to reclaim gid peer history: ${String(error)}`);
11292
+ });
11293
+ state.tail = drain.finally(() => {
11294
+ state.draining = false;
11295
+ if (history !== this._gidPeersHistory) {
11296
+ state.pending.clear();
11297
+ }
11298
+ if (history === this._gidPeersHistory &&
11299
+ state.pending.size > 0 &&
11300
+ state === this._gidPeerHistoryCleanupState) {
11301
+ this.scheduleDeadGidPeerHistoryReclaim([]);
11302
+ }
11303
+ });
11304
+ }
11006
11305
  async canAppend(entry) {
11007
11306
  try {
11008
11307
  if (!entry.meta.data) {
@@ -11460,6 +11759,7 @@ let SharedLog = (() => {
11460
11759
  this.recentlyRebalanced?.clear();
11461
11760
  this.uniqueReplicators?.clear();
11462
11761
  this._topicSubscribersCache?.clear();
11762
+ this._localReachablePeerHashesCache?.clear();
11463
11763
  this._closeController.abort();
11464
11764
  clearInterval(this.interval);
11465
11765
  this._liveness?.stopReplicatorLivenessSweep();
@@ -17033,7 +17333,8 @@ let SharedLog = (() => {
17033
17333
  }
17034
17334
  async rebalanceAll(options) {
17035
17335
  if (options?.clearCache) {
17036
- this._gidPeersHistory.clear();
17336
+ this._gidPeersHistory = new Map();
17337
+ this.resetGidPeerHistoryCleanupState();
17037
17338
  this._nativeSharedLogState?.clearGidPeers();
17038
17339
  this._nativeBackbone?.clearGidPeers();
17039
17340
  }
@@ -17446,9 +17747,11 @@ let SharedLog = (() => {
17446
17747
  }
17447
17748
  const fromHash = evt.detail.from.hashcode();
17448
17749
  const subscriptionEpoch = this._peerSessions.rotate(fromHash, "opening");
17449
- this.remoteBlocks.onReachable(evt.detail.from);
17450
17750
  this._peerSessions.blockReplicationInfo(fromHash);
17451
17751
  this.invalidateSharedLogTopicSubscribersCache();
17752
+ // Invalidate the local reachability snapshot before waking block reads;
17753
+ // their forced resolver runs synchronously from the reachable event.
17754
+ this.remoteBlocks.onReachable(evt.detail.from);
17452
17755
  await this.handleSubscriptionChange(evt.detail.from, evt.detail.topics, true, subscriptionEpoch, evt.detail.session);
17453
17756
  }
17454
17757
  async rebalanceParticipation(ownershipLifecycleController = this.captureReplicationOwnershipLifecycle(), rebalanceParticipationDebounced = this.rebalanceParticipationDebounced) {