@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/src/index.ts CHANGED
@@ -904,6 +904,7 @@ type PreparedPayloadCommitOnlyResult<T, R extends "u32" | "u64"> = {
904
904
  entry: Entry<T>;
905
905
  removed: ShallowOrFullEntry<T>[];
906
906
  removedHashes?: string[];
907
+ removedGids?: string[];
907
908
  appendCommit: PreparedLocalAppendCommit<R>;
908
909
  };
909
910
 
@@ -1407,6 +1408,8 @@ const RECALCULATE_PARTICIPATION_MIN_RELATIVE_CHANGE_WITH_CPU_LIMIT = 0.005;
1407
1408
  const RECALCULATE_PARTICIPATION_MIN_RELATIVE_CHANGE_WITH_MEMORY_LIMIT = 0.001;
1408
1409
  const RECALCULATE_PARTICIPATION_RELATIVE_DENOMINATOR_FLOOR = 1e-3;
1409
1410
  const TOPIC_SUBSCRIBERS_CACHE_TTL_MS = 250;
1411
+ const LOCAL_REACHABLE_PEERS_CACHE_TTL_MS = 250;
1412
+ const LOCAL_REACHABLE_PEERS_MAX = 64;
1410
1413
  const LEADER_SELECTION_CONTEXT_CACHE_TTL_MS = 50;
1411
1414
  // Leader-plan results are invalidated structurally (replication:change,
1412
1415
  // replicator:mature, subscriber changes). The TTL bounds wall-clock maturity
@@ -1430,6 +1433,8 @@ const ADAPTIVE_REBALANCE_MIN_IDLE_AFTER_LOCAL_APPEND_MS = 10_000;
1430
1433
  // raw exchange message size the receive path is tuned for.
1431
1434
  const LIVE_RAW_GOSSIP_MAX_ENTRIES = 256;
1432
1435
  const LIVE_RAW_GOSSIP_MAX_BYTES = 128 * 1024;
1436
+ const GID_PEER_HISTORY_CLEANUP_BATCH_SIZE = 256;
1437
+ const GID_PEER_HISTORY_CLEANUP_PENDING_CAPACITY = 4096;
1433
1438
 
