querysub 0.525.0 → 0.526.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.525.0",
3
+ "version": "0.526.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { SocketFunction } from "socket-function/SocketFunction";
11
- import { timeInMinute, timeInSecond } from "socket-function/src/misc";
11
+ import { sort, timeInMinute, timeInSecond } from "socket-function/src/misc";
12
12
  import { lazy } from "socket-function/src/caching";
13
13
  import { delay, runInfinitePollCallAtStart } from "socket-function/src/batching";
14
14
  import { isServer } from "../config2";
@@ -23,6 +23,8 @@ const LATENCY_POLL_INTERVAL = timeInMinute;
23
23
  const PING_TIMEOUT = timeInSecond * 5;
24
24
  // Rolling average size. Small, so latency changes show up quickly.
25
25
  const LATENCY_SAMPLE_LIMIT = 20;
26
+ // Raw sample history kept per node, so callers can ask for a median over the last N with a known confidence.
27
+ const LATENCY_HISTORY_LIMIT = 100;
26
28
  // Forget nodes we haven't been able to reach for this long.
27
29
  const NODE_EXPIRY_TIME = LATENCY_POLL_INTERVAL * 5;
28
30
 
@@ -30,6 +32,7 @@ export type NodeLatencyInfo = {
30
32
  averageLatency: number;
31
33
  sampleCount: number;
32
34
  lastSeen: number;
35
+ history: number[];
33
36
  };
34
37
 
35
38
  // otherNodeId => our rolling latency to it
@@ -42,6 +45,14 @@ export function getNodeLatencyInfo(nodeId: string): NodeLatencyInfo | undefined
42
45
  export function getNodeLatency(nodeId: string): number | undefined {
43
46
  return getNodeLatencyInfo(nodeId)?.averageLatency;
44
47
  }
48
+ // Median of the last historyCount raw samples, plus how many samples were actually available — so callers can scale how much they trust the number.
49
+ export function getNodeLatencyMedian(config: { nodeId: string; historyCount: number }): { latency: number; historyUsed: number } | undefined {
50
+ let history = getNodeLatencyInfo(config.nodeId)?.history;
51
+ if (!history || history.length === 0) return undefined;
52
+ let samples = history.slice(-config.historyCount);
53
+ sort(samples, x => x);
54
+ return { latency: samples[Math.floor(samples.length / 2)], historyUsed: samples.length };
55
+ }
45
56
 
