@peerbit/blocks 4.2.5 → 4.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/remote.ts CHANGED
@@ -3,10 +3,12 @@ import { TypedEventEmitter } from "@libp2p/interface";
3
3
  import {
4
4
  type GetOptions,
5
5
  type Blocks as IBlocks,
6
- checkDecodeBlock,
7
6
  cidifyString,
8
7
  codecCodes,
8
+ codecMap,
9
+ defaultHasher,
9
10
  stringifyCid,
11
+ verifyBlockBytes,
10
12
  } from "@peerbit/blocks-interface";
11
13
  import { Cache } from "@peerbit/cache";
12
14
  import { PublicSignKey } from "@peerbit/crypto";
@@ -18,6 +20,8 @@ import {
18
20
  dontThrowIfDeliveryError,
19
21
  } from "@peerbit/stream";
20
22
  import {
23
+ BACKGROUND_MESSAGE_PRIORITY,
24
+ FOREGROUND_READ_MESSAGE_PRIORITY,
21
25
  type PeerRefs,
22
26
  type RequestTransportContext,
23
27
  SilentDelivery,
@@ -29,6 +33,14 @@ import { AbortError } from "@peerbit/time";
29
33
  import { CID } from "multiformats";
30
34
  import { type Block } from "multiformats/block";
31
35
  import PQueue from "p-queue";
36
+ import {
37
+ BoundedEagerBlockCache,
38
+ type EagerBlockCache,
39
+ type EagerBlocksSetting,
40
+ MAX_EAGER_BLOCK_CID_LENGTH,
41
+ type NormalizedEagerBlocksOptions,
42
+ normalizeEagerBlocksOptions,
43
+ } from "./eager-cache.js";
32
44
  import type { BlockStore } from "./interface.js";
33
45
 
34
46
  export const logger = loggerFn("peerbit:transport:blocks");
@@ -70,23 +82,46 @@ type BlockMessageContext = {
70
82
  type InFlightRead = {
71
83
  promise: Promise<Block<any, any, any, 1> | undefined>;
72
84
  addProviders: (providers: string[]) => void;
85
+ promotePriority: (priority: number | undefined) => void;
73
86
  };
74
87
 
75
88
  type RemoteReadOptions = Exclude<GetOptions["remote"], boolean | undefined> & {
76
89
  hasher?: any;
77
90
  };
78
91
 
79
- /**
80
- * Shared shape of the TS eager-block cache (`Cache<Uint8Array>`) and the
81
- * native-backed one (`RustEagerBlockCache`).
82
- */
83
- type EagerBlockCache = {
84
- add(cid: string, bytes: Uint8Array): void;
85
- get(cid: string): Uint8Array | null | undefined;
86
- del(cid: string): unknown;
87
- clear(): void;
92
+ export type EagerBlockCacheTelemetry = {
93
+ entries: number;
94
+ bytes: number;
95
+ peakEntries: number;
96
+ peakBytes: number;
97
+ evictions: number;
98
+ expirations: number;
99
+ pendingEntries: number;
100
+ pendingBytes: number;
101
+ peakPendingEntries: number;
102
+ peakPendingBytes: number;
103
+ admitted: number;
104
+ hits: number;
105
+ rejectedCid: number;
106
+ rejectedCodec: number;
107
+ rejectedSize: number;
108
+ rejectedPending: number;
109
+ rejectedIntegrity: number;
110
+ rejectedLifecycle: number;
111
+ limits: NormalizedEagerBlocksOptions;
88
112
  };
89
113
 
114
+ type EagerAdmissionCounters = Omit<
115
+ EagerBlockCacheTelemetry,
116
+ | "entries"
117
+ | "bytes"
118
+ | "peakEntries"
119
+ | "peakBytes"
120
+ | "evictions"
121
+ | "expirations"
122
+ | "limits"
123
+ >;
124
+
90
125
  export class RemoteBlocks implements IBlocks {
91
126
  localStore: BlockStore;
92
127
 
@@ -117,8 +152,27 @@ export class RemoteBlocks implements IBlocks {
117
152
  data: BlockMessage,
118
153
  context?: BlockMessageContext,
119
154
  ) => any;
120
- private _resolvers: Map<string, (data: Uint8Array) => Promise<void>>;
155
+ private _resolvers: Map<
156
+ string,
157
+ (data: Uint8Array, providerHash?: string) => Promise<void>
158
+ >;
121
159
  private _blockCache?: EagerBlockCache;
160
+ private _eagerBlocksOptions?: NormalizedEagerBlocksOptions;
161
+ private _eagerValidationQueue?: PQueue;
162
+ private _eagerAdmission: EagerAdmissionCounters = {
163
+ pendingEntries: 0,
164
+ pendingBytes: 0,
165
+ peakPendingEntries: 0,
166
+ peakPendingBytes: 0,
167
+ admitted: 0,
168
+ hits: 0,
169
+ rejectedCid: 0,
170
+ rejectedCodec: 0,
171
+ rejectedSize: 0,
172
+ rejectedPending: 0,
173
+ rejectedIntegrity: 0,
174
+ rejectedLifecycle: 0,
175
+ };
122
176
  private _providerCache?: Cache<string[]>;
123
177
  private _rustProviderCache?: RustBlockProviderCache;
124
178
  private readonly rustExchange?: RustBlockExchange;
@@ -127,6 +181,7 @@ export class RemoteBlocks implements IBlocks {
127
181
  private readonly maxRequeryOnReachable: number;
128
182
 
129
183
  private _loadFetchQueue: PQueue;
184
+ private _backgroundLoadFetchQueue: PQueue;
130
185
  private _readFromPeersPromises: Map<string, InFlightRead>;
131
186
  private _deferredStoredNotificationCids?: Set<string>;
132
187
  private _deferredStoredNotificationTimer?: ReturnType<typeof setTimeout>;
@@ -143,7 +198,7 @@ export class RemoteBlocks implements IBlocks {
143
198
  localTimeout?: number;
144
199
  messageProcessingConcurrency?: number;
145
200
  publicKey: PublicSignKey;
146
- eagerBlocks?: boolean | { cacheSize?: number };
201
+ eagerBlocks?: EagerBlocksSetting;
147
202
  /**
148
203
  * Optional provider resolver used when `remote: true` is used without `remote.from`.
149
204
  *
@@ -219,8 +274,19 @@ export class RemoteBlocks implements IBlocks {
219
274
  const localTimeout = options?.localTimeout || 1000;
220
275
  this.publicKeyHash = options.publicKey.hashcode();
221
276
  this.rustExchange = options.rust?.exchange;
277
+ const messageProcessingConcurrency =
278
+ options?.messageProcessingConcurrency || 10;
222
279
  this._loadFetchQueue = new PQueue({
223
- concurrency: options?.messageProcessingConcurrency || 10,
280
+ concurrency: messageProcessingConcurrency,
281
+ });
282
+ // A provider handler includes the response publication. When every handler
283
+ // is publishing bulk/background blocks under backpressure, a single priority
284
+ // queue cannot preempt that already-active work. Admit at most N-1 background
285
+ // handlers so foreground reads can use the reserved slot whenever the
286
+ // configured concurrency is at least two. Foreground-only traffic can still
287
+ // use the full configured concurrency.
288
+ this._backgroundLoadFetchQueue = new PQueue({
289
+ concurrency: Math.max(1, messageProcessingConcurrency - 1),
224
290
  });
225
291
  this.localStore = options?.local;
226
292
  const localPutKnownManyColumns = (
@@ -314,13 +380,23 @@ export class RemoteBlocks implements IBlocks {
314
380
  this._resolvers = new Map();
315
381
  this._readFromPeersPromises = new Map();
316
382
  if (options?.eagerBlocks) {
317
- const eagerBlocksMax =
318
- typeof options.eagerBlocks === "boolean"
319
- ? 1e3
320
- : (options.eagerBlocks.cacheSize ?? 1e3);
383
+ this._eagerBlocksOptions = normalizeEagerBlocksOptions(
384
+ options.eagerBlocks,
385
+ );
386
+ this._eagerValidationQueue = new PQueue({
387
+ concurrency: this._eagerBlocksOptions.validationConcurrency,
388
+ });
321
389
  this._blockCache = this.rustExchange
322
- ? this.rustExchange.createEagerCache({ max: eagerBlocksMax, ttl: 1e4 })
323
- : new Cache<Uint8Array>({ max: eagerBlocksMax, ttl: 1e4 });
390
+ ? this.rustExchange.createEagerCache({
391
+ maxEntries: this._eagerBlocksOptions.maxEntries,
392
+ maxBytes: this._eagerBlocksOptions.maxBytes,
393
+ ttlMs: this._eagerBlocksOptions.ttlMs,
394
+ })
395
+ : new BoundedEagerBlockCache({
396
+ maxEntries: this._eagerBlocksOptions.maxEntries,
397
+ maxBytes: this._eagerBlocksOptions.maxBytes,
398
+ ttlMs: this._eagerBlocksOptions.ttlMs,
399
+ });
324
400
  }
325
401
  type ProviderCacheOptions = {
326
402
  maxEntries?: number;
@@ -357,28 +433,40 @@ export class RemoteBlocks implements IBlocks {
357
433
  ) => {
358
434
  try {
359
435
  if (message instanceof BlockRequest && this.localStore) {
360
- this._loadFetchQueue
361
- .add(() => this.handleFetchRequest(message, localTimeout, context))
362
- .catch((e) => {
363
- try {
364
- dontThrowIfDeliveryError(e);
365
- } catch (error) {
366
- logger.error("Got error for libp2p block transport: ", error);
367
- }
368
- });
436
+ const priority =
437
+ context?.transport?.requestPriority ?? BACKGROUND_MESSAGE_PRIORITY;
438
+ const queueSignal = this.closeController.signal;
439
+ const run = () =>
440
+ this._loadFetchQueue.add(
441
+ () =>
442
+ queueSignal.aborted
443
+ ? undefined
444
+ : this.handleFetchRequest(message, localTimeout, context),
445
+ {
446
+ priority,
447
+ },
448
+ );
449
+ const scheduled =
450
+ priority >= FOREGROUND_READ_MESSAGE_PRIORITY
451
+ ? run()
452
+ : this._backgroundLoadFetchQueue.add(
453
+ () => (queueSignal.aborted ? undefined : run()),
454
+ { priority },
455
+ );
456
+ scheduled.catch((e) => {
457
+ if (queueSignal.aborted) return;
458
+ try {
459
+ dontThrowIfDeliveryError(e);
460
+ } catch (error) {
461
+ logger.error("Got error for libp2p block transport: ", error);
462
+ }
463
+ });
369
464
  } else if (message instanceof BlockResponse) {
370
- // TODO make sure we are not storing too much bytes in ram (like filter large blocks)
371
- if (context?.from) {
372
- this.rememberProvider(message.cid, context.from);
373
- }
374
- let resolver = this._resolvers.get(message.cid);
465
+ const resolver = this._resolvers.get(message.cid);
375
466
  if (!resolver) {
376
- if (options.eagerBlocks) {
377
- // wait for the resolve to exist
378
- this._blockCache!.add(message.cid, message.bytes);
379
- }
467
+ this.queueEagerBlock(message.cid, message.bytes, context?.from);
380
468
  } else {
381
- await resolver(message.bytes);
469
+ await resolver(message.bytes, context?.from);
382
470
  }
383
471
  }
384
472
  } catch (error) {
@@ -392,6 +480,170 @@ export class RemoteBlocks implements IBlocks {
392
480
  return this.localStore.getNativeLogBlockStoreHandle?.();
393
481
  }
394
482
 
483
+ /** Snapshot of bounded eager-response admission and retention state. */
484
+ getEagerBlockCacheTelemetry(): EagerBlockCacheTelemetry | undefined {
485
+ if (!this._blockCache || !this._eagerBlocksOptions) return undefined;
486
+ return {
487
+ ...this._blockCache.stats(),
488
+ ...this._eagerAdmission,
489
+ limits: { ...this._eagerBlocksOptions },
490
+ };
491
+ }
492
+
493
+ /** Primarily useful for diagnostics and deterministic tests. */
494
+ waitForEagerBlockValidation(): Promise<void> {
495
+ return this._eagerValidationQueue?.onIdle() ?? Promise.resolve();
496
+ }
497
+
498
+ private queueEagerBlock(
499
+ cidString: string,
500
+ incomingBytes: Uint8Array,
501
+ providerHash?: string,
502
+ ): void {
503
+ const limits = this._eagerBlocksOptions;
504
+ const queue = this._eagerValidationQueue;
505
+ const cache = this._blockCache;
506
+ if (!limits || !queue || !cache) return;
507
+
508
+ const generationSignal = this.closeController.signal;
509
+ if (generationSignal.aborted) {
510
+ this._eagerAdmission.rejectedLifecycle += 1;
511
+ return;
512
+ }
513
+ if (!cidString || cidString.length > MAX_EAGER_BLOCK_CID_LENGTH) {
514
+ this._eagerAdmission.rejectedCid += 1;
515
+ return;
516
+ }
517
+
518
+ let cidObject: CID;
519
+ let canonicalCid: string;
520
+ try {
521
+ cidObject = cidifyString(cidString);
522
+ canonicalCid = stringifyCid(cidObject);
523
+ if (
524
+ cidObject.multihash.code !== defaultHasher.code ||
525
+ canonicalCid.length > MAX_EAGER_BLOCK_CID_LENGTH
526
+ ) {
527
+ throw new Error("unsupported eager block cid");
528
+ }
529
+ } catch {
530
+ this._eagerAdmission.rejectedCid += 1;
531
+ return;
532
+ }
533
+ // Logical DAG-CBOR decoding can materialize even hash-valid attacker-
534
+ // controlled object graphs and expand a small wire payload by orders of
535
+ // magnitude. Eager admission therefore verifies only raw bytes; all other
536
+ // codecs remain available through the requested-response path.
537
+ if (cidObject.code !== codecMap.raw.code) {
538
+ this._eagerAdmission.rejectedCodec += 1;
539
+ return;
540
+ }
541
+ const codec = codecMap.raw;
542
+
543
+ const byteLength = incomingBytes.byteLength;
544
+ if (
545
+ byteLength > limits.maxBlockBytes ||
546
+ byteLength > limits.maxBytes ||
547
+ byteLength > limits.maxPendingBytes
548
+ ) {
549
+ this._eagerAdmission.rejectedSize += 1;
550
+ return;
551
+ }
552
+ if (
553
+ this._eagerAdmission.pendingEntries >= limits.maxPendingEntries ||
554
+ this._eagerAdmission.pendingBytes + byteLength > limits.maxPendingBytes
555
+ ) {
556
+ this._eagerAdmission.rejectedPending += 1;
557
+ return;
558
+ }
559
+
560
+ // The decoder commonly returns a subarray into the complete network frame.
561
+ // Copy exactly the block bytes before queuing so no larger backing buffer or
562
+ // response object remains retained while integrity validation is pending.
563
+ const bytes = new Uint8Array(byteLength);
564
+ bytes.set(incomingBytes);
565
+ this._eagerAdmission.pendingEntries += 1;
566
+ this._eagerAdmission.pendingBytes += byteLength;
567
+ this._eagerAdmission.peakPendingEntries = Math.max(
568
+ this._eagerAdmission.peakPendingEntries,
569
+ this._eagerAdmission.pendingEntries,
570
+ );
571
+ this._eagerAdmission.peakPendingBytes = Math.max(
572
+ this._eagerAdmission.peakPendingBytes,
573
+ this._eagerAdmission.pendingBytes,
574
+ );
575
+
576
+ let released = false;
577
+ const releaseReservation = () => {
578
+ if (released) return;
579
+ released = true;
580
+ this._eagerAdmission.pendingEntries -= 1;
581
+ this._eagerAdmission.pendingBytes -= byteLength;
582
+ };
583
+ const validateAndAdmit = async () => {
584
+ try {
585
+ if (generationSignal.aborted) {
586
+ this._eagerAdmission.rejectedLifecycle += 1;
587
+ return;
588
+ }
589
+ try {
590
+ await this.validateEagerBlock(cidObject, bytes, codec);
591
+ } catch {
592
+ this._eagerAdmission.rejectedIntegrity += 1;
593
+ return;
594
+ }
595
+ if (generationSignal.aborted) {
596
+ this._eagerAdmission.rejectedLifecycle += 1;
597
+ return;
598
+ }
599
+ const resolver =
600
+ this._resolvers.get(canonicalCid) ?? this._resolvers.get(cidString);
601
+ if (resolver) {
602
+ try {
603
+ // A read can install its resolver while eager validation is in
604
+ // progress. Let that resolver enforce its own (possibly custom)
605
+ // hasher contract and learn the provider only if it succeeds.
606
+ await resolver(bytes, providerHash);
607
+ } catch {
608
+ // Match an invalid active response: drop it and leave the read open.
609
+ }
610
+ return;
611
+ }
612
+ if (!cache.add(canonicalCid, bytes)) {
613
+ this._eagerAdmission.rejectedSize += 1;
614
+ return;
615
+ }
616
+ this._eagerAdmission.admitted += 1;
617
+ if (providerHash) {
618
+ // An unsolicited response is only a provider signal after both its
619
+ // CID and payload have passed the integrity gate.
620
+ this.rememberProvider(canonicalCid, providerHash);
621
+ }
622
+ } finally {
623
+ releaseReservation();
624
+ }
625
+ };
626
+ try {
627
+ void queue.add(validateAndAdmit).catch(() => {
628
+ releaseReservation();
629
+ if (!generationSignal.aborted) {
630
+ this._eagerAdmission.rejectedLifecycle += 1;
631
+ }
632
+ });
633
+ } catch {
634
+ releaseReservation();
635
+ this._eagerAdmission.rejectedLifecycle += 1;
636
+ }
637
+ }
638
+
639
+ private validateEagerBlock(
640
+ cid: CID,
641
+ bytes: Uint8Array,
642
+ codec: (typeof codecCodes)[keyof typeof codecCodes],
643
+ ) {
644
+ return verifyBlockBytes(cid, bytes, { codec });
645
+ }
646
+
395
647
  private normalizeProviderHints(
396
648
  providers: string[] | undefined,
397
649
  limit = this.maxProviderHintsPerCid || 8,
@@ -663,7 +915,9 @@ export class RemoteBlocks implements IBlocks {
663
915
  const cidObject = cidifyString(cid);
664
916
  value = await this._readFromPeers(cid, cidObject, remoteOptions);
665
917
  if (remoteOptions?.replicate && value) {
666
- await this.put(value);
918
+ // _readFromPeers verifies the response bytes against cid before it
919
+ // resolves, so avoid hashing the full block a second time here.
920
+ await this.putKnown(cid, value);
667
921
  }
668
922
  }
669
923
  }
@@ -792,6 +1046,7 @@ export class RemoteBlocks implements IBlocks {
792
1046
  signal: controller.signal,
793
1047
  timeout: proxyTimeoutMs,
794
1048
  from: providers,
1049
+ priority: context?.transport?.requestPriority,
795
1050
  });
796
1051
  }
797
1052
  } finally {
@@ -834,26 +1089,38 @@ export class RemoteBlocks implements IBlocks {
834
1089
 
835
1090
  const codec = codecCodes[cidObject.code as keyof typeof codecCodes];
836
1091
 
837
- const tryDecode = async (bytes: Uint8Array) => {
838
- const value = await checkDecodeBlock(cidObject, bytes, {
1092
+ const tryVerify = async (bytes: Uint8Array) => {
1093
+ const verifiedCid = await verifyBlockBytes(cidObject, bytes, {
839
1094
  codec,
840
1095
  hasher: options?.hasher,
841
1096
  });
842
-
843
- return value;
1097
+ // This block-shaped value is internal bookkeeping only. RemoteBlocks moves
1098
+ // opaque bytes and must not invoke the logical codec at this boundary.
1099
+ return {
1100
+ bytes,
1101
+ cid: verifiedCid,
1102
+ value: bytes,
1103
+ } as Block<Uint8Array, any, any, 1>;
844
1104
  };
845
- const cachedValue = this.options.eagerBlocks
846
- ? this._blockCache?.get(cidString)
847
- : undefined;
848
- if (cachedValue) {
849
- this._blockCache!.del(cidString);
850
- try {
851
- const result = await tryDecode(cachedValue);
852
- return result.bytes;
853
- } catch (error) {
854
- // ignore
1105
+ const eagerCacheKey = stringifyCid(cidObject);
1106
+ const consumeCachedValue = (): Uint8Array | undefined => {
1107
+ if (options.hasher && options.hasher !== defaultHasher) {
1108
+ // Eager admission proves the built-in SHA-256 contract only. Keep this
1109
+ // lookup synchronous and leave custom hashers on the requested-response
1110
+ // path; asynchronously revalidating cached entries would reopen a race
1111
+ // before the read resolver is installed.
1112
+ return undefined;
855
1113
  }
856
- }
1114
+ const cachedValue = this._blockCache?.get(eagerCacheKey);
1115
+ if (cachedValue === undefined) return undefined;
1116
+ this._blockCache!.del(eagerCacheKey);
1117
+ this._eagerAdmission.hits += 1;
1118
+ // Eager entries are inserted only after verifyBlockBytes succeeds. The
1119
+ // one-shot cache hit therefore does not hash the full block a second time.
1120
+ return cachedValue;
1121
+ };
1122
+ let cachedResult = consumeCachedValue();
1123
+ if (cachedResult) return cachedResult;
857
1124
 
858
1125
  const explicitFrom = this.normalizeProviderHints(options.from);
859
1126
  let providers =
@@ -865,6 +1132,11 @@ export class RemoteBlocks implements IBlocks {
865
1132
  // A resolver may observe abort and still complete normally. Do not create a
866
1133
  // timeout-backed read from the candidates it returns after shutdown.
867
1134
  throwIfReadWasAborted();
1135
+ // Provider resolution is asynchronous. An eager validation can complete and
1136
+ // cache the response during that await, so consume it before either returning
1137
+ // for lack of providers or installing a resolver that would otherwise wait.
1138
+ cachedResult = consumeCachedValue();
1139
+ if (cachedResult) return cachedResult;
868
1140
  const canResolveLater = typeof this.options.resolveProviders === "function";
869
1141
  if (providers.length === 0 && !canResolveLater) {
870
1142
  // Without an explicit provider set (or a resolver), we intentionally do not
@@ -878,11 +1150,14 @@ export class RemoteBlocks implements IBlocks {
878
1150
 
879
1151
  let inFlight = this._readFromPeersPromises.get(cidString);
880
1152
  if (!inFlight) {
1153
+ let requestPriority = options.priority ?? BACKGROUND_MESSAGE_PRIORITY;
881
1154
  let publishAdditionalProviders: (providers: string[]) => void = () => {};
882
1155
  const promise = new Promise<Block<any, any, any, 1> | undefined>(
883
1156
  (resolve, reject) => {
884
1157
  let timeoutCallback: ReturnType<typeof setTimeout> | undefined;
885
- let resolver: ((bytes: Uint8Array) => Promise<void>) | undefined;
1158
+ let resolver:
1159
+ | ((bytes: Uint8Array, providerHash?: string) => Promise<void>)
1160
+ | undefined;
886
1161
  let settled = false;
887
1162
  const abortHandler = () => {
888
1163
  cleanup();
@@ -921,9 +1196,14 @@ export class RemoteBlocks implements IBlocks {
921
1196
  options.timeout || 30 * 1000,
922
1197
  );
923
1198
 
924
- resolver = async (bytes: Uint8Array) => {
925
- const value = await tryDecode(bytes);
1199
+ resolver = async (bytes: Uint8Array, providerHash?: string) => {
1200
+ const value = await tryVerify(bytes);
926
1201
  if (settled) return;
1202
+ if (providerHash) {
1203
+ // A response is only evidence that its sender can provide this CID
1204
+ // after the payload has passed the requested CID's integrity check.
1205
+ this.rememberProvider(cidString, providerHash);
1206
+ }
927
1207
  settled = true;
928
1208
  cleanup();
929
1209
  resolve(value);
@@ -976,8 +1256,8 @@ export class RemoteBlocks implements IBlocks {
976
1256
  : this.pickRequestBatch(providers, requeryCount);
977
1257
  if (requestProviders.length === 0) return;
978
1258
  await this.options.publish(new BlockRequest(cidString), {
979
- priority: options.priority,
980
- responsePriority: options.priority,
1259
+ priority: requestPriority,
1260
+ responsePriority: requestPriority,
981
1261
  expiresAt,
982
1262
  mode: new SilentDelivery({
983
1263
  to: requestProviders,
@@ -1017,6 +1297,20 @@ export class RemoteBlocks implements IBlocks {
1017
1297
  promise,
1018
1298
  addProviders: (nextProviders) =>
1019
1299
  publishAdditionalProviders(nextProviders),
1300
+ promotePriority: (nextPriority) => {
1301
+ if (!this._resolvers.has(cidString)) return;
1302
+ // A background replication read may win CID coalescing before a
1303
+ // foreground waiter arrives. Reissue only on a strict priority upgrade.
1304
+ const promotedPriority = nextPriority ?? BACKGROUND_MESSAGE_PRIORITY;
1305
+ if (
1306
+ !Number.isFinite(promotedPriority) ||
1307
+ promotedPriority <= requestPriority
1308
+ ) {
1309
+ return;
1310
+ }
1311
+ requestPriority = promotedPriority;
1312
+ tryPublishRequest({ force: true }).catch(dontThrowIfDeliveryError);
1313
+ },
1020
1314
  };
1021
1315
  this._readFromPeersPromises.set(cidString, inFlight);
1022
1316
 
@@ -1101,6 +1395,7 @@ export class RemoteBlocks implements IBlocks {
1101
1395
  }
1102
1396
  }
1103
1397
  } else {
1398
+ inFlight.promotePriority(options.priority);
1104
1399
  if (providers.length > 0) {
1105
1400
  inFlight.addProviders(providers);
1106
1401
  }
@@ -1174,8 +1469,12 @@ export class RemoteBlocks implements IBlocks {
1174
1469
  this._deferredStoredNotificationTimer = undefined;
1175
1470
  }
1176
1471
  await capture(() => this.flushDeferredStoredNotifications());
1177
- await capture(() => this._loadFetchQueue.clear());
1472
+ // Queued wrappers observe the aborted generation and drain without starting
1473
+ // new work. Avoid PQueue.clear(): it would strand promises already returned
1474
+ // by the nested background-admission queue.
1475
+ await capture(() => this._backgroundLoadFetchQueue.onIdle());
1178
1476
  await capture(() => this._loadFetchQueue.onIdle());
1477
+ await capture(() => this._eagerValidationQueue?.onIdle());
1179
1478
  await capture(() => this.localStore?.stop());
1180
1479
  this._readFromPeersPromises.clear();
1181
1480
  this._resolvers.clear();