1434
1439
  type LiveRawGossipBatch = {
1435
1440
  to: string[];
@@ -1763,6 +1768,7 @@ type TrustedLowerLogNativePreparedCommit = {
1763
1768
  hashDigestBytes?: Uint8Array;
1764
1769
  trimmedEntries?: unknown[];
1765
1770
  trimmedEntryHashes?: string[];
1771
+ trimmedEntryGids?: string[];
1766
1772
  nativeBlocksDeleted?: boolean;
1767
1773
  nativeDeleteCleanupToken?: unknown;
1768
1774
  nativeCommitOwnershipToken?: unknown;
@@ -1774,6 +1780,8 @@ type TrustedLowerLogNativePreparedCommit = {
1774
1780
  type TrustedLowerLogPreparedAppendResult<T> = {
1775
1781
  entry: Entry<T>;
1776
1782
  removed: ShallowOrFullEntry<T>[];
1783
+ removedHashes?: string[];
1784
+ removedGids?: string[];
1777
1785
  change: Change<T>;
1778
1786
  appendFacts: PreparedAppendFacts;
1779
1787
  };
@@ -1784,6 +1792,7 @@ type TrustedLowerLogCommitOnlyAppendResult<T> = {
1784
1792
  shallowEntry: ShallowEntry;
1785
1793
  removed: ShallowOrFullEntry<T>[];
1786
1794
  removedHashes?: string[];
1795
+ removedGids?: string[];
1787
1796
  appendFacts: PreparedAppendFacts;
1788
1797
  documentTrimmedHeadsProcessed?: boolean;
1789
1798
  documentPreviousContext?: PreparedLocalAppendCommit<"u32">["documentPreviousContext"];
@@ -1795,6 +1804,7 @@ type TrustedLowerLogCommitOnlyAppendBatchResult<T> = {
1795
1804
  materializeEntries: Array<() => Entry<T>>;
1796
1805
  removed: ShallowOrFullEntry<T>[];
1797
1806
  removedHashes?: string[];
1807
+ removedGids?: string[];
1798
1808
  appendFacts: PreparedAppendFacts[];
1799
1809
  documentTrimmedHeadsProcessed?: boolean[];
1800
1810
  nativeCommittedAppendFinalizer?: TrustedLowerLogNativeCommitFinalizer;
@@ -2082,17 +2092,19 @@ export class SharedLog<
2082
2092
  // GROWTH SHAPE. Rows are released by `deleteGidPeerHistory` on the two prune
2083
2093
  // paths, by `removePeerFromGidPeerHistory` once a gid's last peer drops (the
2084
2094
  // routine disconnect outcome), by `rebalanceAll({ clearCache: true })`, and
2085
- // wholesale on close/reset. Nothing on the TRIM path releases a row, so a
2086
- // node that bounds its log with trim rather than prune accumulates one row
2087
- // per distinct gid it has ever held. A gid names a graph, not an entry: an
2095
+ // wholesale on close/reset. Trim now carries compact distinct removed-gid
2096
+ // facts beside its hash-only result. After the lower mutation commits, a
2097
+ // coalesced cleanup checks authoritative local-head liveness and drops a row
2098
+ // only when no sibling remains. A gid names a graph, not an entry: an
2088
2099
  // entry with `meta.next` inherits `min(next.meta.gid)` (see
2089
2100
  // packages/log/src/entry-v0.ts), so document updates fold into the gid of
2090
2101
  // the first put and the row count tracks distinct chain roots -- distinct
2091
2102
  // document ids -- rather than entry count. Insert-only workloads mint a
2092
- // fresh gid per append and so do grow one row per entry. Merges are a
2093
- // smaller second source: when a join links two graphs the losing entries
2094
- // keep their own `meta.gid` on disk, and shared-log does not subscribe to
2095
- // the log's `onGidRemoved`, so the shadowed gid's row lingers too.
2103
+ // fresh gid per append and so do grow one row per entry. Merge-shadowed gids
2104
+ // are a separate, smaller source that still needs a fast-path-compatible
2105
+ // post-commit metadata seam. Registering the lower log's `onGidRemoved` hook
2106
+ // here would disable native prepared, commit-only, and batch append paths, so
2107
+ // this bounded slice deliberately addresses trim only.
2096
2108
  //
2097
2109
  // WHY TRIM DOES NOT SIMPLY CALL `deleteGidPeerHistory` AS WELL. Both prune
2098
2110
  // callers delete a whole row from a single entry's gid, and under the
@@ -2110,10 +2122,11 @@ export class SharedLog<
2110
2122
  // on the common path, paying for the freed memory in repeated re-delivery of
2111
2123
  // entries that are still here. That trade is not worth it.
2112
2124
  //
2113
- // Bounding this correctly requires a per-gid count of locally held entries,
2114
- // dropping the row only when it reaches zero -- a real reverse index, not a
2115
- // one-line delete. Deliberately not built: the growth is bounded by distinct
2116
- // gids, and the cheap version is a bandwidth regression.
2125
+ // Cleanup work is coalesced, checked in bounded batches, and capped at 4096
2126
+ // pending gids. Saturation forgets the overflowing suppression row
2127
+ // immediately. That bounded fallback can cause redundant delivery for a live
2128
+ // sibling, but missing history is deliberately correctness-safe as described
2129
+ // above; it cannot affect ownership, quorum, or retained data.
2117
2130
  //
2118
2131
  // Existing, deliberate imprecision: under the time domain the coordinate is
2119
2132
  // `meta.clock.timestamp.wallTime` (replication-domain-time.ts) and is
@@ -2121,6 +2134,14 @@ export class SharedLog<
2121
2134
  // and prune's whole-row delete is already over-eager there. The cost is the
2122
2135
  // same bounded extra traffic, never a wrong prune.
2123
2136
  _gidPeersHistory!: Map<string, Set<string>>;
2137
+ private _gidPeerHistoryCleanupState!: {
2138
+ history: Map<string, Set<string>>;
2139
+ tail: Promise<void>;
2140
+ pending: Set<string>;
2141
+ draining: boolean;
2142
+ highWater: number;
2143
+ wake?: () => void;
2144
+ };
2124
2145
 
2125
2146
  private _onSubscriptionFn!: (arg: any) => any;
2126
2147
  private _onUnsubscriptionFn!: (arg: any) => any;
@@ -3427,6 +3448,14 @@ export class SharedLog<
3427
3448
  string,
3428
3449
  { expiresAt: number; keys: PublicSignKey[] }
3429
3450
  >;
3451
+ private _localReachablePeerHashesCache!: Map<
3452
+ string,
3453
+ {
3454
+ expiresAt: number;
3455
+ hashes?: string[];
3456
+ inFlight?: Promise<string[]>;
3457
+ }
3458
+ >;
3430
3459
  private _leaderSelectionContextCache?: {
3431
3460
  expiresAt: number;
3432
3461
  context: LeaderSelectionContext;
@@ -3744,6 +3773,7 @@ export class SharedLog<
3744
3773
  this._receiveHandlerDrainByPeer = new Map();
3745
3774
  this._openingSyncCapabilitiesByPeer = new Map();
3746
3775
  this._gidPeersHistory = new Map();
3776
+ this.resetGidPeerHistoryCleanupState();
3747
3777
  this._repairRetryTimers = new Set();
3748
3778
  this._recentRepairDispatch = new Map();
3749
3779
  this._repairSweepRunning = false;
@@ -3766,6 +3796,7 @@ export class SharedLog<
3766
3796
  this._joinAuthoritativeRepairPeersByDelay = new Map();
3767
3797
  this._appendBackfillPendingByTarget = new Map();
3768
3798
  this._topicSubscribersCache = new Map();
3799
+ this._localReachablePeerHashesCache = new Map();
3769
3800
  this._peerSyncCapabilities = new Map();
3770
3801
  this._peerSyncCapabilitySessions = new Map();
3771
3802
  this._peerSyncCapabilityTimestamps = new Map();
@@ -5134,6 +5165,93 @@ export class SharedLog<
5134
5165
  );
5135
5166
  }
5136
5167
 
5168
+ private async _getLocalReachablePeerHashes(topic: string): Promise<string[]> {
5169
+ const cache = (this._localReachablePeerHashesCache ??= new Map());
5170
+ const cached = cache.get(topic);
5171
+ if (cached?.hashes && cached.expiresAt > Date.now()) {
5172
+ return cached.hashes.slice();
5173
+ }
5174
+ if (cached?.inFlight) {
5175
+ return (await cached.inFlight).slice();
5176
+ }
5177
+
5178
+ const entry: {
5179
+ expiresAt: number;
5180
+ hashes?: string[];
5181
+ inFlight?: Promise<string[]>;
5182
+ } = { expiresAt: 0 };
5183
+ const inFlight = (async () => {
5184
+ const selfHash = this.node.identity.publicKey.hashcode();
5185
+ const hashes: string[] = [];
5186
+ const seen = new Set<string>();
5187
+ const addHash = (hash: unknown) => {
5188
+ if (
5189
+ typeof hash !== "string" ||
5190
+ hash.length === 0 ||
5191
+ hash === selfHash ||
5192
+ seen.has(hash) ||
5193
+ hashes.length >= LOCAL_REACHABLE_PEERS_MAX
5194
+ ) {
5195
+ return;
5196
+ }
5197
+ seen.add(hash);
5198
+ hashes.push(hash);
5199
+ };
5200
+
5201
+ const pubsub = this.node.services.pubsub as any;
5202
+ const peerMap = pubsub?.peers;
5203
+ if (peerMap?.entries) {
5204
+ for (const [hash, peer] of peerMap.entries()) {
5205
+ addHash(peer?.publicKey?.hashcode?.());
5206
+ addHash(hash);
5207
+ if (hashes.length >= LOCAL_REACHABLE_PEERS_MAX) break;
5208
+ }
5209
+ }
5210
+
5211
+ if (hashes.length < LOCAL_REACHABLE_PEERS_MAX) {
5212
+ const connectionManager = pubsub?.components?.connectionManager;
5213
+ for (const conn of connectionManager?.getConnections?.() ?? []) {
5214
+ const peerId = conn?.remotePeer;
5215
+ if (!peerId) continue;
5216
+ try {
5217
+ addHash(getPublicKeyFromPeerId(peerId).hashcode());
5218
+ } catch {
5219
+ // Best-effort only.
5220
+ }
5221
+ if (hashes.length >= LOCAL_REACHABLE_PEERS_MAX) break;
5222
+ }
5223
+ }
5224
+
5225
+ if (hashes.length < LOCAL_REACHABLE_PEERS_MAX) {
5226
+ try {
5227
+ for (const subscriber of (await pubsub.getSubscribers(topic)) ?? []) {
5228
+ addHash(subscriber?.hashcode?.());
5229
+ if (hashes.length >= LOCAL_REACHABLE_PEERS_MAX) break;
5230
+ }
5231
+ } catch {
5232
+ // Local reachability is best-effort.
5233
+ }
5234
+ }
5235
+
5236
+ return hashes;
5237
+ })();
5238
+ entry.inFlight = inFlight;
5239
+ cache.set(topic, entry);
5240
+
5241
+ try {
5242
+ const hashes = await inFlight;
5243
+ if (cache.get(topic) === entry) {
5244
+ entry.hashes = hashes;
5245
+ entry.expiresAt = Date.now() + LOCAL_REACHABLE_PEERS_CACHE_TTL_MS;
5246
+ entry.inFlight = undefined;
5247
+ }
5248
+ return hashes.slice();
5249
+ } catch (error) {
5250
+ if (cache.get(topic) === entry) cache.delete(topic);
5251
+ throw error;
5252
+ }
5253
+ }
5254
+
5137
5255
  private async _getTopicSubscribers(
5138
5256
  topic: string,
5139
5257
  ): Promise<PublicSignKey[] | undefined> {
@@ -5258,6 +5376,7 @@ export class SharedLog<
5258
5376
  for (const topic of topics) {
5259
5377
  if (!topic) continue;
5260
5378
  this._topicSubscribersCache.delete(topic);
5379
+ this._localReachablePeerHashesCache?.delete(topic);
5261
5380
  }
5262
5381
  this.invalidateLeaderSelectionContextCache();
5263
5382
  }
@@ -10293,6 +10412,8 @@ export class SharedLog<
10293
10412
  ): Promise<{
10294
10413
  entry: Entry<T>;
10295
10414
  removed: ShallowOrFullEntry<T>[];
10415
+ removedHashes?: string[];
10416
+ removedGids?: string[];
10296
10417
  appendCommit: PreparedLocalAppendCommit<R>;
10297
10418
  }> {
10298
10419
  this.throwIfNativeDurableCommitFailed();
@@ -10334,6 +10455,8 @@ export class SharedLog<
10334
10455
  return {
10335
10456
  entry: result.entry,
10336
10457
  removed: result.removed,
10458
+ removedHashes: result.removedHashes,
10459
+ removedGids: result.removedGids,
10337
10460
  appendCommit: nativePreparedCommit,
10338
10461
  };
10339
10462
  }
@@ -10400,6 +10523,8 @@ export class SharedLog<
10400
10523
  return {
10401
10524
  entry: result.entry,
10402
10525
  removed: result.removed,
10526
+ removedHashes: result.removedHashes,
10527
+ removedGids: result.removedGids,
10403
10528
  appendCommit: this.createPreparedLocalAppendCommitFromFacts(
10404
10529
  result.appendFacts,
10405
10530
  nativeAppendPlan,
@@ -10412,6 +10537,8 @@ export class SharedLog<
10412
10537
  entry?: Entry<T>;
10413
10538
  materializeEntry?: () => Entry<T>;
10414
10539
  removed: ShallowOrFullEntry<T>[];
10540
+ removedHashes?: string[];
10541
+ removedGids?: string[];
10415
10542
  change?: Change<T>;
10416
10543
  appendFacts: PreparedAppendFacts;
10417
10544
  },
@@ -10439,6 +10566,7 @@ export class SharedLog<
10439
10566
 
10440
10567
  const plannedCoordinateDeleteHashes =
10441
10568
  result.change?.removed.map((entry) => entry.hash) ??
10569
+ result.removedHashes ??
10442
10570
  result.removed.map((entry) => entry.hash);
10443
10571
  const nativeAppendPlan = await this.planNativeLocalAppendFacts(
10444
10572
  result.appendFacts,
@@ -10473,6 +10601,8 @@ export class SharedLog<
10473
10601
  {
10474
10602
  forgetNativeCoordinates:
10475
10603
  !nativeAppendPlan.committedNativeCoordinateDeletes,
10604
+ removedHashes: result.removedHashes,
10605
+ removedGids: result.removedGids,
10476
10606
  ownershipLifecycleController,
10477
10607
  },
10478
10608
  );
@@ -11257,6 +11387,7 @@ export class SharedLog<
11257
11387
  },
11258
11388
  removed: prepared.removed,
11259
11389
  removedHashes: prepared.removedHashes,
11390
+ removedGids: prepared.removedGids,
11260
11391
  appendCommit,
11261
11392
  };
11262
11393
  };
@@ -11288,7 +11419,10 @@ export class SharedLog<
11288
11419
  prepared.appendFacts,
11289
11420
  prepared.removed,
11290
11421
  prepared.materializeEntry,
11291
- { removedHashes: prepared.removedHashes },
11422
+ {
11423
+ removedHashes: prepared.removedHashes,
11424
+ removedGids: prepared.removedGids,
11425
+ },
11292
11426
  );