46
57
  export function getCachedNodeLatencyInfoList(): Map<string, NodeLatencyInfo> {
47
58
  return latencyByNode;
@@ -60,10 +71,16 @@ export function getOwnLatencies(): { [nodeId: string]: number } {
60
71
  function recordLatency(nodeId: string, latency: number) {
61
72
  let prev = latencyByNode.get(nodeId);
62
73
  let sampleCount = Math.min(prev?.sampleCount || 0, LATENCY_SAMPLE_LIMIT);
74
+ let history = prev?.history || [];
75
+ history.push(latency);
76
+ if (history.length > LATENCY_HISTORY_LIMIT) {
77
+ history.shift();
78
+ }
63
79
  latencyByNode.set(nodeId, {
64
80
  averageLatency: ((prev?.averageLatency || 0) * sampleCount + latency) / (sampleCount + 1),
65
81
  sampleCount: sampleCount + 1,
66
82
  lastSeen: Date.now(),
83
+ history,
67
84
  });
68
85
  }
69
86
 
@@ -20,7 +20,7 @@ import { formatNumber, formatPercent, formatTime } from "socket-function/src/for
20
20
  setFlag(require, "cbor-x", "allowclient", true);
21
21
 
22
22
  import * as pako from "pako";
23
- import { delay } from "socket-function/src/batching";
23
+ import { delay, safeLoop } from "socket-function/src/batching";
24
24
  import { LZ4 } from "../storage/LZ4";
25
25
  import { unblockLoop } from "socket-function/src/batching";
26
26
  setFlag(require, "pako", "allowclient", true);
@@ -345,6 +345,7 @@ class PathValueSerializer {
345
345
  let encodedValues: (Buffer | number)[] = [];
346
346
 
347
347
  let i = 0;
348
+ let valuesSinceYield = 0;
348
349
  for (let values of valuesGroups) {
349
350
  measureBlock(() => {
350
351
  // NOTE: Writing one value at a time is about 1.6X slower. BUT, it allows us to very efficient decode
@@ -378,21 +379,23 @@ class PathValueSerializer {
378
379
  i++;
379
380
  }
380
381
  }, "valuesWriteLoop");
381
- await delay("paintLoop");
382
+ valuesSinceYield += values.length;
383
+ if (valuesSinceYield >= 10_000) {
384
+ valuesSinceYield = 0;
385
+ await delay("paintLoop");
386
+ }
382
387
  }
383
388
 
384
389
  measureBlock(function valuesWriteTypesBuffer() {
385
390
  writer.writeBuffer(types);
386
391
  });
387
- // Break encodeValues into groups of 100, so we can check the time and delay
388
- // if we are taking too long, but not check it EVERY loop, as that would
389
- // be too slow.
392
+ // Break encodeValues into groups, and yield based on the amount of values written NOT by measuring time, as timing every loop is itself too slow.
390
393
  let valueGroups: (Buffer | number)[][] = [];
391
394
  const VALUE_GROUP_SIZE = 1000;
392
395
  for (let i = 0; i < encodedValues.length; i += VALUE_GROUP_SIZE) {
393
396
  valueGroups.push(encodedValues.slice(i, i + VALUE_GROUP_SIZE));
394
397
  }
395
- let prevTime = Date.now();
398
+ let encodedSinceYield = 0;
396
399
  for (let encodedValues of valueGroups) {
397
400
  measureBlock(function valuesWriteTypes() {
398
401
  for (let value of encodedValues) {
@@ -403,10 +406,10 @@ class PathValueSerializer {
403
406
  }
404
407
  }
405
408
  });
406
- let now = Date.now();
407
- if (now - prevTime > 10) {
409
+ encodedSinceYield += encodedValues.length;
410
+ if (encodedSinceYield >= 10_000) {
411
+ encodedSinceYield = 0;
408
412
  await delay("paintLoop");
409
- prevTime = now;
410
413
  }
411
414
  }
412
415
  }
@@ -550,9 +553,14 @@ class PathValueSerializer {
550
553
  }
551
554
 
552
555
  startNewBuffer("pathValues");
556
+ let pathValuesSinceYield = 0;
553
557
  for (let values of valueGroups) {
554
558
  this.pathValuesWrite(writer, values, settings);
555
- await delay("afterPaint");
559
+ pathValuesSinceYield += values.length;
560
+ if (pathValuesSinceYield >= 10_000) {
561
+ pathValuesSinceYield = 0;
562
+ await delay("afterPaint");
563
+ }
556
564
  }
557
565
 
558
566
  if (!settings.noLocks) {
@@ -593,15 +601,20 @@ class PathValueSerializer {
593
601
  curCount += str.length;
594
602
  }
595
603
 
604
+ let stringBytesSinceYield = 0;
596
605
  for (let strings of stringParts) {
597
606
  let stringBuffer = StringSerialize.serializeStrings(strings);
598
607
  settings.bufferMap!.push("strings");
599
608
  outputBuffers.push(stringBuffer);
600
- await delay("paintLoop");
609
+ stringBytesSinceYield += stringBuffer.length;
610
+ if (stringBytesSinceYield >= 1_000_000) {
611
+ stringBytesSinceYield = 0;
612
+ await delay("paintLoop");
613
+ }
601
614
  }
602
615
 
603
616
  if (settings.compression === "lz4") {
604
- let compressedBuffers = await unblockLoop(outputBuffers, x => LZ4.compress(x));
617
+ let compressedBuffers = await measureBlock(() => safeLoop({ data: outputBuffers, name: "PathValueSerializer.serialize|lz4" }, x => LZ4.compress(x)), "PathValueSerializer.serialize|lz4");
605
618
 
606
619
  // If the compress factor is less than a threshold, use the uncompressed buffers
607
620
  let uncompressedSize = outputBuffers.reduce((total, x) => total + x.length, 0);
@@ -723,6 +736,7 @@ class PathValueSerializer {
723
736
  }
724
737
  if (!config?.skipStrings) {
725
738
  let stringArrays: string[][] = [];
739
+ let bytesSinceYield = 0;
726
740
  for (let stringBuffer of stringBuffers) {
727
741
  let obj = StringSerialize.deserializeStringsLazy(stringBuffer);
728
742
  while (true) {
@@ -731,11 +745,14 @@ class PathValueSerializer {
731
745
  break;
732
746
  }
733
747
  stringArrays.push(nextStrings);
734
- if (stringArrays.length > 1) {
748
+ // Each getNextStrings call decodes ~1MB, so on buffers past that we yield per chunk decoded.
749
+ if (stringBuffer.length > 1_000_000) {
735
750
  await delay("paintLoop");
736
751
  }
737
752
  }
738
- if (stringBuffers.length > 1) {
753
+ bytesSinceYield += stringBuffer.length;
754
+ if (bytesSinceYield >= 1_000_000) {
755
+ bytesSinceYield = 0;
739
756
  await delay("paintLoop");
740
757
  }
741
758
  }
@@ -848,6 +865,7 @@ class PathValueSerializer {
848
865
  }
849
866
 
850
867
  let stringArrays: string[][] = [];
868
+ let bytesSinceYield = 0;
851
869
  for (let bufferIndex of stringBufferIndexes) {
852
870
  let stringBuffer = buffers[bufferIndex];
853
871
  let obj = StringSerialize.deserializeStringsLazy(stringBuffer);
@@ -857,13 +875,12 @@ class PathValueSerializer {
857
875
  break;
858
876
  }
859
877
  stringArrays.push(nextStrings);
860
- if (stringArrays.length > 1) {
878
+ bytesSinceYield += stringBuffer.length;
879
+ if (bytesSinceYield >= 1_000_000) {
880
+ bytesSinceYield = 0;
861
881
  await delay("paintLoop");
862
882
  }
863
883
  }
864
- if (stringBufferIndexes.length > 1) {
865
- await delay("paintLoop");
866
- }
867
884
  }
868
885
  strings = stringArrays.flat();
869
886
  }
@@ -1,4 +1,4 @@
1
- import { timeInMinute, timeInSecond } from "socket-function/src/misc";
1
+ import { sort, timeInMinute, timeInSecond } from "socket-function/src/misc";
2
2
  import { nestArchives } from "../-a-archives/archives";
3
3
  import { getArchivesBackblaze } from "../-a-archives/archivesBackBlaze";
4
4
  import { archiveJSONT } from "../-a-archives/archivesJSONT";
@@ -31,7 +31,7 @@ let CALL_TIMEOUT = timeInSecond * 5;
31
31
  const SYNC_JITTER_WINDOW = timeInSecond * 10;
32
32
 
33
33
  // Nodes this close to their scheduled shutdown are avoided as sources (only used if nothing else can satisfy the request).
34
- const SHUTDOWN_AVOID_WINDOW = timeInMinute * 10;
34
+ const SHUTDOWN_AVOID_WINDOW = timeInMinute * 5;
35
35
 
36
36
  export type AuthorityEntry = {
37
37
  nodeId: string;
@@ -82,6 +82,8 @@ class AuthorityLookup {
82
82
  public async setOurSpec(spec: AuthoritySpec) {
83
83
  if (!SocketFunction.isMounted()) throw new Error("Cannot call setOurPaths without mounting first (use Querysub.hostService).");
84
84
  spec.nodeId = getOwnNodeId();
85
+ // AuthoritySpec.prefixes promises to be sorted, and this is the choke point every published spec goes through.
86
+ sort(spec.prefixes, prefix => prefix.originalPrefix);
85
87
 
86
88
 
87
89
  if (this.setSpec) {
@@ -5,7 +5,7 @@ import { PathValue } from "./pathValueCore";
5
5
  import { shuffle } from "../misc/random";
6
6
  import { fastHash } from "../misc/hash";
7
7
  import { getOwnNodeId, isOwnNodeId } from "../-f-node-discovery/NodeDiscovery";
8
- import { getNodeLatency } from "../-f-node-discovery/LatencyTracking";
8
+ import { getNodeLatencyMedian } from "../-f-node-discovery/LatencyTracking";
9
9
  import { unique } from "../misc";
10
10
  import { measureFnc } from "socket-function/src/profiling/measure";
11
11
  import { getRoutingOverride, hasPrefixHash } from "./PathRouterRouteOverride";
@@ -22,15 +22,18 @@ export { LOCAL_DOMAIN, LOCAL_DOMAIN_PATH };
22
22
  // fine (two nearby servers just talk to each other). Replaces the old uniform-random pick between candidates.
23
23
  const LATENCY_CANDIDATE_LIMIT = 5;
24
24
  const LATENCY_WEIGHT_OFFSET = 10;
25
- // A candidate whose weight is below (the current max weight / this factor) is dropped to zero probability. So a node
26
- // weighted 10x another (10x lower effective latency) is picked over it 100% of the time.
27
- const LATENCY_WEIGHT_CUTOFF_FACTOR = 10;
25
+ // How many latency samples we use per node (median of the last N). The more we actually got, the more we trust the number, and the more aggressively we cut slow candidates.
26
+ const LATENCY_HISTORY_COUNT = 10;
27
+ // A candidate whose weight is below (the current max weight / (this - its historyUsed)) is dropped to zero probability. So with full history the factor is 2 (2x the effective latency of the best is never picked), and with a single sample it is 11 (only extreme outliers are cut).
28
+ const LATENCY_CUTOFF_BASE_FACTOR = 12;
28
29
  // Latency assumed for a node we haven't measured yet, so new/unreached nodes stay eligible but aren't preferred.
29
30
  const UNKNOWN_LATENCY_MS = 50;
30
31
 
31
- function getRoutingLatency(nodeId: string): number {
32
- if (isOwnNodeId(nodeId)) return 0;
33
- return getNodeLatency(nodeId) ?? UNKNOWN_LATENCY_MS;
32
+ function getRoutingLatency(nodeId: string): { latency: number; historyUsed: number } {
33
+ if (isOwnNodeId(nodeId)) return { latency: 0, historyUsed: LATENCY_HISTORY_COUNT };
34
+ let median = getNodeLatencyMedian({ nodeId, historyCount: LATENCY_HISTORY_COUNT });
35
+ if (!median) return { latency: UNKNOWN_LATENCY_MS, historyUsed: 0 };
36
+ return median;
34
37
  }
35
38
 
36
39
  // In-place reorder of candidates by a latency-weighted random draw over the lowest LATENCY_CANDIDATE_LIMIT
@@ -39,16 +42,22 @@ function getRoutingLatency(nodeId: string): number {
39
42
  // since an overloaded server's latency spikes, which already makes it less likely to be chosen here.
40
43
  function latencyWeightedShuffle<T extends { nodeId: string }>(arr: T[]) {
41
44
  if (arr.length <= 1) return;
42
- let withLatency = arr.map(x => ({ x, latency: getRoutingLatency(x.nodeId) }));
45
+ let withLatency = arr.map(x => {
46
+ let routingLatency = getRoutingLatency(x.nodeId);
47
+ return { x, latency: routingLatency.latency, historyUsed: routingLatency.historyUsed };
48
+ });
43
49
  sort(withLatency, e => e.latency);
44
50
  let pool = withLatency.slice(0, LATENCY_CANDIDATE_LIMIT);
45
51
  let rest = withLatency.slice(LATENCY_CANDIDATE_LIMIT);
46
52
  let ordered: T[] = [];
47
53
  while (pool.length > 0) {
48
54
  let weights = pool.map(e => 1 / (e.latency + LATENCY_WEIGHT_OFFSET));
49
- let cutoff = Math.max(...weights) / LATENCY_WEIGHT_CUTOFF_FACTOR;
55
+ let maxWeight = Math.max(...weights);
50
56
  for (let i = 0; i < weights.length; i++) {
51
- if (weights[i] < cutoff) weights[i] = 0;
57
+ let cutoffFactor = LATENCY_CUTOFF_BASE_FACTOR - Math.min(pool[i].historyUsed, LATENCY_HISTORY_COUNT);
58
+ if (weights[i] < maxWeight / cutoffFactor) {
59
+ weights[i] = 0;
60
+ }
52
61
  }
53
62
  let total = 0;
54
63
  for (let w of weights) {
@@ -90,6 +99,7 @@ export type AuthoritySpec = {
90
99
  routeEnd: number;
91
100
  // If the path.startsWith(prefix), but prefix !== path, then we hash getPathIndex(path, hashIndex)
92
101
  // - For now, let's just never add overlapping prefixes.
102
+ // - Sorted by originalPrefix (authorityLookup.setOurSpec sorts before publishing), so specs can be compared element-wise.
93
103
  prefixes: PrefixMatcher[];
94
104
  // - Make sure to set this if you just want the prefix values, otherwise you will get all that that prefixes don't match (the prefixes exclude prefix match but where route does not match).
95
105
  excludeDefault?: boolean;
@@ -162,6 +172,18 @@ export function parsePrefixMatcher(prefixPath: string): PrefixMatcher {
162
172
  };
163
173
  }
164
174
 
175
+ // Full-overlap equivalence: same range, same default matching, and the same prefixes (prefixes are sorted, so element-wise comparison works).
176
+ export function areAuthoritySpecsEquivalent(a: AuthoritySpec, b: AuthoritySpec): boolean {
177
+ if (a.routeStart !== b.routeStart) return false;
178
+ if (a.routeEnd !== b.routeEnd) return false;
179
+ if (!a.excludeDefault !== !b.excludeDefault) return false;
180
+ if (a.prefixes.length !== b.prefixes.length) return false;
181
+ for (let [i, prefix] of a.prefixes.entries()) {
182
+ if (prefix.originalPrefix !== b.prefixes[i].originalPrefix) return false;
183
+ }
184
+ return true;
185
+ }
186
+
165
187
  export function debugSpec(spec: AuthoritySpec) {
166
188
  return {
167
189
  info: `${spec.routeStart}-${spec.routeEnd} (${spec.prefixes.length} prefixes${spec.excludeDefault ? " excluding default" : ""})`,
@@ -6,7 +6,9 @@ import { isNode, sort, timeInMinute, timeInSecond } from "socket-function/src/mi
6
6
  import { measureFnc, measureBlock } from "socket-function/src/profiling/measure";
7
7
  import { getOwnNodeId, isOwnNodeId } from "../-f-node-discovery/NodeDiscovery";
8
8
  import { PathValueController } from "../0-path-value-core/PathValueController";
9
- import { AuthoritySpec, PathRouter } from "../0-path-value-core/PathRouter";
9
+ import { AuthoritySpec, PathRouter, areAuthoritySpecsEquivalent } from "../0-path-value-core/PathRouter";
10
+ import { getNodeLatencyMedian } from "../-f-node-discovery/LatencyTracking";
11
+ import { formatTime } from "socket-function/src/formatting/format";
10
12
  import { WatchConfig, authorityStorage } from "../0-path-value-core/pathValueCore";
11
13
  import { pathWatcher } from "../0-path-value-core/PathWatcher";
12
14
  import { ActionsHistory } from "../diagnostics/ActionsHistory";
@@ -29,9 +31,15 @@ setImmediate(() => import("../4-querysub/Querysub"));
29
31
  const STOP_KEYS_DOUBLE_SENDS = true;
30
32
 
31
33
  // How close to an authority's scheduled shutdown we start treating it like a disconnect (rehoming all the paths we watch on it).
32
- const SHUTDOWN_REHOME_WINDOW = timeInMinute * 5;
34
+ const SHUTDOWN_REHOME_WINDOW = timeInMinute * 3;
33
35
  const SHUTDOWN_REHOME_POLL_INTERVAL = timeInSecond * 30;
34
36
 
37
+ // Rehome the watches on an authority when an equivalent authority has this factor lower latency. At 2 the router's full-confidence cutoff factor (also 2) then reliably routes everything to the better node on the rewatch.
38
+ const LATENCY_REHOME_FACTOR = 2;
39
+ // Both latencies must be medians over this many samples — with fewer we don't trust either number enough to move watches over it.
40
+ const LATENCY_REHOME_HISTORY_COUNT = 10;
41
+ const LATENCY_REHOME_POLL_INTERVAL = timeInMinute;
42
+
35
43
  // NOTE: If a parent watch is broken up between multiple nodes, we generally watch everything on all those nodes and then filter when we receive the data. This isn't efficient, and we should probably change it. However, in practice, it's probably fine, as it's unlikely for the watches to be broken up to a much finer granularity than the network (it would require very strange partially overlapping sharding cases which no reasonable sharding setup would ever satisfy).
36
44
  export class RemoteWatcher {
37
45
  public static DEBUG = false;
@@ -152,6 +160,40 @@ export class RemoteWatcher {
152
160
  }
153
161
  }
154
162
  }
163
+
164
+ private latencyRehomeLoop = runInfinitePoll(LATENCY_REHOME_POLL_INTERVAL, () => this.rehomeHighLatencyAuthorities());
165
+ private rehomeHighLatencyAuthorities() {
166
+ let authorityIds = new Set<string>(this.remoteWatchPaths.values());
167
+ for (let watchObj of this.remoteWatchParents2.values()) {
168
+ for (let range of watchObj.ranges) {
169
+ authorityIds.add(range.authorityId);
170
+ }
171
+ }
172
+ // Also guards getTopologySync — if we watch anything remotely, the authority lookup has synced.
173
+ if (authorityIds.size === 0) return;
174
+ let topology = authorityLookup.getTopologySync();
175
+ for (let authorityId of authorityIds) {
176
+ if (isOwnNodeId(authorityId)) continue;
177
+ let entry = topology.find(candidate => candidate.nodeId === authorityId);
178
+ if (!entry) continue;
179
+ let current = getNodeLatencyMedian({ nodeId: authorityId, historyCount: LATENCY_REHOME_HISTORY_COUNT });
180
+ if (!current || current.historyUsed < LATENCY_REHOME_HISTORY_COUNT) continue;
181
+ for (let candidate of topology) {
182
+ if (candidate.nodeId === authorityId) continue;
183
+ if (isOwnNodeId(candidate.nodeId)) continue;
184
+ if (!candidate.isReady) continue;
185
+ if (authorityLookup.nodeIsShuttingDownSoon(candidate.nodeId)) continue;
186
+ if (!areAuthoritySpecsEquivalent(entry.authoritySpec, candidate.authoritySpec)) continue;
187
+ let candidateLatency = getNodeLatencyMedian({ nodeId: candidate.nodeId, historyCount: LATENCY_REHOME_HISTORY_COUNT });
188
+ if (!candidateLatency || candidateLatency.historyUsed < LATENCY_REHOME_HISTORY_COUNT) continue;
189
+ if (candidateLatency.latency * LATENCY_REHOME_FACTOR > current.latency) continue;
190
+ console.log(yellow(`Authority ${authorityId} has high latency (${formatTime(current.latency)}), and the equivalent authority ${candidate.nodeId} is much faster (${formatTime(candidateLatency.latency)}), so we are rehoming all paths watched on it`));
191
+ logErrors(this.refreshAllWatches(authorityId));
192
+ break;
193
+ }
194
+ }
195
+ }
196
+
155
197
  private async tryToReconnectNow() {
156
198
  if (!this.disconnectedPaths.size && !this.disconnectedParents.size) return;
157
199
 
@@ -709,6 +751,8 @@ export class RemoteWatcher {
709
751
  }
710
752
  let paths = Array.from(pathsToWatch);
711
753
 
754
+ console.log(yellow(`Refreshing all watches on ${authorityNodeId}: ${pathsToWatch.size} paths, ${parentPathsToRewatch.size} parent paths (${parentRemotePathsToUnwatch.size} remote parent ranges)`));
755
+
712
756
  logErrors(RemoteWatcher.REMOTE_UNWATCH_FUNCTION({
713
757
  paths,
714
758
  parentPaths: Array.from(parentRemotePathsToUnwatch)
@@ -7,13 +7,13 @@
7
7
  import { SocketFunction } from "socket-function/SocketFunction";
8
8
  import { timeInMinute, timeInSecond, sort } from "socket-function/src/misc";
9
9
  import { lazy } from "socket-function/src/caching";
10
- import { delay, runInfinitePollCallAtStart } from "socket-function/src/batching";
10
+ import { delay, runInfinitePoll, runInfinitePollCallAtStart } from "socket-function/src/batching";
11
11
  import { isClient } from "../config2";
12
12
  import { t } from "../2-proxy/schema2";
13
13
  import { createLocalSchema } from "./schemaHelpers";
14
14
  import { Querysub } from "./Querysub";
15
15
  import { getAllNodeIds, getOwnNodeId, watchDeltaNodeIds } from "../-f-node-discovery/NodeDiscovery";
16
- import { getNodeLatencyInfo } from "../-f-node-discovery/LatencyTracking";
16
+ import { getNodeLatencyInfo, getNodeLatencyMedian } from "../-f-node-discovery/LatencyTracking";
17
17
  import { FunctionRunnerInfoController, FunctionStatsSummary } from "../3-path-functions/PathFunctionRunner";
18
18
  import { URLParam } from "../library-components/URLParam";
19
19
  import { logErrors, timeoutToUndefined, timeoutToUndefinedSilent } from "../errors";
@@ -37,6 +37,12 @@ const CALL_TIME_WEIGHT_BASE = 1000;
37
37
  // If every runner on a network will be shut down within this window, the network is dying and we switch off of it.
38
38
  const NETWORK_DYING_WINDOW = timeInMinute * 2;
39
39
 
40
+ // Once selected we stick with a network — only switching when another network's best node has this factor lower latency than our network's best node (comparing medians, both with full history, so we never switch on noisy data).
41
+ const NETWORK_SWITCH_LATENCY_FACTOR = 2;
42
+ const NETWORK_SWITCH_HISTORY_COUNT = 10;
43
+ // The latency data is already collected by LatencyTracking, so checking is cheap and can run often.
44
+ const NETWORK_SWITCH_POLL_INTERVAL = timeInMinute;
45
+
40
46
  // The forced network is a URL parameter, so it survives reloads and is shareable / obvious in the URL.
41
47
  export const forcedNetworkURL = new URLParam("network", "");
42
48
 
@@ -306,11 +312,75 @@ export function getAutoSelectedNetwork(): string | undefined {
306
312
  return candidates[0]?.network;
307
313
  }
308
314
 
315
+ // The lowest full-history median latency among the network's runners (the best node is what we compare networks by).
316
+ function getNetworkBestNodeLatency(network: string): number | undefined {
317
+ let index = getFunctionRunnerIndex();
318
+ if (!index) return undefined;
319
+ let best: number | undefined;
320
+ for (let node of index.nodes) {
321
+ if (!node.networks.includes(network)) continue;
322
+ let median = getNodeLatencyMedian({ nodeId: node.nodeId, historyCount: NETWORK_SWITCH_HISTORY_COUNT });
323
+ if (!median || median.historyUsed < NETWORK_SWITCH_HISTORY_COUNT) continue;
324
+ if (best === undefined || median.latency < best) {
325
+ best = median.latency;
326
+ }
327
+ }
328
+ return best;
329
+ }
330
+
331
+ let stickyNetwork: string | undefined;
332
+ function checkNetworkSwitch() {
333
+ if (!stickyNetwork) return;
334
+ let currentLatency = getNetworkBestNodeLatency(stickyNetwork);
335
+ if (currentLatency === undefined) return;
336
+
337
+ let candidates = getNetworkSelectionInfos().filter(x => !x.dying && x.network !== stickyNetwork);
338
+ if (isPublic()) {
339
+ candidates = candidates.filter(x => x.isPublic);
340
+ }
341
+ let upLongEnough = candidates.filter(x => x.upLongEnough);
342
+ if (upLongEnough.length > 0) {
343
+ candidates = upLongEnough;
344
+ }
345
+
346
+ let bestNetwork: string | undefined;
347
+ let bestLatency: number | undefined;
348
+ for (let candidate of candidates) {
349
+ let latency = getNetworkBestNodeLatency(candidate.network);
350
+ if (latency === undefined) continue;
351
+ if (bestLatency === undefined || latency < bestLatency) {
352
+ bestLatency = latency;
353
+ bestNetwork = candidate.network;
354
+ }
355
+ }
356
+ if (bestNetwork === undefined || bestLatency === undefined) return;
357
+ if (bestLatency * NETWORK_SWITCH_LATENCY_FACTOR > currentLatency) return;
358
+
359
+ console.log(yellow(`Switching the selected function network from ${stickyNetwork} (best node latency ${formatTime(currentLatency)}) to ${bestNetwork} (best node latency ${formatTime(bestLatency)}), so all new function calls use the faster network`));
360
+ stickyNetwork = bestNetwork;
361
+ }
362
+ const startNetworkSwitchPoll = lazy(() => {
363
+ logErrors(runInfinitePoll(NETWORK_SWITCH_POLL_INTERVAL, checkNetworkSwitch));
364
+ });
365
+
366
+ // Sticky: the score-based pick only initializes (or replaces a dead/dying selection); after that only checkNetworkSwitch changes it, so calls don't bounce between networks on small score changes.
367
+ function getStickyNetwork(): string | undefined {
368
+ void startNetworkSwitchPoll();
369
+ if (stickyNetwork) {
370
+ let info = getNetworkSelectionInfos().find(x => x.network === stickyNetwork);
371
+ if (info && !info.dying && (!isPublic() || info.isPublic)) {
372
+ return stickyNetwork;
373
+ }
374
+ }
375
+ stickyNetwork = getAutoSelectedNetwork();
376
+ return stickyNetwork;
377
+ }
378
+
309
379
  /** Safe to call from any context. Returns the network new function calls should be put on. Throws if no network is available (calls without a network can never run), unless noThrow is set. */
310
380
  export function getSelectedNetwork(): string;
311
381
  export function getSelectedNetwork(config: { noThrow: boolean }): string | undefined;
312
382
  export function getSelectedNetwork(config?: { noThrow?: boolean }): string | undefined {
313
- let network = forcedNetworkURL.value || getAutoSelectedNetwork();
383
+ let network = forcedNetworkURL.value || getStickyNetwork();
314
384
  if (!network && !config?.noThrow) {
315
385
  throw new Error(`No function runner discovered during discovery, so this call cannot be given a network and would never run (we keep polling for function runners, every ${formatTime(POLL_INTERVAL)} when tracking, every ${formatTime(INDEX_POLL_INTERVAL)} for the client index)`);
316
386
  }
@@ -433,6 +433,35 @@ class NodeInfoTable extends qreact.Component<{
433
433
  }
434
434
  }
435
435
 
436
+ // Nodes that never reported their latencies — all we have is their nodeId (and possibly a DNS-resolved machine IP), so this is a much narrower table than NodeInfoTable, still grouped by machine.
437
+ class UnresponsiveNodesTable extends qreact.Component<{ nodeIds: string[]; machineIp: Map<string, string> }> {
438
+ render() {
439
+ let rows = this.props.nodeIds.map(nodeId => ({ nodeId, machineId: machineIdOf(nodeId) }));
440
+ let sep = String.fromCharCode(1);
441
+ sort(rows, row => `${row.machineId}${sep}${threadLabel(row.nodeId)}`);
442
+ return <div className={css.vbox(0).fillWidth.overflowAuto.bord2(0, 0, 85)}>
443
+ <div className={css.hbox(0).hsl(0, 0, 96).colorhsl(0, 0, 20).boldStyle}>
444
+ <div className={css.width(130).flexShrink0.pad2(6).ellipsis}>IP</div>
445
+ <div className={css.width(96).flexShrink0.pad2(6).ellipsis}>Machine</div>
446
+ <div className={css.width(130).flexShrink0.pad2(6).ellipsis}>Thread:Port</div>
447
+ <div className={css.width(520).flexShrink0.pad2(6).ellipsis}>Node Id</div>
448
+ </div>
449
+ {rows.map((row, i) => {
450
+ let firstOfMachine = i === 0 || rows[i - 1].machineId !== row.machineId;
451
+ return <div
452
+ className={css.hbox(0).fillWidth.hsl(0, 0, 99)
453
+ .borderTop(firstOfMachine ? "1px solid hsl(0, 0%, 80%)" : "1px solid hsl(0, 0%, 93%)")}
454
+ >
455
+ <div className={css.width(130).flexShrink0.pad2(6).ellipsis}>{firstOfMachine && this.props.machineIp.get(row.machineId) || ""}</div>
456
+ <div className={css.width(96).flexShrink0.pad2(6).ellipsis}>{firstOfMachine && row.machineId.slice(0, ID_CHARS) || ""}</div>
457
+ <div className={css.width(130).flexShrink0.pad2(6).ellipsis}>{threadLabel(row.nodeId)}</div>
458
+ <div className={css.width(520).flexShrink0.pad2(6).ellipsis.colorhsl(0, 0, 45)} title={row.nodeId}>{row.nodeId}</div>
459
+ </div>;
460
+ })}
461
+ </div>;
462
+ }
463
+ }
464
+
436
465
  class LatencyGraphSection extends qreact.Component<{ nodeIds: string[]; infos: NodeAuthorityInfo[] }> {
437
466
  render() {
438
467
  let nodeIds = this.props.nodeIds;
@@ -625,11 +654,13 @@ class LatencyGraphSection extends qreact.Component<{ nodeIds: string[]; infos: N
625
654
 
626
655
  // One row per thread node, grouped so a machine's threads sit together; the selected machine sorts to the top.
627
656
  // Single composite key: selected-first, then machine, then thread — the low separator keeps segments ordered.
628
- let rows = nodeIds.map(nodeId => buildNodeRow({ nodeId, runner: runnerByNode.get(nodeId), info: infoByNode.get(nodeId), traffic: trafficMaps.get(nodeId), machineIp, machineNetworks }));
657
+ let rows = nodeIds.filter(nodeId => respondedSet.has(nodeId)).map(nodeId => buildNodeRow({ nodeId, runner: runnerByNode.get(nodeId), info: infoByNode.get(nodeId), traffic: trafficMaps.get(nodeId), machineIp, machineNetworks }));
629
658
  let selected = selectedMachineParam.value;
630
659
  let sep = String.fromCharCode(1);
631
660
  sort(rows, row => `${row.machineId === selected ? 0 : 1}${sep}${row.machineId}${sep}${row.threadPort}`);
632
661
 
662
+ let unresponsiveNodeIds = nodeIds.filter(nodeId => !respondedSet.has(nodeId));
663
+
633
664
  return <div className={css.vbox(8).fillWidth}>
634
665
  <div className={css.hbox(14)}>
635
666
  <h2 className={css.margin(0)}>Latency Graph ({machineRespondedThreads.size} machines · {respondedSet.size}/{nodeIds.length} nodes reported)</h2>
@@ -656,6 +687,10 @@ class LatencyGraphSection extends qreact.Component<{ nodeIds: string[]; infos: N
656
687
  <div className={css.colorhsl(0, 0, 50)}>Click a node to sort its information to the top of the table below.</div>
657
688
  <h2 className={css.margin(0)}>Nodes ({rows.length})</h2>
658
689
  <NodeInfoTable rows={rows} selectedMachine={selected} machineColumns={machineColumns} nodeMachineLatency={nodeMachineLatency} />
690
+ {unresponsiveNodeIds.length > 0 && <>
691
+ <h2 className={css.margin(0)}>Unresponsive Nodes ({unresponsiveNodeIds.length})</h2>
692
+ <UnresponsiveNodesTable nodeIds={unresponsiveNodeIds} machineIp={machineIp} />
693
+ </>}
659
694
  </div>;
660
695
  }
661
696
  }
@@ -78,7 +78,8 @@ if (isNode()) {
78
78
  }
79
79
 
80
80
  if (data.toString().includes("\r")) {
81
- logAll();
81
+ import("./watchdog").then(m => m.logUnfiltered());
82
+ //logAll();
82
83
  }
83
84
  });
84
85
 
@@ -116,6 +116,17 @@ function logProfileMeasuresTimingsNow() {
116
116
  thresholdInTable: 0
117
117
  });
118
118
  };
119
+ export function logUnfiltered(depth = 2) {
120
+ let profile = measureObj.finish();
121
+ measureObj = startMeasure();
122
+ logMeasureTable(profile, {
123
+ name: `all logs at ${new Date().toLocaleString()}`,
124
+ mergeDepth: depth,
125
+ minTimeToLog: 0,
126
+ maxTableEntries: 10000000,
127
+ thresholdInTable: 0
128
+ });
129
+ };
119
130
 
120
131
 
121
132
  registerPeriodic(logProfileMeasuresTimingsNow);
package/src/server.ts CHANGED
@@ -1,3 +1,10 @@
1
+ import { disableMeasurements } from "socket-function/src/profiling/measure";
2
+ // NOTE: Profiling seems to make us use about twice as much memory, at least in the bad case where we have many tiny watchers.
3
+ if (typeof document === "undefined" && (process.argv.includes("--noprofile") || process.argv.includes("--nprofile"))) {
4
+ disableMeasurements();
5
+ }
6
+
7
+
1
8
  import "./forceProduction";
2
9
  import "./inject";
3
10