@peerbit/shared-log 12.3.5 → 13.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/benchmark/sync-batch-sweep.d.ts +2 -0
  2. package/dist/benchmark/sync-batch-sweep.d.ts.map +1 -0
  3. package/dist/benchmark/sync-batch-sweep.js +305 -0
  4. package/dist/benchmark/sync-batch-sweep.js.map +1 -0
  5. package/dist/src/fanout-envelope.d.ts +18 -0
  6. package/dist/src/fanout-envelope.d.ts.map +1 -0
  7. package/dist/src/fanout-envelope.js +85 -0
  8. package/dist/src/fanout-envelope.js.map +1 -0
  9. package/dist/src/index.d.ts +55 -6
  10. package/dist/src/index.d.ts.map +1 -1
  11. package/dist/src/index.js +1595 -339
  12. package/dist/src/index.js.map +1 -1
  13. package/dist/src/pid.d.ts.map +1 -1
  14. package/dist/src/pid.js +21 -5
  15. package/dist/src/pid.js.map +1 -1
  16. package/dist/src/ranges.d.ts +3 -1
  17. package/dist/src/ranges.d.ts.map +1 -1
  18. package/dist/src/ranges.js +14 -5
  19. package/dist/src/ranges.js.map +1 -1
  20. package/dist/src/sync/index.d.ts +45 -1
  21. package/dist/src/sync/index.d.ts.map +1 -1
  22. package/dist/src/sync/rateless-iblt.d.ts +13 -2
  23. package/dist/src/sync/rateless-iblt.d.ts.map +1 -1
  24. package/dist/src/sync/rateless-iblt.js +194 -3
  25. package/dist/src/sync/rateless-iblt.js.map +1 -1
  26. package/dist/src/sync/simple.d.ts +24 -3
  27. package/dist/src/sync/simple.d.ts.map +1 -1
  28. package/dist/src/sync/simple.js +330 -32
  29. package/dist/src/sync/simple.js.map +1 -1
  30. package/package.json +16 -16
  31. package/src/fanout-envelope.ts +27 -0
  32. package/src/index.ts +2162 -691
  33. package/src/pid.ts +22 -4
  34. package/src/ranges.ts +14 -4
  35. package/src/sync/index.ts +53 -1
  36. package/src/sync/rateless-iblt.ts +237 -4
  37. package/src/sync/simple.ts +427 -41