11293
11427
  try {
11294
11428
  await this.completeNativeStrictDurableTransaction(
@@ -11518,6 +11652,11 @@ export class SharedLog<
11518
11652
  const nativeTrimmedHashes =
11519
11653
  backboneAppend.trimmedHashes ??
11520
11654
  backboneAppend.trimmed.map((entry) => entry.hash);
11655
+ const nativeTrimmedGids =
11656
+ backboneAppend.trimmedGids ??
11657
+ (backboneAppend.trimmed.length > 0
11658
+ ? backboneAppend.trimmed.map((entry) => entry.gid)
11659
+ : undefined);
11521
11660
  const committedHash =
11522
11661
  backboneAppend.entry.cid ?? backboneAppend.entry.hash;
11523
11662
  const committedNext = backboneAppend.entry.next ?? next;
@@ -11600,6 +11739,9 @@ export class SharedLog<
11600
11739
  trimmedEntryHashes: useTrimmedHashesOnly
11601
11740
  ? backboneAppend.trimmedHashes
11602
11741
  : undefined,
11742
+ trimmedEntryGids: useTrimmedHashesOnly
11743
+ ? nativeTrimmedGids
11744
+ : undefined,
11603
11745
  nativeBlocksDeleted: commitBlocksInBackbone,
11604
11746
  nativeDeleteCleanupToken,
11605
11747
  documentPreviousContext: backboneAppend.documentPreviousContext,
@@ -11804,6 +11946,7 @@ export class SharedLog<
11804
11946
  },
11805
11947
  removed: prepared.removed,
11806
11948
  removedHashes: prepared.removedHashes,
11949
+ removedGids: prepared.removedGids,
11807
11950
  appendCommit,
11808
11951
  };
