@peerbit/stream 4.5.3 → 4.6.0-000e3f1

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,7 +1,9 @@
1
- import { AbortError, delay } from "@peerbit/time";
2
-
3
1
  export const MAX_ROUTE_DISTANCE = Number.MAX_SAFE_INTEGER - 1;
4
2
 
3
+ const DEFAULT_MAX_FROM_ENTRIES = 2048;
4
+ const DEFAULT_MAX_TARGETS_PER_FROM = 10_000;
5
+ const DEFAULT_MAX_RELAYS_PER_TARGET = 32;
6
+
5
7
  type RelayInfo = {
6
8
  session: number;
7
9
  hash: string;
@@ -44,17 +46,103 @@ export class Routes {
44
46
 
45
47
  signal?: AbortSignal;
46
48
 
49
+ private pendingCleanupByFrom: Map<string, Set<string>> = new Map();
50
+ private cleanupTimer?: ReturnType<typeof setTimeout>;
51
+ private maxFromEntries: number;
52
+ private maxTargetsPerFrom: number;
53
+ private maxRelaysPerTarget: number;
54
+
47
55
  constructor(
48
56
  readonly me: string,
49
- options?: { routeMaxRetentionPeriod?: number; signal?: AbortSignal },
57
+ options?: {
58
+ routeMaxRetentionPeriod?: number;
59
+ signal?: AbortSignal;
60
+ maxFromEntries?: number;
61
+ maxTargetsPerFrom?: number;
62
+ maxRelaysPerTarget?: number;
63
+ },
50
64
  ) {
51
65
  this.routeMaxRetentionPeriod =
52
66
  options?.routeMaxRetentionPeriod ?? 10 * 1000;
53
67
  this.signal = options?.signal;
68
+ this.maxFromEntries = Math.max(
69
+ 1,
70
+ Math.floor(options?.maxFromEntries ?? DEFAULT_MAX_FROM_ENTRIES),
71
+ );
72
+ this.maxTargetsPerFrom = Math.max(
73
+ 1,
74
+ Math.floor(options?.maxTargetsPerFrom ?? DEFAULT_MAX_TARGETS_PER_FROM),
75
+ );
76
+ this.maxRelaysPerTarget = Math.max(
77
+ 1,
78
+ Math.floor(options?.maxRelaysPerTarget ?? DEFAULT_MAX_RELAYS_PER_TARGET),
79
+ );
54
80
  }
55
81
 
56
82
  clear() {
57
83
  this.routes.clear();
84
+ this.pendingCleanupByFrom.clear();
85
+ if (this.cleanupTimer) clearTimeout(this.cleanupTimer);
86
+ this.cleanupTimer = undefined;
87
+ }
88
+
89
+ private requestCleanup(from: string, to: string) {
90
+ if (this.signal?.aborted) return;
91
+ let targets = this.pendingCleanupByFrom.get(from);
92
+ if (!targets) {
93
+ targets = new Set<string>();
94
+ this.pendingCleanupByFrom.set(from, targets);
95
+ }
96
+ targets.add(to);
97
+
98
+ // Coalesce cleanups into a single timer. The previous per-update timer approach
99
+ // scales poorly in large networks and can OOM in single-process simulations.
100
+ if (this.cleanupTimer) return;
101
+ this.cleanupTimer = setTimeout(() => {
102
+ this.cleanupTimer = undefined;
103
+ const pending = this.pendingCleanupByFrom;
104
+ this.pendingCleanupByFrom = new Map();
105
+ for (const [fromKey, tos] of pending) {
106
+ for (const toKey of tos) {
107
+ this.cleanup(fromKey, toKey);
108
+ }
109
+ }
110
+ }, this.routeMaxRetentionPeriod + 100);
111
+ }
112
+
113
+ private pruneFromMaps() {
114
+ if (this.routes.size <= this.maxFromEntries) return;
115
+
116
+ // Keep `me` pinned: local routes are used for pruning decisions and should be
117
+ // the last thing we evict under memory pressure.
118
+ while (this.routes.size > this.maxFromEntries) {
119
+ const oldest = this.routes.keys().next().value as string | undefined;
120
+ if (!oldest) return;
121
+ if (oldest === this.me) {
122
+ const selfMap = this.routes.get(oldest);
123
+ if (!selfMap) {
124
+ this.routes.delete(oldest);
125
+ continue;
126
+ }
127
+ // Move to the end (most recently used) and continue eviction.
128
+ this.routes.delete(oldest);
129
+ this.routes.set(oldest, selfMap);
130
+ continue;
131
+ }
132
+ this.routes.delete(oldest);
133
+ }
134
+ }
135
+
136
+ private pruneTargets(from: string, fromMap: Map<string, RouteInfo>) {
137
+ if (fromMap.size <= this.maxTargetsPerFrom) return;
138
+ while (fromMap.size > this.maxTargetsPerFrom) {
139
+ const oldestTarget = fromMap.keys().next().value as string | undefined;
140
+ if (!oldestTarget) break;
141
+ fromMap.delete(oldestTarget);
142
+ }
143
+ if (fromMap.size === 0) {
144
+ this.routes.delete(from);
145
+ }
58
146
  }
59
147
 
60
148
  private cleanup(from: string, to: string) {
@@ -73,6 +161,10 @@ export class Routes {
73
161
  }
74
162
  }
75
163
 
164
+ if (keepRoutes.length > this.maxRelaysPerTarget) {
165
+ keepRoutes.length = this.maxRelaysPerTarget;
166
+ }
167
+
76
168
  if (keepRoutes.length > 0) {
77
169
  map.list = keepRoutes;
78
170
  } else {
@@ -96,6 +188,10 @@ export class Routes {
96
188
  if (!fromMap) {
97
189
  fromMap = new Map();
98
190
  this.routes.set(from, fromMap);
191
+ } else {
192
+ // LRU-touch the `from` map.
193
+ this.routes.delete(from);
194
+ this.routes.set(from, fromMap);
99
195
  }
100
196
 
101
197
  let prev = fromMap.get(target);
@@ -106,6 +202,10 @@ export class Routes {
106
202
  if (!prev) {
107
203
  prev = { session, remoteSession, list: [] as RelayInfo[] };
108
204
  fromMap.set(target, prev);
205
+ } else {
206
+ // LRU-touch the target entry.
207
+ fromMap.delete(target);
208
+ fromMap.set(target, prev);
109
209
  }
110
210
 
111
211
  const isRelayed = from !== this.me;
@@ -127,20 +227,6 @@ export class Routes {
127
227
 
128
228
  prev.session = Math.max(session, prev.session);
129
229
 
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
230
  // Update routes and cleanup all old routes that are older than latest session - some threshold
145
231
  if (isNewSession) {
146
232
  // Mark previous routes as old
@@ -156,10 +242,10 @@ export class Routes {
156
242
 
157
243
  // Initiate cleanup
158
244
  if (distance !== -1 && foundNodeToExpire) {
159
- scheduleCleanup();
245
+ this.requestCleanup(from, target);
160
246
  }
161
247
  } else if (isOldSession) {
162
- scheduleCleanup();
248
+ this.requestCleanup(from, target);
163
249
  }
164
250
 
165
251
  // Modify list for new/update route
@@ -173,10 +259,20 @@ export class Routes {
173
259
  route.session = session;
174
260
  route.expireAt = undefined; // remove expiry since we updated
175
261
  sortRoutes(prev.list);
262
+ if (prev.list.length > this.maxRelaysPerTarget) {
263
+ prev.list.length = this.maxRelaysPerTarget;
264
+ }
265
+ this.pruneTargets(from, fromMap);
266
+ this.pruneFromMaps();
176
267
  return isNewRemoteSession ? "restart" : "updated";
177
268
  } else if (route.distance === distance) {
178
269
  route.session = session;
179
270
  route.expireAt = undefined; // remove expiry since we updated
271
+ if (prev.list.length > this.maxRelaysPerTarget) {
272
+ prev.list.length = this.maxRelaysPerTarget;
273
+ }
274
+ this.pruneTargets(from, fromMap);
275
+ this.pruneFromMaps();
180
276
  return isNewRemoteSession ? "restart" : "updated";
181
277
  }
182
278
  }
@@ -199,8 +295,14 @@ export class Routes {
199
295
  : undefined,
200
296
  });
201
297
  sortRoutes(prev.list);
298
+ if (prev.list.length > this.maxRelaysPerTarget) {
299
+ prev.list.length = this.maxRelaysPerTarget;
300
+ }
202
301
  }
203
302
 
303
+ this.pruneTargets(from, fromMap);
304
+ this.pruneFromMaps();
305
+
204
306
  return exist ? (isNewRemoteSession ? "restart" : "updated") : "new";
205
307
  }
206
308
 
@@ -19,35 +19,66 @@ 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
- const abortFn = (e: any) =>
27
+ const abortFn = (e?: unknown) => {
28
+ if (e instanceof Error) {
29
+ deferred.reject(e);
30
+ return;
31
+ }
32
+
33
+ const reason = (e as any)?.target?.reason;
24
34
  deferred.reject(
25
- e ??
35
+ reason ??
26
36
  new AbortError(
27
37
  "Aborted waiting for event: " +
28
38
  String(events.length > 1 ? events.join(", ") : events[0]),
29
39
  ),
30
40
  );
41
+ };
31
42
 
32
43
  const checkIsReady = (...args: any[]) => resolver(deferred);
44
+ let timeout: ReturnType<typeof setTimeout> | undefined = undefined;
33
45
 
34
- deferred.promise.finally(() => {
35
- for (const event of events) {
36
- emitter.removeEventListener(event as any, checkIsReady);
37
- }
38
- clearTimeout(timeout);
39
- options?.signals?.forEach((signal) =>
40
- signal.removeEventListener("abort", abortFn),
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(() => {});
58
+
59
+ const abortedSignal = options?.signals?.find((signal) => signal.aborted);
60
+ if (abortedSignal) {
61
+ deferred.reject(
62
+ abortedSignal.reason ??
63
+ new AbortError(
64
+ "Aborted waiting for event: " +
65
+ String(events.length > 1 ? events.join(", ") : events[0]),
66
+ ),
41
67
  );
42
- });
68
+ return deferred.promise;
69
+ }
43
70
 
44
71
  for (const event of events) {
45
- emitter.addEventListener(event as any, (evt) => {
46
- checkIsReady(event);
47
- });
72
+ emitter.addEventListener(event as any, checkIsReady as any);
48
73
  }
49
- let timeout = setTimeout(
50
- () => abortFn(new TimeoutError("Timeout waiting for event")),
74
+ timeout = setTimeout(
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
+ },
51
82
  options?.timeout ?? 10 * 1000,
52
83
  );
53
84
  options?.signals?.forEach((signal) =>