package/src/pid.ts CHANGED
@@ -63,16 +63,34 @@ export class PIDReplicationController {
63
63
  errorCoverageUnmodified;
64
64
 
65
65
  const errorFromEven = 1 / peerCount - currentFactor;
66
+ // When the network is under-covered (`totalFactor < 1`) balancing "down" (negative
67
+ // error) can further reduce coverage and force constrained peers (memory/CPU limited)
68
+ // to take boundary assignments that exceed their budgets.
69
+ //
70
+ // Use a soft clamp: only suppress negative balance strongly when the coverage deficit
71
+ // is material. This avoids oscillations around `totalFactor ~= 1`.
72
+ const coverageDeficit = Math.max(0, errorCoverageUnmodified); // ~= max(0, 1 - totalFactor)
73
+ const negativeBalanceScale =
74
+ coverageDeficit <= 0
75
+ ? 1
76
+ : 1 - Math.min(1, coverageDeficit / 0.1); // full clamp at 10% deficit
77
+ const errorFromEvenForBalance =
78
+ errorFromEven >= 0 ? errorFromEven : errorFromEven * negativeBalanceScale;
66
79
 
67
80
  const balanceErrorScaler = this.maxMemoryLimit
68
81
  ? Math.abs(errorMemory)
69
82
  : 1 - Math.abs(errorCoverage);
70
83
 
71
- const errorBalance = (this.maxMemoryLimit ? errorMemory > 0 : true)
72
- ? errorFromEven > 0
73
- ? balanceErrorScaler * errorFromEven
84
+ // Balance should be symmetric (allow negative error) so a peer can *reduce*
85
+ // participation when peerCount increases. Otherwise early joiners can get
86
+ // "stuck" over-replicating even after new peers join (no memory/CPU limits).
87
+ const errorBalance = this.maxMemoryLimit
88
+ ? // Only balance when we have spare memory headroom. When memory is
89
+ // constrained (`errorMemory < 0`) the memory term will dominate anyway.
90
+ errorMemory > 0
91
+ ? balanceErrorScaler * errorFromEvenForBalance
74
92
  : 0
75
- : 0;
93
+ : balanceErrorScaler * errorFromEvenForBalance;
76
94
 
77
95
  const errorCPU = peerCount > 1 ? -1 * (cpuUsage || 0) : 0;
78
96
 
package/src/ranges.ts CHANGED
@@ -2565,13 +2565,17 @@ export const debounceAggregationChanges = <
2565
2565
  let aggregated: Map<string, ReplicationChange<T>> = new Map();
2566
2566
  return {
2567
2567
  add: (change: ReplicationChange<T>) => {
2568
- const prev = aggregated.get(change.range.idString);
2568
+ // Keep different change types for the same segment id. In particular, range
2569
+ // updates produce a `replaced` + `added` pair; collapsing by id would drop the
2570
+ // "removed" portion and prevent correct rebalancing/pruning.
2571
+ const key = `${change.type}:${change.range.idString}`;
2572
+ const prev = aggregated.get(key);
2569
2573
  if (prev) {
2570
2574
  if (prev.range.timestamp < change.range.timestamp) {
2571
- aggregated.set(change.range.idString, change);
2575
+ aggregated.set(key, change);
2572
2576
  }
2573
2577
  } else {
2574
- aggregated.set(change.range.idString, change);
2578
+ aggregated.set(key, change);
2575
2579
  }
2576
2580
  },
2577
2581
  delete: (key: string) => {
@@ -2703,8 +2707,14 @@ export const toRebalance = <R extends "u32" | "u64">(
2703
2707
  | ReplicationChanges<ReplicationRangeIndexable<R>>[],
2704
2708
  index: Index<EntryReplicated<R>>,
2705
2709
  rebalanceHistory: Cache<string>,
2710
+ options?: { forceFresh?: boolean },
2706
2711
  ): AsyncIterable<EntryReplicated<R>> => {
2707
- const change = mergeReplicationChanges(changeOrChanges, rebalanceHistory);
2712
+ const change = options?.forceFresh
2713
+ ? (Array.isArray(changeOrChanges[0])
2714
+ ? (changeOrChanges as ReplicationChanges<ReplicationRangeIndexable<R>>[])
2715
+ .flat()
2716
+ : (changeOrChanges as ReplicationChanges<ReplicationRangeIndexable<R>>))
2717
+ : mergeReplicationChanges(changeOrChanges, rebalanceHistory);
2708
2718
  return {
2709
2719
  [Symbol.asyncIterator]: async function* () {
2710
2720
  const iterator = index.iterate({
package/src/sync/index.ts CHANGED
@@ -24,6 +24,30 @@ export type SyncOptions<R extends "u32" | "u64"> = {
24
24
  * high-priority entries using the simple synchronizer.
25
25
  */
26
26
  maxSimpleEntries?: number;
27
+
28
+ /**
29
+ * Maximum number of hash strings in one simple sync message.
30
+ */
31
+ maxSimpleHashesPerMessage?: number;
32
+
33
+ /**
34
+ * Maximum number of coordinates in one simple sync coordinate message.
35
+ */
36
+ maxSimpleCoordinatesPerMessage?: number;
37
+
38
+ /**
39
+ * Maximum number of hashes tracked per convergent repair session target.
40
+ * Large sessions still dispatch all entries, but only this many are tracked
41
+ * for deterministic completion metadata.
42
+ */
43
+ maxConvergentTrackedHashes?: number;
44
+
45
+ /**
46
+ * Maximum number of candidate entries buffered per target before the
47
+ * background repair sweep dispatches a maybe-sync batch.
48
+ * Larger values reduce orchestration overhead but increase per-target memory.
49
+ */
50
+ repairSweepTargetBufferSize?: number;
27
51
  };
28
52
 
29
53
  export type SynchronizerComponents<R extends "u32" | "u64"> = {
@@ -41,7 +65,35 @@ export type SynchronizerConstructor<R extends "u32" | "u64"> = new (
41
65
 
42
66
  export type SyncableKey = string | bigint; // hash or coordinate
43
67
 
68
+ export type RepairSessionMode = "best-effort" | "convergent";
69
+
70
+ export type RepairSessionResult = {
71
+ target: string;
72
+ requested: number;
73
+ resolved: number;
74
+ unresolved: string[];
75
+ attempts: number;
76
+ durationMs: number;
77
+ completed: boolean;
78
+ requestedTotal?: number;
79
+ truncated?: boolean;
80
+ };
81
+
82
+ export type RepairSession = {
83
+ id: string;
84
+ done: Promise<RepairSessionResult[]>;
85
+ cancel: () => void;
86
+ };
87
+
44
88
  export interface Syncronizer<R extends "u32" | "u64"> {
89
+ startRepairSession(properties: {
90
+ entries: Map<string, EntryReplicated<R>>;
91
+ targets: string[];
92
+ mode?: RepairSessionMode;
93
+ timeoutMs?: number;
94
+ retryIntervalsMs?: number[];
95
+ }): RepairSession;
96
+
45
97
  onMaybeMissingEntries(properties: {
46
98
  entries: Map<string, EntryReplicated<R>>;
47
99
  targets: string[];
@@ -59,7 +111,7 @@ export interface Syncronizer<R extends "u32" | "u64"> {
59
111
 
60
112
  onEntryAdded(entry: Entry<any>): void;
61
113
  onEntryRemoved(hash: string): void;
62
- onPeerDisconnected(key: PublicSignKey): void;
114
+ onPeerDisconnected(key: PublicSignKey | string): void;
63
115
 
64
116
  open(): Promise<void> | void;
65
117
  close(): Promise<void> | void;
@@ -1,7 +1,13 @@
1
1
  import { field, variant, vec } from "@dao-xyz/borsh";
2
2
  import { Cache } from "@peerbit/cache";
3
3
  import { type PublicSignKey, randomBytes, toBase64 } from "@peerbit/crypto";
4
- import { type Index } from "@peerbit/indexer-interface";
4
+ import {
5
+ And,
6
+ type Index,
7
+ IntegerCompare,
8
+ Or,
9
+ type Query,
10
+ } from "@peerbit/indexer-interface";
5
11
  import type { Entry, Log } from "@peerbit/log";
6
12
  import { logger as loggerFn } from "@peerbit/logger";
7
13
  import {
@@ -13,8 +19,11 @@ import type { RPC, RequestContext } from "@peerbit/rpc";
13
19
  import { SilentDelivery } from "@peerbit/stream-interface";
14
20
  import { type EntryWithRefs } from "../exchange-heads.js";
15
21
  import { TransportMessage } from "../message.js";
16
- import { type EntryReplicated, matchEntriesInRangeQuery } from "../ranges.js";
22
+ import { type EntryReplicated } from "../ranges.js";
17
23
  import type {
24
+ RepairSession,
25
+ RepairSessionMode,
26
+ RepairSessionResult,
18
27
  SyncableKey,
19
28
  SynchronizerComponents,
20
29
  Syncronizer,
@@ -49,6 +58,10 @@ const getSyncIdString = (message: { syncId: Uint8Array }) => {
49
58
  return toBase64(message.syncId);
50
59
  };
51
60
 
61
+ const DEFAULT_CONVERGENT_REPAIR_TIMEOUT_MS = 30_000;
62
+ const DEFAULT_CONVERGENT_RETRY_INTERVALS_MS = [0, 1_000, 3_000, 7_000];
63
+ const DEFAULT_MAX_CONVERGENT_TRACKED_HASHES = 4_096;
64
+
52
65
  @variant([3, 0])
53
66
  export class StartSync extends TransportMessage {
54
67
  @field({ type: Uint8Array })
@@ -131,6 +144,50 @@ export interface SSymbol {
131
144
  symbol: bigint;
132
145
  }
133
146
 
147
+ const matchEntriesByHashNumberInRangeQuery = (range: {
148
+ start1: number | bigint;
149
+ end1: number | bigint;
150
+ start2: number | bigint;
151
+ end2: number | bigint;
152
+ }): Query => {
153
+ const c1 = new And([
154
+ new IntegerCompare({
155
+ key: "hashNumber",
156
+ compare: "gte",
157
+ value: range.start1,
158
+ }),
159
+ new IntegerCompare({
160
+ key: "hashNumber",
161
+ compare: "lt",
162
+ value: range.end1,
163
+ }),
164
+ ]);
165
+
166
+ // if range2 has length 0 or range 2 is equal to range 1 only make one query
167
+ if (
168
+ range.start2 === range.end2 ||
169
+ (range.start1 === range.start2 && range.end1 === range.end2)
170
+ ) {
171
+ return c1;
172
+ }
173
+
174
+ return new Or([
175
+ c1,
176
+ new And([
177
+ new IntegerCompare({
178
+ key: "hashNumber",
179
+ compare: "gte",
180
+ value: range.start2,
181
+ }),
182
+ new IntegerCompare({
183
+ key: "hashNumber",
184
+ compare: "lt",
185
+ value: range.end2,
186
+ }),
187
+ ]),
188
+ ]);
189
+ };
190
+
134
191
  const buildEncoderOrDecoderFromRange = async <
135
192
  T extends "encoder" | "decoder",
136
193
  E = T extends "encoder" ? EncoderWrapper : DecoderWrapper,
@@ -152,7 +209,8 @@ const buildEncoderOrDecoderFromRange = async <
152
209
  const entries = await entryIndex
153
210
  .iterate(
154
211
  {
155
- query: matchEntriesInRangeQuery({
212
+ // Range sync for IBLT is done in hashNumber space.
213
+ query: matchEntriesByHashNumberInRangeQuery({
156
214
  end1: ranges.end1,
157
215
  start1: ranges.start1,
158
216
  end2: ranges.end2,
@@ -182,6 +240,7 @@ export class RatelessIBLTSynchronizer<D extends "u32" | "u64">
182
240
  implements Syncronizer<D>
183
241
  {
184
242
  simple: SimpleSyncronizer<D>;
243
+ private repairSessionCounter: number;
185
244
 
186
245
  startedOrCompletedSynchronizations: Cache<string>;
187
246
  private localRangeEncoderCacheVersion = 0;
@@ -219,11 +278,179 @@ export class RatelessIBLTSynchronizer<D extends "u32" | "u64">
219
278
 
220
279
  constructor(readonly properties: SynchronizerComponents<D>) {
221
280
  this.simple = new SimpleSyncronizer(properties);
281
+ this.repairSessionCounter = 0;
222
282
  this.outgoingSyncProcesses = new Map();
223
283
  this.ingoingSyncProcesses = new Map();
224
284
  this.startedOrCompletedSynchronizations = new Cache({ max: 1e4 });
225
285
  }
226
286
 
287
+ private get maxConvergentTrackedHashes() {
288
+ const value = this.properties.sync?.maxConvergentTrackedHashes;
289
+ return value && Number.isFinite(value) && value > 0
290
+ ? Math.floor(value)
291
+ : DEFAULT_MAX_CONVERGENT_TRACKED_HASHES;
292
+ }
293
+
294
+ private normalizeRetryIntervals(retryIntervalsMs?: number[]): number[] {
295
+ if (!retryIntervalsMs || retryIntervalsMs.length === 0) {
296
+ return [...DEFAULT_CONVERGENT_RETRY_INTERVALS_MS];
297
+ }
298
+
299
+ return [...retryIntervalsMs]
300
+ .map((x) => Math.max(0, Math.floor(x)))
301
+ .filter((x, i, arr) => arr.indexOf(x) === i)
302
+ .sort((a, b) => a - b);
303
+ }
304
+
305
+ private getPrioritizedEntries(entries: Map<string, EntryReplicated<D>>) {
306
+ const priorityFn = this.properties.sync?.priority;
307
+ if (!priorityFn) {
308
+ return [...entries.values()];
309
+ }
310
+
311
+ let index = 0;
312
+ const scored: { entry: EntryReplicated<D>; index: number; priority: number }[] =
313
+ [];
314
+ for (const entry of entries.values()) {
315
+ const priorityValue = priorityFn(entry);
316
+ scored.push({
317
+ entry,
318
+ index,
319
+ priority: Number.isFinite(priorityValue) ? priorityValue : 0,
320
+ });
321
+ index += 1;
322
+ }
323
+ scored.sort((a, b) => b.priority - a.priority || a.index - b.index);
324
+ return scored.map((x) => x.entry);
325
+ }
326
+
327
+ startRepairSession(properties: {
328
+ entries: Map<string, EntryReplicated<D>>;
329
+ targets: string[];
330
+ mode?: RepairSessionMode;
331
+ timeoutMs?: number;
332
+ retryIntervalsMs?: number[];
333
+ }): RepairSession {
334
+ const mode = properties.mode ?? "best-effort";
335
+ const targets = [...new Set(properties.targets)];
336
+ const timeoutMs = Math.max(
337
+ 1,
338
+ Math.floor(properties.timeoutMs ?? DEFAULT_CONVERGENT_REPAIR_TIMEOUT_MS),
339
+ );
340
+ const retryIntervalsMs = this.normalizeRetryIntervals(properties.retryIntervalsMs);
341
+ const trackedLimit = this.maxConvergentTrackedHashes;
342
+ const requestedHashes = [...properties.entries.keys()];
343
+ const requestedHashesTracked = requestedHashes.slice(0, trackedLimit);
344
+ const truncated = requestedHashesTracked.length < requestedHashes.length;
345
+
346
+ if (mode === "convergent") {
347
+ if (properties.entries.size <= trackedLimit) {
348
+ return this.simple.startRepairSession({
349
+ ...properties,
350
+ mode: "convergent",
351
+ timeoutMs,
352
+ retryIntervalsMs,
353
+ });
354
+ }
355
+
356
+ const id = `rateless-repair-${++this.repairSessionCounter}`;
357
+ const startedAt = Date.now();
358
+ const prioritized = this.getPrioritizedEntries(properties.entries);
359
+ const trackedEntries = new Map<string, EntryReplicated<D>>();
360
+ for (const entry of prioritized.slice(0, trackedLimit)) {
361
+ trackedEntries.set(entry.hash, entry);
362
+ }
363
+
364
+ let cancelled = false;
365
+ const trackedSession = this.simple.startRepairSession({
366
+ entries: trackedEntries,
367
+ targets,
368
+ mode: "convergent",
369
+ timeoutMs,
370
+ retryIntervalsMs,
371
+ });
372
+
373
+ const runDispatchSchedule = async () => {
374
+ let previousDelay = 0;
375
+ for (const delayMs of retryIntervalsMs) {
376
+ if (cancelled) {
377
+ return;
378
+ }
379
+ const elapsed = Date.now() - startedAt;
380
+ if (elapsed >= timeoutMs) {
381
+ return;
382
+ }
383
+ const waitMs = Math.max(0, delayMs - previousDelay);
384
+ previousDelay = delayMs;
385
+ if (waitMs > 0) {
386
+ await new Promise<void>((resolve) => {
387
+ const timer = setTimeout(resolve, waitMs);
388
+ timer.unref?.();
389
+ });
390
+ }
391
+ if (cancelled) {
392
+ return;
393
+ }
394
+ try {
395
+ await this.onMaybeMissingEntries({
396
+ entries: properties.entries,
397
+ targets,
398
+ });
399
+ } catch {
400
+ // Best-effort schedule: tracked session timeout/result decides completion.
401
+ }
402
+ }
403
+ };
404
+
405
+ const done = (async (): Promise<RepairSessionResult[]> => {
406
+ await runDispatchSchedule();
407
+ const trackedResults = await trackedSession.done;
408
+ return trackedResults.map((result) => ({
409
+ ...result,
410
+ requestedTotal: requestedHashes.length,
411
+ truncated: true,
412
+ }));
413
+ })();
414
+
415
+ return {
416
+ id,
417
+ done,
418
+ cancel: () => {
419
+ cancelled = true;
420
+ trackedSession.cancel();
421
+ },
422
+ };
423
+ }
424
+
425
+ const id = `rateless-repair-${++this.repairSessionCounter}`;
426
+ const startedAt = Date.now();
427
+ const done = (async (): Promise<RepairSessionResult[]> => {
428
+ await this.onMaybeMissingEntries({
429
+ entries: properties.entries,
430
+ targets,
431
+ });
432
+ const durationMs = Date.now() - startedAt;
433
+ return targets.map((target) => ({
434
+ target,
435
+ requested: requestedHashesTracked.length,
436
+ resolved: 0,
437
+ unresolved: [...requestedHashesTracked],
438
+ attempts: 1,
439
+ durationMs,
440
+ completed: false,
441
+ requestedTotal: requestedHashes.length,
442
+ truncated,
443
+ }));
444
+ })();
445
+ return {
446
+ id,
447
+ done,
448
+ cancel: () => {
449
+ // no-op: best-effort dispatch does not maintain cancelable session state
450
+ },
451
+ };
452
+ }
453
+
227
454
  private clearLocalRangeEncoderCache() {
228
455
  for (const [, cached] of this.localRangeEncoderCache) {
229
456
  cached.encoder.free();
@@ -313,6 +540,8 @@ export class RatelessIBLTSynchronizer<D extends "u32" | "u64">
313
540
  entries: Map<string, EntryReplicated<D>>;
314
541
  targets: string[];
315
542
  }): Promise<void> {
543
+ // NOTE: this method is best-effort dispatch, not a per-hash convergence API.
544
+ // It may require follow-up repair rounds under churn/loss to fully close all gaps.
316
545
  // Strategy:
317
546
  // - For small sets, prefer the simple synchronizer to reduce complexity and avoid
318
547
  // IBLT overhead on tiny batches.
@@ -478,9 +707,13 @@ export class RatelessIBLTSynchronizer<D extends "u32" | "u64">
478
707
  }
479
708
  }
480
709
 
710
+ // For smaller sets, the original `sqrt(n)` heuristic can occasionally under-provision
711
+ // low-degree symbols early, causing an unnecessary `MoreSymbols` round-trip. Use a
712
+ // small floor to make small-delta syncs more reliable without affecting large-n behavior.
481
713
  let initialSymbols = Math.round(
482
714
  Math.sqrt(allCoordinatesToSyncWithIblt.length),
483
715
  ); // TODO choose better
716
+ initialSymbols = Math.max(64, initialSymbols);
484
717
  for (let i = 0; i < initialSymbols; i++) {
485
718
  startSync.symbols.push(
486
719
  new SymbolSerialized(encoder.produce_next_coded_symbol()),
@@ -770,7 +1003,7 @@ export class RatelessIBLTSynchronizer<D extends "u32" | "u64">
770
1003
  return this.simple.onEntryRemoved(hash);
771
1004
  }
772
1005
 
773
- onPeerDisconnected(key: PublicSignKey) {
1006
+ onPeerDisconnected(key: PublicSignKey | string) {
774
1007
  return this.simple.onPeerDisconnected(key);
775
1008
  }
776
1009