11809
11952
  };
@@ -11930,6 +12073,7 @@ export class SharedLog<
11930
12073
  {
11931
12074
  forgetNativeCoordinates: false,
11932
12075
  removedHashes: prepared.removedHashes,
12076
+ removedGids: prepared.removedGids,
11933
12077
  },
11934
12078
  );
11935
12079
  try {
@@ -11976,6 +12120,7 @@ export class SharedLog<
11976
12120
  materializeEntry: () => Entry<T>;
11977
12121
  removed: ShallowOrFullEntry<T>[];
11978
12122
  removedHashes?: string[];
12123
+ removedGids?: string[];
11979
12124
  appendFacts: PreparedAppendFacts;
11980
12125
  }
11981
12126
  | undefined,
@@ -11998,6 +12143,7 @@ export class SharedLog<
11998
12143
  result.materializeEntry,
11999
12144
  {
12000
12145
  removedHashes: result.removedHashes,
12146
+ removedGids: result.removedGids,
12001
12147
  ownershipLifecycleController,
12002
12148
  },
12003
12149
  );
@@ -12027,6 +12173,7 @@ export class SharedLog<
12027
12173
  },
12028
12174
  removed: result.removed,
12029
12175
  removedHashes: result.removedHashes,
12176
+ removedGids: result.removedGids,
12030
12177
  appendCommit: this.createPreparedLocalAppendCommitFromFacts(
12031
12178
  result.appendFacts,
12032
12179
  ),
@@ -12040,6 +12187,7 @@ export class SharedLog<
12040
12187
  },
12041
12188
  removed: result.removed,
12042
12189
  removedHashes: result.removedHashes,
12190
+ removedGids: result.removedGids,
12043
12191
  appendCommit: this.createPreparedLocalAppendCommitFromFacts(
12044
12192
  result.appendFacts,
12045
12193
  ),
@@ -12070,6 +12218,7 @@ export class SharedLog<
12070
12218
  materializeEntry: () => Entry<T>;
12071
12219
  removed: ShallowOrFullEntry<T>[];
12072
12220
  removedHashes?: string[];
12221
+ removedGids?: string[];
12073
12222
  appendFacts: PreparedAppendFacts;
12074
12223
  },
12075
12224
  options: SharedAppendOptions<T> | undefined,
@@ -12122,6 +12271,7 @@ export class SharedLog<
12122
12271
  materializeEntry: () => Entry<T>;
12123
12272
  removed: ShallowOrFullEntry<T>[];
12124
12273
  removedHashes?: string[];
12274
+ removedGids?: string[];
12125
12275
  appendFacts: PreparedAppendFacts;
12126
12276
  },
12127
12277
  minReplicasValue: number,
@@ -12147,6 +12297,7 @@ export class SharedLog<
12147
12297
  },
12148
12298
  removed: result.removed,
12149
12299
  removedHashes: result.removedHashes,
12300
+ removedGids: result.removedGids,
12150
12301
  appendCommit: nativePreparedCommit,
12151
12302
  };
12152
12303
  }
@@ -12157,6 +12308,7 @@ export class SharedLog<
12157
12308
  materializeEntry?: () => Entry<T>;
12158
12309
  removed: ShallowOrFullEntry<T>[];
12159
12310
  removedHashes?: string[];
12311
+ removedGids?: string[];
12160
12312
  change?: Change<T>;
12161
12313
  appendFacts: PreparedAppendFacts;
12162
12314
  },
@@ -12208,6 +12360,7 @@ export class SharedLog<
12208
12360
  forgetNativeCoordinates:
12209
12361
  !nativeAppendPlan.committedNativeCoordinateDeletes,
12210
12362
  removedHashes: result.removedHashes,
12363
+ removedGids: result.removedGids,
12211
12364
  ownershipLifecycleController,
12212
12365
  },
12213
12366
  );
@@ -12290,6 +12443,7 @@ export class SharedLog<
12290
12443
  materializeEntry: () => Entry<T>;
12291
12444
  removed: ShallowOrFullEntry<T>[];
12292
12445
  removedHashes?: string[];
12446
+ removedGids?: string[];
12293
12447
  appendFacts: PreparedAppendFacts;
12294
12448
  },
12295
12449
  options: SharedAppendOptions<T> | undefined,
@@ -12310,6 +12464,8 @@ export class SharedLog<
12310
12464
  return result.entry;
12311
12465
  },
12312
12466
  removed: result.removed,
