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 +1 -1
- package/src/-f-node-discovery/LatencyTracking.ts +18 -1
- package/src/-h-path-value-serialize/PathValueSerializer.ts +35 -18
- package/src/0-path-value-core/AuthorityLookup.ts +4 -2
- package/src/0-path-value-core/PathRouter.ts +32 -10
- package/src/1-path-client/RemoteWatcher.ts +46 -2
- package/src/4-querysub/FunctionRunnerTracking.ts +73 -3
- package/src/diagnostics/misc-pages/RoutingTablePage.tsx +36 -1
- package/src/diagnostics/periodic.ts +2 -1
- package/src/diagnostics/watchdog.ts +11 -0
- package/src/server.ts +7 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
407
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 *
|
|
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 {
|
|
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
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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 =>
|
|
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
|
|
55
|
+
let maxWeight = Math.max(...weights);
|
|
50
56
|
for (let i = 0; i < weights.length; i++) {
|
|
51
|
-
|
|
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 *
|
|
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 ||
|
|
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
|
}
|
|
@@ -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
|
|