@peerbit/stream 4.6.0 → 5.0.0-2d88223

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/routes.ts CHANGED
@@ -1,10 +1,15 @@
1
- import { AbortError, delay } from "@peerbit/time";
1
+ import type { DirectStreamAckRouteHint } from "@peerbit/stream-interface";
2
2
 
3
3
  export const MAX_ROUTE_DISTANCE = Number.MAX_SAFE_INTEGER - 1;
4
4
 
5
+ const DEFAULT_MAX_FROM_ENTRIES = 2048;
6
+ const DEFAULT_MAX_TARGETS_PER_FROM = 10_000;
7
+ const DEFAULT_MAX_RELAYS_PER_TARGET = 32;
8
+
5
9
  type RelayInfo = {
6
10
  session: number;
7
11
  hash: string;
12
+ updatedAt: number;
8
13
  expireAt?: number;
9
14
  distance: number;
10
15
  };
@@ -44,17 +49,103 @@ export class Routes {
44
49
 
45
50
  signal?: AbortSignal;
46
51
 
52
+ private pendingCleanupByFrom: Map<string, Set<string>> = new Map();
53
+ private cleanupTimer?: ReturnType<typeof setTimeout>;
54
+ private maxFromEntries: number;
55
+ private maxTargetsPerFrom: number;
56
+ private maxRelaysPerTarget: number;
57
+
47
58
  constructor(
48
59
  readonly me: string,
49
- options?: { routeMaxRetentionPeriod?: number; signal?: AbortSignal },
60
+ options?: {
61
+ routeMaxRetentionPeriod?: number;
62
+ signal?: AbortSignal;
63
+ maxFromEntries?: number;
64
+ maxTargetsPerFrom?: number;
65
+ maxRelaysPerTarget?: number;
66
+ },
50
67
  ) {
51
68
  this.routeMaxRetentionPeriod =
52
69
  options?.routeMaxRetentionPeriod ?? 10 * 1000;
53
70
  this.signal = options?.signal;
71
+ this.maxFromEntries = Math.max(
72
+ 1,
73
+ Math.floor(options?.maxFromEntries ?? DEFAULT_MAX_FROM_ENTRIES),
74
+ );
75
+ this.maxTargetsPerFrom = Math.max(
76
+ 1,
77
+ Math.floor(options?.maxTargetsPerFrom ?? DEFAULT_MAX_TARGETS_PER_FROM),
78
+ );
79
+ this.maxRelaysPerTarget = Math.max(
80
+ 1,
81
+ Math.floor(options?.maxRelaysPerTarget ?? DEFAULT_MAX_RELAYS_PER_TARGET),
82
+ );
54
83
  }
55
84
 
56
85
  clear() {
57
86
  this.routes.clear();
87
+ this.pendingCleanupByFrom.clear();
88
+ if (this.cleanupTimer) clearTimeout(this.cleanupTimer);
89
+ this.cleanupTimer = undefined;
90
+ }
91
+
92
+ private requestCleanup(from: string, to: string) {
93
+ if (this.signal?.aborted) return;
94
+ let targets = this.pendingCleanupByFrom.get(from);
95
+ if (!targets) {
96
+ targets = new Set<string>();
97
+ this.pendingCleanupByFrom.set(from, targets);
98
+ }
99
+ targets.add(to);
100
+
101
+ // Coalesce cleanups into a single timer. The previous per-update timer approach
102
+ // scales poorly in large networks and can OOM in single-process simulations.
103
+ if (this.cleanupTimer) return;
104
+ this.cleanupTimer = setTimeout(() => {
105
+ this.cleanupTimer = undefined;
106
+ const pending = this.pendingCleanupByFrom;
107
+ this.pendingCleanupByFrom = new Map();
108
+ for (const [fromKey, tos] of pending) {
109
+ for (const toKey of tos) {
110
+ this.cleanup(fromKey, toKey);
111
+ }
112
+ }
113
+ }, this.routeMaxRetentionPeriod + 100);
114
+ }
115
+
116
+ private pruneFromMaps() {
117
+ if (this.routes.size <= this.maxFromEntries) return;
118
+
119
+ // Keep `me` pinned: local routes are used for pruning decisions and should be
120
+ // the last thing we evict under memory pressure.
121
+ while (this.routes.size > this.maxFromEntries) {
122
+ const oldest = this.routes.keys().next().value as string | undefined;
123
+ if (!oldest) return;
124
+ if (oldest === this.me) {
125
+ const selfMap = this.routes.get(oldest);
126
+ if (!selfMap) {
127
+ this.routes.delete(oldest);
128
+ continue;
129
+ }
130
+ // Move to the end (most recently used) and continue eviction.
131
+ this.routes.delete(oldest);
132
+ this.routes.set(oldest, selfMap);
133
+ continue;
134
+ }
135
+ this.routes.delete(oldest);
136
+ }
137
+ }
138
+
139
+ private pruneTargets(from: string, fromMap: Map<string, RouteInfo>) {
140
+ if (fromMap.size <= this.maxTargetsPerFrom) return;
141
+ while (fromMap.size > this.maxTargetsPerFrom) {
142
+ const oldestTarget = fromMap.keys().next().value as string | undefined;
143
+ if (!oldestTarget) break;
144
+ fromMap.delete(oldestTarget);
145
+ }
146
+ if (fromMap.size === 0) {
147
+ this.routes.delete(from);
148
+ }
58
149
  }
59
150
 
60
151
  private cleanup(from: string, to: string) {
@@ -73,6 +164,10 @@ export class Routes {
73
164
  }
74
165
  }
75
166
 
167
+ if (keepRoutes.length > this.maxRelaysPerTarget) {
168
+ keepRoutes.length = this.maxRelaysPerTarget;
169
+ }
170
+
76
171
  if (keepRoutes.length > 0) {
77
172
  map.list = keepRoutes;
78
173
  } else {
@@ -96,6 +191,10 @@ export class Routes {
96
191
  if (!fromMap) {
97
192
  fromMap = new Map();
98
193
  this.routes.set(from, fromMap);
194
+ } else {
195
+ // LRU-touch the `from` map.
196
+ this.routes.delete(from);
197
+ this.routes.set(from, fromMap);
99
198
  }
100
199
 
101
200
  let prev = fromMap.get(target);
@@ -106,6 +205,10 @@ export class Routes {
106
205
  if (!prev) {
107
206
  prev = { session, remoteSession, list: [] as RelayInfo[] };
108
207
  fromMap.set(target, prev);
208
+ } else {
209
+ // LRU-touch the target entry.
210
+ fromMap.delete(target);
211
+ fromMap.set(target, prev);
109
212
  }
110
213
 
111
214
  const isRelayed = from !== this.me;
@@ -127,20 +230,6 @@ export class Routes {
127
230
 
128
231
  prev.session = Math.max(session, prev.session);
129
232
 
130
- const scheduleCleanup = () => {
131
- return delay(this.routeMaxRetentionPeriod + 100, { signal: this.signal })
132
- .then(() => {
133
- this.cleanup(from, target);
134
- })
135
- .catch((e) => {
136
- if (e instanceof AbortError) {
137
- // skip
138
- return;
139
- }
140
- throw e;
141
- });
142
- };
143
-
144
233
  // Update routes and cleanup all old routes that are older than latest session - some threshold
145
234
  if (isNewSession) {
146
235
  // Mark previous routes as old
@@ -156,10 +245,10 @@ export class Routes {
156
245
 
157
246
  // Initiate cleanup
158
247
  if (distance !== -1 && foundNodeToExpire) {
159
- scheduleCleanup();
248
+ this.requestCleanup(from, target);
160
249
  }
161
250
  } else if (isOldSession) {
162
- scheduleCleanup();
251
+ this.requestCleanup(from, target);
163
252
  }
164
253
 
165
254
  // Modify list for new/update route
@@ -171,12 +260,24 @@ export class Routes {
171
260
  if (route.distance > distance) {
172
261
  route.distance = distance;
173
262
  route.session = session;
263
+ route.updatedAt = +new Date();
174
264
  route.expireAt = undefined; // remove expiry since we updated
175
265
  sortRoutes(prev.list);
266
+ if (prev.list.length > this.maxRelaysPerTarget) {
267
+ prev.list.length = this.maxRelaysPerTarget;
268
+ }
269
+ this.pruneTargets(from, fromMap);
270
+ this.pruneFromMaps();
176
271
  return isNewRemoteSession ? "restart" : "updated";
177
272
  } else if (route.distance === distance) {
178
273
  route.session = session;
274
+ route.updatedAt = +new Date();
179
275
  route.expireAt = undefined; // remove expiry since we updated
276
+ if (prev.list.length > this.maxRelaysPerTarget) {
277
+ prev.list.length = this.maxRelaysPerTarget;
278
+ }
279
+ this.pruneTargets(from, fromMap);
280
+ this.pruneFromMaps();
180
281
  return isNewRemoteSession ? "restart" : "updated";
181
282
  }
182
283
  }
@@ -194,13 +295,20 @@ export class Routes {
194
295
  distance,
195
296
  session,
196
297
  hash: neighbour,
298
+ updatedAt: +new Date(),
197
299
  expireAt: isOldSession
198
300
  ? +new Date() + this.routeMaxRetentionPeriod
199
301
  : undefined,
200
302
  });
201
303
  sortRoutes(prev.list);
304
+ if (prev.list.length > this.maxRelaysPerTarget) {
305
+ prev.list.length = this.maxRelaysPerTarget;
306
+ }
202
307
  }
203
308
 
309
+ this.pruneTargets(from, fromMap);
310
+ this.pruneFromMaps();
311
+
204
312
  return exist ? (isNewRemoteSession ? "restart" : "updated") : "new";
205
313
  }
206
314
 
@@ -262,6 +370,38 @@ export class Routes {
262
370
  return this.routes.get(from)?.get(target);
263
371
  }
264
372
 
373
+ getRouteHints(from: string, target: string): DirectStreamAckRouteHint[] {
374
+ const route = this.routes.get(from)?.get(target);
375
+ if (!route) {
376
+ return [];
377
+ }
378
+ const now = Date.now();
379
+ const out: DirectStreamAckRouteHint[] = [];
380
+ for (const next of route.list) {
381
+ if (next.expireAt != null && next.expireAt < now) {
382
+ continue;
383
+ }
384
+ out.push({
385
+ kind: "directstream-ack",
386
+ from,
387
+ target,
388
+ nextHop: next.hash,
389
+ distance: next.distance,
390
+ session: next.session,
391
+ updatedAt: next.updatedAt,
392
+ expiresAt: next.expireAt,
393
+ });
394
+ }
395
+ return out;
396
+ }
397
+
398
+ getBestRouteHint(
399
+ from: string,
400
+ target: string,
401
+ ): DirectStreamAckRouteHint | undefined {
402
+ return this.getRouteHints(from, target)[0];
403
+ }
404
+
265
405
  isReachable(from: string, target: string, maxDistance = MAX_ROUTE_DISTANCE) {
266
406
  const remoteInfo = this.remoteInfo.get(target);
267
407
  const routeInfo = this.routes.get(from)?.get(target);
@@ -19,6 +19,10 @@ export function waitForEvent<
19
19
  timeout?: number;
20
20
  },
21
21
  ): Promise<void> {
22
+ const traceEnabled =
23
+ (globalThis as any)?.process?.env?.PEERBIT_WAITFOREVENT_TRACE === "1";
24
+ const callsite = traceEnabled ? new Error("waitForEvent callsite").stack : undefined;
25
+
22
26
  const deferred = pDefer<void>();
23
27
  const abortFn = (e?: unknown) => {
24
28
  if (e instanceof Error) {
@@ -39,15 +43,18 @@ export function waitForEvent<
39
43
  const checkIsReady = (...args: any[]) => resolver(deferred);
40
44
  let timeout: ReturnType<typeof setTimeout> | undefined = undefined;
41
45
 
42
- deferred.promise.finally(() => {
43
- for (const event of events) {
44
- emitter.removeEventListener(event as any, checkIsReady as any);
45
- }
46
- timeout && clearTimeout(timeout);
47
- options?.signals?.forEach((signal) =>
48
- signal.removeEventListener("abort", abortFn),
49
- );
50
- });
46
+ void deferred.promise
47
+ .finally(() => {
48
+ for (const event of events) {
49
+ emitter.removeEventListener(event as any, checkIsReady as any);
50
+ }
51
+ timeout && clearTimeout(timeout);
52
+ options?.signals?.forEach((signal) =>
53
+ signal.removeEventListener("abort", abortFn),
54
+ );
55
+ })
56
+ // Avoid triggering an unhandled rejection from the `.finally()` return promise.
57
+ .catch(() => {});
51
58
 
52
59
  const abortedSignal = options?.signals?.find((signal) => signal.aborted);
53
60
  if (abortedSignal) {
@@ -65,7 +72,13 @@ export function waitForEvent<
65
72
  emitter.addEventListener(event as any, checkIsReady as any);
66
73
  }
67
74
  timeout = setTimeout(
68
- () => abortFn(new TimeoutError("Timeout waiting for event")),
75
+ () => {
76
+ const err = new TimeoutError("Timeout waiting for event");
77
+ if (callsite && err.stack) {
78
+ err.stack += `\n\n${callsite}`;
79
+ }
80
+ abortFn(err);
81
+ },
69
82
  options?.timeout ?? 10 * 1000,
70
83
  );
71
84
  options?.signals?.forEach((signal) =>