12467
+ removedHashes: result.removedHashes,
12468
+ removedGids: result.removedGids,
12313
12469
  appendCommit: nativePreparedCommit,
12314
12470
  };
12315
12471
  }
@@ -12324,6 +12480,7 @@ export class SharedLog<
12324
12480
  result.materializeEntry,
12325
12481
  {
12326
12482
  removedHashes: result.removedHashes,
12483
+ removedGids: result.removedGids,
12327
12484
  ownershipLifecycleController,
12328
12485
  },
12329
12486
  );
@@ -12385,6 +12542,7 @@ export class SharedLog<
12385
12542
  },
12386
12543
  removed: result.removed,
12387
12544
  removedHashes: result.removedHashes,
12545
+ removedGids: result.removedGids,
12388
12546
  appendCommit: this.createPreparedLocalAppendCommitFromFacts(
12389
12547
  result.appendFacts,
12390
12548
  nativeAppendPlan,
@@ -12711,6 +12869,11 @@ export class SharedLog<
12711
12869
  nativeIndexMutationLockOwner:
12712
12870
  nativeStrictTransaction?.lowerHashMutationLockOwner,
12713
12871
  trimmedEntryHashes: append.trimmedHashes,
12872
+ trimmedEntryGids:
12873
+ append.trimmedGids ??
12874
+ (append.trimmed.length > 0
12875
+ ? append.trimmed.map((entry) => entry.gid)
12876
+ : undefined),
12714
12877
  nativeBlocksDeleted: true,
12715
12878
  nativeDeleteCleanupToken,
12716
12879
  documentTrimmedHeadsProcessed: append.documentTrimmedHeadsProcessed,
@@ -13042,6 +13205,11 @@ export class SharedLog<
13042
13205
  {
13043
13206
  forgetNativeCoordinates: false,
13044
13207
  removedHashes: plannedCoordinateDeleteHashes,
13208
+ removedGids:
13209
+ backboneAppend.trimmedGids ??
13210
+ (backboneAppend.trimmed.length > 0
13211
+ ? backboneAppend.trimmed.map((entry) => entry.gid)
13212
+ : undefined),
13045
13213
  },
13046
13214
  );
13047
13215
  if (!runtimeOnlyCoordinates && this.remoteBlocks.hasNotifyStoredHook()) {
@@ -14495,6 +14663,7 @@ export class SharedLog<
14495
14663
  this._appendBackfillPendingByTarget = new Map();
14496
14664
  this._repairMetrics = createRepairMetrics();
14497
14665
  this._topicSubscribersCache = new Map();
14666
+ this._localReachablePeerHashesCache = new Map();
14498
14667
  this._leaderSelectionContextCache = undefined;
14499
14668
  this._leaderPlanCache = new LeaderPlanCache({
14500
14669
  max: LEADER_PLAN_CACHE_MAX,
@@ -14715,31 +14884,73 @@ export class SharedLog<
14715
14884
  // compatible eager path with bounded validation and storage budgets.
14716
14885
  eagerBlocks: options?.eagerBlocks ?? false,
14717
14886
  resolveProviders: async (cid, opts) => {
14718
- // 1) tracker-backed provider directory (best-effort, bounded)
14887
+ const maxPeers = 8;
14888
+ const localCandidates =
14889
+ (await this.resolveCandidatePeersForHash(cid, {
14890
+ signal: opts?.signal,
14891
+ maxPeers,
14892
+ })) ?? [];
14893
+ if (opts?.signal?.aborted) return [];
14894
+ const locallyReachable = new Set(
14895
+ await this._getLocalReachablePeerHashes(this.topic),
14896
+ );
14897
+ const confirmed = this._checkedPrune.getConfirmedReplicators(cid);
14898
+ const contacted = this._checkedPrune.getContactedReplicators(cid);
14899
+ const hasLiveCandidate = localCandidates.some(
14900
+ (peer) =>
14901
+ locallyReachable.has(peer) &&
14902
+ (confirmed?.has(peer) ||
14903
+ contacted?.has(peer) ||
14904
+ this.uniqueReplicators.has(peer)),
14905
+ );
14906
+
14907
+ // Only reachability corroborated by provider/replicator evidence may
14908
+ // bypass the initial CID lookup. Arbitrary bootstrap connections are
14909
+ // useful fallbacks, but are not evidence that they hold this block.
14910
+ if (hasLiveCandidate && !opts?.refresh) {
14911
+ return localCandidates;
14912
+ }
14913
+
14914
+ let directoryProviders: string[] = [];
14719
14915
  try {
14720
- const providers = await fanoutService?.queryProviders(
14721
- blockProviderNamespace(cid),
14722
- {
14723
- want: 8,
14724
- timeoutMs: 2_000,
14725
- queryTimeoutMs: 500,
14726
- bootstrapMaxPeers: 2,
14727
- signal: opts?.signal,
14728
- },
14729
- );
14730
- if (providers && providers.length > 0) return providers;
14916
+ directoryProviders =
14917
+ (await fanoutService?.queryProviders(
14918
+ blockProviderNamespace(cid),
14919
+ {
14920
+ want: maxPeers,
14921
+ timeoutMs: 2_000,
14922
+ queryTimeoutMs: 500,
14923
+ bootstrapMaxPeers: 2,
14924
+ signal: opts?.signal,
14925
+ },
14926
+ )) ?? [];
14731
14927
  } catch {
14732
- // ignore discovery failures
14928
+ // Ignore discovery failures; local evidence remains usable.
14733
14929
  }
14734
14930
 
14735
- // 2) reuse the same per-hash / replicator / subscriber fallback used by
14736
- // entry loads so block retries can widen beyond stale explicit hints.
14737
- return (
14738
- (await this.resolveCandidatePeersForHash(cid, {
14739
- signal: opts?.signal,
14740
- maxPeers: 8,
14741
- })) ?? []
14742
- );
14931
+ const selected: string[] = [];
14932
+ const selectedSet = new Set<string>();
14933
+ const add = (peer: string | undefined) => {
14934
+ if (
14935
+ !peer ||
14936
+ selectedSet.has(peer) ||
14937
+ selected.length >= maxPeers
14938
+ ) {
14939
+ return;
14940
+ }
14941
+ selectedSet.add(peer);
14942
+ selected.push(peer);
14943
+ };
14944
+ for (
14945
+ let index = 0;
14946
+ selected.length < maxPeers &&
14947
+ (index < localCandidates.length || index < directoryProviders.length);
14948
+ index++
14949
+ ) {
14950
+ add(localCandidates[index]);
14951
+ add(directoryProviders[index]);
14952
+ }
14953
+ return selected;
14743
14954
  },
14744
14955
  watchProviders: fanoutService
14745
14956
  ? (cid, opts) =>
@@ -14783,6 +14994,7 @@ export class SharedLog<
14783
14994
  })) > 0;
14784
14995
 
14785
14996
  this._gidPeersHistory = new Map();
14997
+ this.resetGidPeerHistoryCleanupState();
14786
14998
  const replicationChangeOwnershipLifecycleController =
14787
14999
  this.captureReplicationOwnershipLifecycle();
14788
15000
  let replicationChangeDebounceFn!: typeof this.replicationChangeDebounceFn;
@@ -16112,73 +16324,99 @@ export class SharedLog<
16112
16324
  if (options?.signal?.aborted) return undefined;
16113
16325
 
16114
16326
  const maxPeers = options?.maxPeers ?? 8;
16327
+ if (maxPeers <= 0) return undefined;
16115
16328
  const self = this.node.identity.publicKey.hashcode();
16116
16329
  const seed = hashToSeed32(hash);
16117
16330
 
16118
- const hinted = this._checkedPrune.getConfirmedReplicators(hash);
16119
- if (hinted && hinted.size > 0) {
16120
- const peers = [...hinted].filter((p) => p !== self);
16121
- return peers.length > 0
16122
- ? pickDeterministicSubset(peers, seed, maxPeers)
16123
- : undefined;
16124
- }
16125
-
16331
+ // Replication/provider knowledge deliberately outlives a transport session.
16332
+ // That is useful for repair, but it must not fill the bounded fetch target set
16333
+ // with departed peers while a reachable holder is available. The local
16334
+ // snapshot performs no tracker/provider discovery or key resolution.
16335
+ const reachable = await this._getLocalReachablePeerHashes(this.topic);
16336
+ if (options?.signal?.aborted) return undefined;
16337
+ const reachableSet = new Set(reachable);
16338
+ const confirmed = this._checkedPrune.getConfirmedReplicators(hash);
16126
16339
  const contacted = this._checkedPrune.getContactedReplicators(hash);
16127
- if (contacted && contacted.size > 0) {
16128
- const peers = [...contacted].filter((p) => p !== self);
16129
- return peers.length > 0
16130
- ? pickDeterministicSubset(peers, seed, maxPeers)
16131
- : undefined;
16132
- }
16133
-
16134
- let candidates: string[] | undefined;
16135
- const replicatorCandidates = [...this.uniqueReplicators].filter(
16136
- (p) => p !== self,
16137
- );
16138
- if (replicatorCandidates.length > 0) {
16139
- candidates = replicatorCandidates;
16140
- } else {
16141
- try {
16142
- const subscribers = await this._getTopicSubscribers(this.topic);
16143
- const subscriberCandidates =
16144
- subscribers?.map((k) => k.hashcode()).filter((p) => p !== self) ?? [];
16145
- candidates =
16146
- subscriberCandidates.length > 0 ? subscriberCandidates : undefined;
16147
- } catch {
16148
- // Best-effort only.
16149
- }
16340
+ const evidenceTiers: readonly Iterable<string>[] = [
16341
+ confirmed ?? [],
16342
+ contacted ?? [],
16343
+ this.uniqueReplicators,
16344
+ ];
16150
16345
 
16151
- if (!candidates || candidates.length === 0) {
16152
- const peerMap = (this.node.services.pubsub as any)?.peers;
16153
- if (peerMap?.keys) {
16154
- candidates = [...peerMap.keys()];
16346
+ // Peers supported by both current transport state and provider history are
16347
+ // the strongest signal. Afterwards alternate live-only and historical-only
16348
+ // evidence so either source can widen a saturated bounded set. Derive the
16349
+ // live intersection from the bounded reachability snapshot; never clone or
16350
+ // scan an unbounded historical replicator set per missing block.
16351
+ const liveEvidenceTiers: string[][] = [[], [], []];
16352
+ for (const peer of reachable) {
16353
+ if (confirmed?.has(peer)) liveEvidenceTiers[0].push(peer);
16354
+ else if (contacted?.has(peer)) liveEvidenceTiers[1].push(peer);
16355
+ else if (this.uniqueReplicators.has(peer)) liveEvidenceTiers[2].push(peer);
16356
+ }
16357
+ const liveKnown: string[] = [];
16358
+ for (let tier = 0; tier < liveEvidenceTiers.length; tier++) {
16359
+ liveKnown.push(
16360
+ ...pickDeterministicSubset(
16361
+ liveEvidenceTiers[tier],
16362
+ (seed ^ Math.imul(tier + 1, 0x9e3779b1)) >>> 0,
16363
+ maxPeers - liveKnown.length,
16364
+ ),
16365
+ );
16366
+ if (liveKnown.length >= maxPeers) break;
16367
+ }
16368
+ const liveKnownSet = new Set(liveKnown);
16369
+ const liveOnly = pickDeterministicSubset(
16370
+ reachable.filter((peer) => !liveKnownSet.has(peer)),
16371
+ (seed ^ 0x85ebca6b) >>> 0,
16372
+ maxPeers,
16373
+ );
16374
+ const historicalOnly: string[] = [];
16375
+ const historicalSeen = new Set<string>();
16376
+ for (let tier = 0; tier < evidenceTiers.length; tier++) {
16377
+ const available: string[] = [];
16378
+ let inspected = 0;
16379
+ for (const peer of evidenceTiers[tier]) {
16380
+ if (inspected++ >= LOCAL_REACHABLE_PEERS_MAX) break;
16381
+ if (
16382
+ !peer ||
16383
+ peer === self ||
16384
+ reachableSet.has(peer) ||
16385
+ historicalSeen.has(peer)
16386
+ ) {
16387
+ continue;
16155
16388
  }
16389
+ historicalSeen.add(peer);
16390
+ available.push(peer);
16156
16391
  }
16157
-
16158
- if (!candidates || candidates.length === 0) {
16159
- const connectionManager = (this.node.services.pubsub as any)?.components
16160
- ?.connectionManager;
16161
- const connections = connectionManager?.getConnections?.() ?? [];
16162
- const connectionHashes: string[] = [];
16163
- for (const conn of connections) {
16164
- const peerId = conn?.remotePeer;
16165
- if (!peerId) continue;
16166
- try {
16167
- connectionHashes.push(getPublicKeyFromPeerId(peerId).hashcode());
16168
- } catch {
16169
- // Best-effort only.
16170
- }
16171
- }
16172
- if (connectionHashes.length > 0) {
16173
- candidates = connectionHashes;
16174
- }
16392
+ historicalOnly.push(
16393
+ ...pickDeterministicSubset(
16394
+ available,
16395
+ (seed ^ Math.imul(tier + 5, 0x9e3779b1)) >>> 0,
16396
+ maxPeers - historicalOnly.length,
16397
+ ),
16398
+ );
16399
+ if (historicalOnly.length >= maxPeers) break;
16400
+ }
16401
+ const selected = liveKnown.slice(0, maxPeers);
16402
+ let liveIndex = 0;
16403
+ let historicalIndex = 0;
16404
+ while (
16405
+ selected.length < maxPeers &&
16406
+ (liveIndex < liveOnly.length || historicalIndex < historicalOnly.length)
16407
+ ) {
16408
+ if (liveIndex < liveOnly.length) {
16409
+ selected.push(liveOnly[liveIndex++]);
16410
+ }
16411
+ if (
16412
+ selected.length < maxPeers &&
16413
+ historicalIndex < historicalOnly.length
16414
+ ) {
16415
+ selected.push(historicalOnly[historicalIndex++]);
16175
16416
  }
16176
16417
  }
16177
16418
 
16178
- if (!candidates || candidates.length === 0) return undefined;
16179
- const peers = candidates.filter((p) => p !== self);
16180
- if (peers.length === 0) return undefined;
16181
- return pickDeterministicSubset(peers, seed, maxPeers);
16419
+ return selected.length > 0 ? selected : undefined;
16182
16420
  }
16183
16421
 
16184
16422
  async getMemoryUsage() {
@@ -16580,6 +16818,9 @@ export class SharedLog<
16580
16818
  (removed) => removed.hash,
16581
16819
  );
16582
16820
  this.onEntryRemovedHashes(deferredCoordinateDeleteHashes);
16821
+ this.scheduleDeadGidPeerHistoryReclaim(
16822
+ change.removed.map((removed) => removed.meta.gid),
16823
+ );
16583
16824
  if (options?.forgetNativeCoordinates === false) {
16584
16825
  this._coordinates.forgetResidentCoordinateStateForHashes(
16585
16826
  deferredCoordinateDeleteHashes,
@@ -16610,6 +16851,7 @@ export class SharedLog<
16610
16851
  options?: {
16611
16852
  forgetNativeCoordinates?: boolean;
16612
16853
  removedHashes?: string[];
16854
+ removedGids?: string[];
16613
16855
  ownershipLifecycleController?: AbortController;
16614
16856
  },
16615
16857
  ): string[] | undefined {
@@ -16630,6 +16872,11 @@ export class SharedLog<
16630
16872
  ? normalizedHashValues(removedHashes)
16631
16873
  : removed.map((entry) => entry.hash);
16632
16874
  this.onEntryRemovedHashes(deferredCoordinateDeleteHashes);
16875
+ this.scheduleDeadGidPeerHistoryReclaim(
16876
+ removed.length > 0
16877
+ ? removed.map((entry) => entry.meta.gid)
16878
+ : options?.removedGids,
16879
+ );
16633
16880
  if (options?.forgetNativeCoordinates === false) {
16634
16881
  this._coordinates.forgetResidentCoordinateStateForHashes(
16635
16882
  deferredCoordinateDeleteHashes,
@@ -16663,9 +16910,135 @@ export class SharedLog<
16663
16910
  }
16664
16911
  this.onEntryRemoved(removed.hash);
16665
16912
  }
16913
+ this.scheduleDeadGidPeerHistoryReclaim(
16914
+ removedEntries.map((removed) => removed.meta.gid),
16915
+ );
16666
16916
  return undefined;
16667
16917
  }
16668
16918
 
16919
+ private async reclaimDeadGidPeerHistory(
16920
+ gids: Iterable<string> | undefined,
16921
+ history = this._gidPeersHistory,
16922
+ ): Promise<void> {
16923
+ if (!gids) return;
16924
+ const candidates = [...new Set(gids)].filter(
16925
+ (gid) =>
16926
+ history === this._gidPeersHistory && !!gid && history.has(gid),
16927
+ );
16928
+ if (candidates.length === 0) return;
16929
+ const hasHeads = await this.hasAnyHeadForGidSets(
16930
+ candidates.map((gid) => [gid]),
16931
+ );
16932
+ if (history !== this._gidPeersHistory) return;
16933
+ for (let index = 0; index < candidates.length; index++) {
16934
+ const gid = candidates[index]!;
16935
+ if (!hasHeads[index] && history.has(gid)) {
16936
+ this.deleteGidPeerHistory(gid);
16937
+ }
16938
+ }
16939
+ }
16940
+
16941
+ private resetGidPeerHistoryCleanupState(): void {
16942
+ this._gidPeerHistoryCleanupState = {
16943
+ history: this._gidPeersHistory,
16944
+ tail: Promise.resolve(),
16945
+ pending: new Set(),
16946
+ draining: false,
16947
+ highWater: 0,
16948
+ };
16949
+ }
16950
+
16951
+ private scheduleDeadGidPeerHistoryReclaim(
16952
+ gids: Iterable<string> | undefined,
16953
+ ): void {
16954
+ if (!gids) return;
16955
+ const state = this._gidPeerHistoryCleanupState;
16956
+ const history = state.history;
16957
+ for (const gid of gids) {
16958
+ if (!gid || !history.has(gid)) continue;
16959
+ if (state.pending.has(gid)) continue;
16960
+ if (
16961
+ state.pending.size >= GID_PEER_HISTORY_CLEANUP_PENDING_CAPACITY
16962
+ ) {
16963
+ // History is only a suppression memo. Under sustained synchronous
16964
+ // production, forgetting an overflow row is the bounded, safe fallback:
16965
+ // it can cause redundant delivery but cannot affect ownership or data.
16966
+ this.deleteGidPeerHistory(gid);
16967
+ continue;
16968
+ }
16969
+ state.pending.add(gid);
16970
+ }
16971
+ state.highWater = Math.max(state.highWater, state.pending.size);
16972
+ if (state.pending.size === 0) return;
16973
+ // A macrotask gate lets sequential awaited appends coalesce; a Promise
16974
+ // microtask runs before the caller can enqueue the next trimmed gid. The
16975
+ // fixed early-wake threshold also amortizes latest-only (retain=1) logs,
16976
+ // while the separate pending cap remains the absolute memory bound.
16977
+ const wakeThreshold = GID_PEER_HISTORY_CLEANUP_BATCH_SIZE;
16978
+ if (state.draining) {
16979
+ if (state.pending.size >= wakeThreshold) state.wake?.();
16980
+ return;
16981
+ }
16982
+ state.draining = true;
16983
+ let wake!: () => void;
16984
+ const batchReady = new Promise<void>((resolve) => {
16985
+ let settled = false;
16986
+ let cancel = () => {};
16987
+ wake = () => {
16988
+ if (settled) return;
16989
+ settled = true;
16990
+ cancel();
16991
+ if (state.wake === wake) state.wake = undefined;
16992
+ resolve();
16993
+ };
16994
+ state.wake = wake;
16995
+ if (typeof setImmediate === "function") {
16996
+ const handle = setImmediate(wake);
16997
+ cancel = () => clearImmediate(handle);
16998
+ } else {
16999
+ const handle = setTimeout(wake, 0);
17000
+ cancel = () => clearTimeout(handle);
17001
+ }
17002
+ });
17003
+ if (state.pending.size >= wakeThreshold) wake();
17004
+ const drain = state.tail
17005
+ .then(async () => {
17006
+ await batchReady;
17007
+ while (history === this._gidPeersHistory) {
17008
+ const batch = [...state.pending].slice(
17009
+ 0,
17010
+ GID_PEER_HISTORY_CLEANUP_BATCH_SIZE,
17011
+ );
17012
+ if (batch.length === 0) break;
17013
+ for (const gid of batch) state.pending.delete(gid);
17014
+ try {
17015
+ await this.reclaimDeadGidPeerHistory(batch, history);
17016
+ } catch (error) {
17017
+ warn(`Failed to reclaim gid peer history: ${String(error)}`);
17018
+ if (history === this._gidPeersHistory) {
17019
+ for (const gid of batch) this.deleteGidPeerHistory(gid);
17020
+ }
17021
+ }
17022
+ }
17023
+ })
17024
+ .catch((error) => {
17025
+ warn(`Failed to reclaim gid peer history: ${String(error)}`);
17026
+ });
17027
+ state.tail = drain.finally(() => {
17028
+ state.draining = false;
17029
+ if (history !== this._gidPeersHistory) {
17030
+ state.pending.clear();
17031
+ }
17032
+ if (
17033
+ history === this._gidPeersHistory &&
17034
+ state.pending.size > 0 &&
17035
+ state === this._gidPeerHistoryCleanupState
17036
+ ) {
17037
+ this.scheduleDeadGidPeerHistoryReclaim([]);
17038
+ }
17039
+ });
17040
+ }
17041
+
16669
17042
  async canAppend(entry: Entry<T>) {
16670
17043
  try {
16671
17044
  if (!entry.meta.data) {
@@ -17213,6 +17586,7 @@ export class SharedLog<
17213
17586
  this.recentlyRebalanced?.clear();
17214
17587
  this.uniqueReplicators?.clear();
17215
17588
  this._topicSubscribersCache?.clear();
17589
+ this._localReachablePeerHashesCache?.clear();
17216
17590
  this._closeController.abort();
17217
17591
  clearInterval(this.interval);
17218
17592
  this._liveness?.stopReplicatorLivenessSweep();
@@ -24678,7 +25052,8 @@ export class SharedLog<
24678
25052
 
24679
25053
  async rebalanceAll(options?: { clearCache?: boolean }) {
24680
25054
  if (options?.clearCache) {
24681
- this._gidPeersHistory.clear();
25055
+ this._gidPeersHistory = new Map();
25056
+ this.resetGidPeerHistoryCleanupState();
24682
25057
  this._nativeSharedLogState?.clearGidPeers();
24683
25058
  this._nativeBackbone?.clearGidPeers();
24684
25059
  }
@@ -25262,9 +25637,11 @@ export class SharedLog<
25262
25637
 
25263
25638
  const fromHash = evt.detail.from.hashcode();
25264
25639
  const subscriptionEpoch = this._peerSessions.rotate(fromHash, "opening");
25265
- this.remoteBlocks.onReachable(evt.detail.from);
25266
25640
  this._peerSessions.blockReplicationInfo(fromHash);
25267
25641
  this.invalidateSharedLogTopicSubscribersCache();
25642
+ // Invalidate the local reachability snapshot before waking block reads;
25643
+ // their forced resolver runs synchronously from the reachable event.
25644
+ this.remoteBlocks.onReachable(evt.detail.from);
25268
25645
 
25269
25646
  await this.handleSubscriptionChange(
25270
25647
  evt.detail.from,