querysub 0.524.0 → 0.525.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 +2 -2
- package/src/-c-identity/IdentityController.ts +2 -2
- package/src/-d-trust/NetworkTrust2.ts +2 -2
- package/src/-f-node-discovery/LatencyTracking.ts +8 -1
- package/src/-f-node-discovery/TrafficTracking.ts +25 -19
- package/src/0-path-value-core/PathRouter.ts +11 -3
- package/src/0-path-value-core/PathValueCommitter.ts +2 -2
- package/src/0-path-value-core/PathValueController.ts +74 -54
- package/src/0-path-value-core/ValidStateComputer.ts +28 -8
- package/src/1-path-client/RemoteWatcher.ts +13 -0
- package/src/3-path-functions/PathFunctionRunner.ts +4 -1
- package/src/4-deploy/edgeBootstrap.ts +1 -1
- package/src/4-querysub/QuerysubController.ts +0 -2
- package/src/diagnostics/logs/IndexedLogs/IndexedLogs.ts +1 -1
- package/src/diagnostics/misc-pages/RoutingTablePage.tsx +133 -17
- package/src/diagnostics/pathAuditer.ts +77 -18
- package/src/library-components/LatencyGraph.tsx +43 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "querysub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.525.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",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"pako": "^2.1.0",
|
|
71
71
|
"peggy": "^5.0.6",
|
|
72
72
|
"sliftutils": "^1.7.5",
|
|
73
|
-
"socket-function": "^1.2.
|
|
73
|
+
"socket-function": "^1.2.14",
|
|
74
74
|
"terser": "^5.31.0",
|
|
75
75
|
"typenode": "^6.6.1",
|
|
76
76
|
"typesafecss": "^0.32.0",
|
|
@@ -19,7 +19,7 @@ import { waitForFirstTimeSync } from "socket-function/time/trueTimeShim";
|
|
|
19
19
|
import { red } from "socket-function/src/formatting/logColors";
|
|
20
20
|
import { isNode } from "typesafecss";
|
|
21
21
|
import { areNodeIdsEqual, getOwnNodeId, getOwnThreadId } from "../-f-node-discovery/NodeDiscovery";
|
|
22
|
-
import { timeInMinute } from "socket-function/src/misc";
|
|
22
|
+
import { timeInMinute, isIpDomain } from "socket-function/src/misc";
|
|
23
23
|
import { isClient, isServer } from "../config2";
|
|
24
24
|
import { getDomain } from "../config";
|
|
25
25
|
|
|
@@ -242,7 +242,7 @@ const changeIdentityOnce = cacheWeak(async function changeIdentityOnce(connectio
|
|
|
242
242
|
certIssuer: issuer.cert.toString(),
|
|
243
243
|
mountedPort: getNodeIdLocation(SocketFunction.mountedNodeId)?.port,
|
|
244
244
|
debugEntryPoint: isServer() ? process.argv[1] : "browser",
|
|
245
|
-
clientIsNode: isServer(),
|
|
245
|
+
clientIsNode: isServer() && !isIpDomain(nodeId),
|
|
246
246
|
};
|
|
247
247
|
let signature = sign(threadKeyCert, payload);
|
|
248
248
|
await timeoutToError(
|
|
@@ -111,7 +111,7 @@ export async function isNodeTrusted(nodeId: string) {
|
|
|
111
111
|
return await isTrusted(machineId);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
const loadServerCert = cache(async (machineId: string) => {
|
|
114
|
+
export const loadServerCert = cache(async (machineId: string) => {
|
|
115
115
|
// This cert isn't stored in the archives, but... this is fine?
|
|
116
116
|
if (machineId === "127-0-0-1." + getDomain()) return;
|
|
117
117
|
let certFile = await archives().get(machineId);
|
|
@@ -131,7 +131,7 @@ export const ensureWeAreTrusted = lazy(measureWrap(async () => {
|
|
|
131
131
|
}
|
|
132
132
|
}));
|
|
133
133
|
|
|
134
|
-
async function loadTrustCerts(nodeId: string) {
|
|
134
|
+
export async function loadTrustCerts(nodeId: string) {
|
|
135
135
|
let location = getNodeIdLocation(nodeId);
|
|
136
136
|
if (location) {
|
|
137
137
|
let machineId = getMachineId(location.address, getDomain());
|
|
@@ -43,6 +43,10 @@ export function getNodeLatency(nodeId: string): number | undefined {
|
|
|
43
43
|
return getNodeLatencyInfo(nodeId)?.averageLatency;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
export function getCachedNodeLatencyInfoList(): Map<string, NodeLatencyInfo> {
|
|
47
|
+
return latencyByNode;
|
|
48
|
+
}
|
|
49
|
+
|
|
46
50
|
/** Our measured latency to every node we have reached, as a plain map (for sending over the wire). */
|
|
47
51
|
export function getOwnLatencies(): { [nodeId: string]: number } {
|
|
48
52
|
void startLatencyTracking();
|
|
@@ -72,9 +76,11 @@ async function pingNode(nodeId: string) {
|
|
|
72
76
|
recordLatency(nodeId, Date.now() - start);
|
|
73
77
|
}
|
|
74
78
|
|
|
79
|
+
let firstCall = true;
|
|
75
80
|
async function pollLatencies() {
|
|
76
81
|
let nodeIds = (await getAllNodeIds()).filter(nodeId => !isOwnNodeId(nodeId));
|
|
77
|
-
|
|
82
|
+
let spread = firstCall ? 0 : LATENCY_POLL_INTERVAL;
|
|
83
|
+
await spreadCallsOverTime(nodeIds, spread, pingNode);
|
|
78
84
|
|
|
79
85
|
let now = Date.now();
|
|
80
86
|
for (let [nodeId, info] of Array.from(latencyByNode)) {
|
|
@@ -82,6 +88,7 @@ async function pollLatencies() {
|
|
|
82
88
|
latencyByNode.delete(nodeId);
|
|
83
89
|
}
|
|
84
90
|
}
|
|
91
|
+
firstCall = false;
|
|
85
92
|
}
|
|
86
93
|
|
|
87
94
|
export const startLatencyTracking = lazy(async () => {
|
|
@@ -26,14 +26,14 @@ const BUCKET_HISTORY = BUCKET_SIZE * 20;
|
|
|
26
26
|
// not O(client connections) — clients churn through many raw ids per hour and we never want a bucket per client.
|
|
27
27
|
const OUTSIDE_KEY = "";
|
|
28
28
|
|
|
29
|
-
export type NodeDataTraffic = { sent: number; received: number; };
|
|
29
|
+
export type NodeDataTraffic = { sent: number; received: number; pathValuesSent: number; pathValuesReceived: number; };
|
|
30
30
|
// All numbers are PER-SECOND rates (see file header).
|
|
31
31
|
export type TrafficStats = {
|
|
32
32
|
functionsExecuted: number;
|
|
33
33
|
pathValuesSent: number;
|
|
34
34
|
pathValuesReceived: number;
|
|
35
35
|
querysubCalls: number;
|
|
36
|
-
// Byte rates to/from each known network node, plus one aggregate for everything outside the node list.
|
|
36
|
+
// Byte and path value rates to/from each known network node, plus one aggregate for everything outside the node list.
|
|
37
37
|
perNode: { [nodeId: string]: NodeDataTraffic };
|
|
38
38
|
outside: NodeDataTraffic;
|
|
39
39
|
};
|
|
@@ -48,6 +48,8 @@ let querysubCalls: BucketMap = new Map();
|
|
|
48
48
|
// raw connection nodeId => buckets
|
|
49
49
|
let bytesSent = new Map<string, BucketMap>();
|
|
50
50
|
let bytesReceived = new Map<string, BucketMap>();
|
|
51
|
+
let pathValuesSentPerNode = new Map<string, BucketMap>();
|
|
52
|
+
let pathValuesReceivedPerNode = new Map<string, BucketMap>();
|
|
51
53
|
|
|
52
54
|
function addSimple(buckets: BucketMap, amount: number) {
|
|
53
55
|
let bucket = Math.floor(Date.now() / BUCKET_SIZE) * BUCKET_SIZE;
|
|
@@ -65,13 +67,15 @@ function addKeyed(map: Map<string, BucketMap>, key: string, amount: number) {
|
|
|
65
67
|
export function recordFunctionExecuted() {
|
|
66
68
|
addSimple(functionsExecuted, 1);
|
|
67
69
|
}
|
|
68
|
-
export function recordPathValuesSent(count: number) {
|
|
69
|
-
if (count <= 0) return;
|
|
70
|
-
addSimple(pathValuesSent, count);
|
|
70
|
+
export function recordPathValuesSent(config: { count: number; nodeId: string }) {
|
|
71
|
+
if (config.count <= 0) return;
|
|
72
|
+
addSimple(pathValuesSent, config.count);
|
|
73
|
+
addKeyed(pathValuesSentPerNode, trafficKey(config.nodeId), config.count);
|
|
71
74
|
}
|
|
72
|
-
export function recordPathValuesReceived(count: number) {
|
|
73
|
-
if (count <= 0) return;
|
|
74
|
-
addSimple(pathValuesReceived, count);
|
|
75
|
+
export function recordPathValuesReceived(config: { count: number; nodeId: string }) {
|
|
76
|
+
if (config.count <= 0) return;
|
|
77
|
+
addSimple(pathValuesReceived, config.count);
|
|
78
|
+
addKeyed(pathValuesReceivedPerNode, trafficKey(config.nodeId), config.count);
|
|
75
79
|
}
|
|
76
80
|
export function recordQuerysubCall() {
|
|
77
81
|
addSimple(querysubCalls, 1);
|
|
@@ -111,10 +115,10 @@ function bucketRate(buckets: BucketMap, windowSize: number): number {
|
|
|
111
115
|
function aggregateData(windowSize: number): { perNode: { [nodeId: string]: NodeDataTraffic }; outside: NodeDataTraffic; } {
|
|
112
116
|
let known = new Set(getCachedNodeIds());
|
|
113
117
|
let perNode: { [nodeId: string]: NodeDataTraffic } = {};
|
|
114
|
-
let outside: NodeDataTraffic = { sent: 0, received: 0 };
|
|
118
|
+
let outside: NodeDataTraffic = { sent: 0, received: 0, pathValuesSent: 0, pathValuesReceived: 0 };
|
|
115
119
|
// At most (node count + 1) keys, so this whole pass is O(nodes). debugNodeId (a linear lookup) is only used for
|
|
116
120
|
// the rare server-style id that isn't already a known node.
|
|
117
|
-
let attribute = (rawNodeId: string, field:
|
|
121
|
+
let attribute = (rawNodeId: string, field: keyof NodeDataTraffic, rate: number) => {
|
|
118
122
|
if (rate <= 0) return;
|
|
119
123
|
if (rawNodeId === OUTSIDE_KEY) {
|
|
120
124
|
outside[field] += rate;
|
|
@@ -124,7 +128,7 @@ function aggregateData(windowSize: number): { perNode: { [nodeId: string]: NodeD
|
|
|
124
128
|
if (known.has(nice)) {
|
|
125
129
|
let entry = perNode[nice];
|
|
126
130
|
if (!entry) {
|
|
127
|
-
entry = { sent: 0, received: 0 };
|
|
131
|
+
entry = { sent: 0, received: 0, pathValuesSent: 0, pathValuesReceived: 0 };
|
|
128
132
|
perNode[nice] = entry;
|
|
129
133
|
}
|
|
130
134
|
entry[field] += rate;
|
|
@@ -132,14 +136,16 @@ function aggregateData(windowSize: number): { perNode: { [nodeId: string]: NodeD
|
|
|
132
136
|
outside[field] += rate;
|
|
133
137
|
}
|
|
134
138
|
};
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
139
|
+
let aggregateMap = (map: Map<string, BucketMap>, field: keyof NodeDataTraffic) => {
|
|
140
|
+
for (let [rawNodeId, buckets] of Array.from(map)) {
|
|
141
|
+
attribute(rawNodeId, field, bucketRate(buckets, windowSize));
|
|
142
|
+
if (buckets.size === 0) map.delete(rawNodeId);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
aggregateMap(bytesSent, "sent");
|
|
146
|
+
aggregateMap(bytesReceived, "received");
|
|
147
|
+
aggregateMap(pathValuesSentPerNode, "pathValuesSent");
|
|
148
|
+
aggregateMap(pathValuesReceivedPerNode, "pathValuesReceived");
|
|
143
149
|
return { perNode, outside };
|
|
144
150
|
}
|
|
145
151
|
|
|
@@ -22,6 +22,9 @@ 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
28
|
// Latency assumed for a node we haven't measured yet, so new/unreached nodes stay eligible but aren't preferred.
|
|
26
29
|
const UNKNOWN_LATENCY_MS = 50;
|
|
27
30
|
|
|
@@ -42,14 +45,19 @@ function latencyWeightedShuffle<T extends { nodeId: string }>(arr: T[]) {
|
|
|
42
45
|
let rest = withLatency.slice(LATENCY_CANDIDATE_LIMIT);
|
|
43
46
|
let ordered: T[] = [];
|
|
44
47
|
while (pool.length > 0) {
|
|
48
|
+
let weights = pool.map(e => 1 / (e.latency + LATENCY_WEIGHT_OFFSET));
|
|
49
|
+
let cutoff = Math.max(...weights) / LATENCY_WEIGHT_CUTOFF_FACTOR;
|
|
50
|
+
for (let i = 0; i < weights.length; i++) {
|
|
51
|
+
if (weights[i] < cutoff) weights[i] = 0;
|
|
52
|
+
}
|
|
45
53
|
let total = 0;
|
|
46
|
-
for (let
|
|
47
|
-
total +=
|
|
54
|
+
for (let w of weights) {
|
|
55
|
+
total += w;
|
|
48
56
|
}
|
|
49
57
|
let r = Math.random() * total;
|
|
50
58
|
let idx = 0;
|
|
51
59
|
while (idx < pool.length - 1) {
|
|
52
|
-
r -=
|
|
60
|
+
r -= weights[idx];
|
|
53
61
|
if (r <= 0) break;
|
|
54
62
|
idx++;
|
|
55
63
|
}
|
|
@@ -144,7 +144,7 @@ class PathValueCommitter {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
private broadcastValues = batchFunction(
|
|
147
|
-
{ delay:
|
|
147
|
+
{ delay: 1, throttleWindow: 2000, noMeasure: true },
|
|
148
148
|
async function internal_forwardWrites(valuesBatched: {
|
|
149
149
|
values: Set<PathValue>;
|
|
150
150
|
tryCount?: number;
|
|
@@ -327,7 +327,7 @@ class PathValueCommitter {
|
|
|
327
327
|
|
|
328
328
|
|
|
329
329
|
public ingestRemoteValuesAndValidStates = batchFunction(
|
|
330
|
-
{ delay:
|
|
330
|
+
{ delay: 1, throttleWindow: 1000, name: "ingestRemoteValuesAndValidStates", noMeasure: true },
|
|
331
331
|
async (batched: RemoteValueAndValidState[]) => {
|
|
332
332
|
const { remoteWatcher } = await import("../1-path-client/RemoteWatcher");
|
|
333
333
|
|
|
@@ -23,11 +23,18 @@ import { encodeCborx, decodeCborx } from "../misc/cloneHelpers";
|
|
|
23
23
|
import { debugGetAllCallFactories } from "socket-function/src/nodeCache";
|
|
24
24
|
import { delay } from "socket-function/src/batching";
|
|
25
25
|
import { isDefined } from "../misc";
|
|
26
|
+
import { measureBlock } from "socket-function/src/profiling/measure";
|
|
26
27
|
export { pathValueCommitter };
|
|
27
28
|
|
|
28
29
|
let pathValueSendCount = 0;
|
|
29
30
|
export function getPathValueSendCount() { return pathValueSendCount; }
|
|
30
31
|
|
|
32
|
+
// Debug hook, called with every batch of values sendData receives, before any filtering or ingest — so tests can observe exactly when values arrive, without worrying about values being ignored or batched downstream.
|
|
33
|
+
let debugOnSendData: ((values: PathValue[], sourceNodeId: string) => void) | undefined;
|
|
34
|
+
export function setDebugOnSendData(callback: typeof debugOnSendData) {
|
|
35
|
+
debugOnSendData = callback;
|
|
36
|
+
}
|
|
37
|
+
|
|
31
38
|
// ONLY returns non-canGCValues that are valid
|
|
32
39
|
export type AuditSnapshotEntry = {
|
|
33
40
|
path: string;
|
|
@@ -53,7 +60,7 @@ export class PathValueControllerBase {
|
|
|
53
60
|
let { pathValues, nodeId } = config;
|
|
54
61
|
|
|
55
62
|
pathValueSendCount += pathValues.length;
|
|
56
|
-
let serializedValues = await pathValueSerializer.serialize(pathValues, { compress: Querysub.COMPRESS_NETWORK });
|
|
63
|
+
let serializedValues = await measureBlock(() => pathValueSerializer.serialize(pathValues, { compress: Querysub.COMPRESS_NETWORK }), "createValues|serialize");
|
|
57
64
|
if (isDebugLogEnabled()) {
|
|
58
65
|
for (let value of pathValues) {
|
|
59
66
|
auditLog("SEND CREATED VALUE", {
|
|
@@ -111,11 +118,11 @@ export class PathValueControllerBase {
|
|
|
111
118
|
let changes = config.pathValues;
|
|
112
119
|
let { nodeId, initialTriggers } = config;
|
|
113
120
|
pathValueSendCount += changes.length;
|
|
114
|
-
recordPathValuesSent(changes.length);
|
|
115
|
-
let buffers = await pathValueSerializer.serialize(changes, {
|
|
121
|
+
recordPathValuesSent({ count: changes.length, nodeId });
|
|
122
|
+
let buffers = await measureBlock(() => pathValueSerializer.serialize(changes, {
|
|
116
123
|
noLocks: !config.keepLocks,
|
|
117
124
|
compress: getCompressNetwork(),
|
|
118
|
-
});
|
|
125
|
+
}), "sendValues|serialize");
|
|
119
126
|
this.logSendValues({ nodeId, pathValues: changes, initialTriggers, reason: config.reason });
|
|
120
127
|
return await PathValueController.nodes[nodeId].sendData({
|
|
121
128
|
valueBuffers: buffers,
|
|
@@ -137,7 +144,7 @@ export class PathValueControllerBase {
|
|
|
137
144
|
|
|
138
145
|
}): Promise<void | "refused"> {
|
|
139
146
|
let callerId = SocketFunction.getCaller().nodeId;
|
|
140
|
-
|
|
147
|
+
const { initialCreation, valueBuffers } = config;
|
|
141
148
|
|
|
142
149
|
let slowdown = getSlowdown();
|
|
143
150
|
if (slowdown) {
|
|
@@ -146,28 +153,34 @@ export class PathValueControllerBase {
|
|
|
146
153
|
|
|
147
154
|
let values: PathValue[] = [];
|
|
148
155
|
if (valueBuffers) {
|
|
149
|
-
values = await pathValueSerializer.deserialize(valueBuffers);
|
|
156
|
+
values = await measureBlock(() => pathValueSerializer.deserialize(valueBuffers), "sendData|deserialize");
|
|
150
157
|
ActionsHistory.OnRead(values);
|
|
151
|
-
recordPathValuesReceived(values.length);
|
|
158
|
+
recordPathValuesReceived({ count: values.length, nodeId: callerId });
|
|
159
|
+
}
|
|
160
|
+
if (debugOnSendData) {
|
|
161
|
+
debugOnSendData(values, callerId);
|
|
152
162
|
}
|
|
153
163
|
|
|
154
164
|
if (initialCreation) {
|
|
155
|
-
let
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
let
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
165
|
+
let rejected = measureBlock(() => {
|
|
166
|
+
let sourceNodeId = debugNodeId(callerId);
|
|
167
|
+
let anyRejected = false;
|
|
168
|
+
let threshold = Date.now() - MAX_CHANGE_AGE;
|
|
169
|
+
for (let value of values) {
|
|
170
|
+
let pastThreshold = threshold - value.time.time;
|
|
171
|
+
if (pastThreshold > 0) {
|
|
172
|
+
anyRejected = true;
|
|
173
|
+
console.error(`Rejecting values as one is too old. It likely got caught in the pipeline too long and now can't be committed without trying to undone history which can already been committed to disk.`, {
|
|
174
|
+
path: value.path,
|
|
175
|
+
timeId: value.time.time,
|
|
176
|
+
sourceNodeId,
|
|
177
|
+
sourceNodeThreadId: decodeNodeId(sourceNodeId, getDomain())?.threadId,
|
|
178
|
+
totalValueCount: values.length,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
169
181
|
}
|
|
170
|
-
|
|
182
|
+
return anyRejected;
|
|
183
|
+
}, "sendData|ageCheck");
|
|
171
184
|
if (rejected) {
|
|
172
185
|
return "refused";
|
|
173
186
|
}
|
|
@@ -189,7 +202,7 @@ export class PathValueControllerBase {
|
|
|
189
202
|
}
|
|
190
203
|
|
|
191
204
|
// Note which values are genuinely new to us BEFORE ingesting. We only re-share new values with the other authorities below; if we already had a value we already shared it when we first received it, so re-sharing is redundant and can clobber a newer value with an older copy.
|
|
192
|
-
let valuesNewToUs = config.initialCreation && values.filter(value => !authorityStorage.getValueExactMaybeRejected(value.path, value.time)) || [];
|
|
205
|
+
let valuesNewToUs = measureBlock(() => config.initialCreation && values.filter(value => !authorityStorage.getValueExactMaybeRejected(value.path, value.time)) || [], "sendData|newToUsCheck");
|
|
193
206
|
|
|
194
207
|
try {
|
|
195
208
|
let initialTriggers = config.initialTriggers || { values: new Set(), parentPaths: new Set() };
|
|
@@ -205,7 +218,8 @@ export class PathValueControllerBase {
|
|
|
205
218
|
|
|
206
219
|
if (config.initialCreation && valuesNewToUs.length > 0) {
|
|
207
220
|
// Always shared the latest. If we don't have it, maybe something with ingestion delayed, we should still share it so we don't lose the value. If we haven't even ingested it, then it is our latest version of the value.
|
|
208
|
-
valuesNewToUs = valuesNewToUs.map(value => authorityStorage.getValueExactMaybeRejected(value.path, value.time) || value);
|
|
221
|
+
valuesNewToUs = measureBlock(() => valuesNewToUs.map(value => authorityStorage.getValueExactMaybeRejected(value.path, value.time) || value), "sendData|newToUsRemap");
|
|
222
|
+
// Not measured: this waits on network sends to the other authorities.
|
|
209
223
|
await PathValueControllerBase.authorityShareValues({ pathValues: valuesNewToUs });
|
|
210
224
|
}
|
|
211
225
|
}
|
|
@@ -242,13 +256,13 @@ export class PathValueControllerBase {
|
|
|
242
256
|
auditLog("WATCH PARENT PATH", { path: value, sourceNodeId });
|
|
243
257
|
}
|
|
244
258
|
}
|
|
245
|
-
pathWatcher.watchPath({
|
|
259
|
+
measureBlock(() => pathWatcher.watchPath({
|
|
246
260
|
nodeId: callerId,
|
|
247
261
|
paths: config.paths,
|
|
248
262
|
parentPaths: config.parentPaths,
|
|
249
263
|
initialTrigger: true,
|
|
250
264
|
fullHistory: config.fullHistory,
|
|
251
|
-
});
|
|
265
|
+
}), "watchLatest|watchPath");
|
|
252
266
|
}
|
|
253
267
|
public async unwatchLatest(config: WatchConfig) {
|
|
254
268
|
let callerId = SocketFunction.getCaller().nodeId;
|
|
@@ -261,7 +275,7 @@ export class PathValueControllerBase {
|
|
|
261
275
|
auditLog("UNWATCHING PARENT PATH", { path: value, sourceNodeId });
|
|
262
276
|
}
|
|
263
277
|
}
|
|
264
|
-
pathWatcher.unwatchPath({ paths: config.paths, parentPaths: config.parentPaths, callback: callerId, reason: "PathValueController.unwatchLatest" });
|
|
278
|
+
measureBlock(() => pathWatcher.unwatchPath({ paths: config.paths, parentPaths: config.parentPaths, callback: callerId, reason: "PathValueController.unwatchLatest" }), "unwatchLatest|unwatchPath");
|
|
265
279
|
}
|
|
266
280
|
|
|
267
281
|
public static async getInitialValues(config: {
|
|
@@ -281,11 +295,11 @@ export class PathValueControllerBase {
|
|
|
281
295
|
startTime: number;
|
|
282
296
|
endTime: number;
|
|
283
297
|
}): Promise<Buffer[]> {
|
|
284
|
-
let values = authorityStorage.getAllValues(config);
|
|
285
|
-
let buffers = await pathValueSerializer.serialize(values, {
|
|
298
|
+
let values = measureBlock(() => authorityStorage.getAllValues(config), "getInitialValues|getAllValues");
|
|
299
|
+
let buffers = await measureBlock(() => pathValueSerializer.serialize(values, {
|
|
286
300
|
noLocks: true,
|
|
287
301
|
compress: getCompressNetwork(),
|
|
288
|
-
});
|
|
302
|
+
}), "getInitialValues|serialize");
|
|
289
303
|
return buffers;
|
|
290
304
|
}
|
|
291
305
|
|
|
@@ -304,23 +318,25 @@ export class PathValueControllerBase {
|
|
|
304
318
|
let { spec } = config;
|
|
305
319
|
let { compareTime } = await import("./pathValueCore");
|
|
306
320
|
|
|
307
|
-
|
|
321
|
+
return measureBlock(() => {
|
|
322
|
+
let allValues = authorityStorage.getAllValues({ spec, startTime: 0, endTime: Number.MAX_SAFE_INTEGER });
|
|
308
323
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
324
|
+
let pathToLatest = new Map<string, Time>();
|
|
325
|
+
for (let value of allValues) {
|
|
326
|
+
if (!value.valid) continue;
|
|
327
|
+
if (value.canGCValue) continue;
|
|
313
328
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
329
|
+
let existing = pathToLatest.get(value.path);
|
|
330
|
+
if (!existing || compareTime(value.time, existing) > 0) {
|
|
331
|
+
pathToLatest.set(value.path, value.time);
|
|
332
|
+
}
|
|
317
333
|
}
|
|
318
|
-
}
|
|
319
334
|
|
|
320
|
-
|
|
335
|
+
let entries: AuditSnapshotEntry[] = Array.from(pathToLatest.entries()).map(([path, time]) => ({ path, time }));
|
|
321
336
|
|
|
322
|
-
|
|
323
|
-
|
|
337
|
+
let encoded = encodeCborx(entries);
|
|
338
|
+
return LZ4.compress(encoded);
|
|
339
|
+
}, "getAuditSnapshot|local");
|
|
324
340
|
}
|
|
325
341
|
|
|
326
342
|
|
|
@@ -333,19 +349,22 @@ export class PathValueControllerBase {
|
|
|
333
349
|
}
|
|
334
350
|
|
|
335
351
|
public async getValuesByPathAndTime(entries: AuditSnapshotEntry[]): Promise<Buffer[]> {
|
|
336
|
-
let values
|
|
337
|
-
|
|
338
|
-
let
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
352
|
+
let values = measureBlock(() => {
|
|
353
|
+
let matched: PathValue[] = [];
|
|
354
|
+
for (let entry of entries) {
|
|
355
|
+
let value = authorityStorage.getValueExactMaybeRejected(entry.path, entry.time);
|
|
356
|
+
if (!value) continue;
|
|
357
|
+
if (!value.valid) continue;
|
|
358
|
+
if (value.isTransparent) continue;
|
|
359
|
+
if (value.canGCValue) continue;
|
|
360
|
+
matched.push(value);
|
|
361
|
+
}
|
|
362
|
+
return matched;
|
|
363
|
+
}, "getValuesByPathAndTime|gather");
|
|
364
|
+
let buffers = await measureBlock(() => pathValueSerializer.serialize(values, {
|
|
346
365
|
noLocks: true,
|
|
347
366
|
compress: getCompressNetwork(),
|
|
348
|
-
});
|
|
367
|
+
}), "getValuesByPathAndTime|serialize");
|
|
349
368
|
return buffers;
|
|
350
369
|
}
|
|
351
370
|
}
|
|
@@ -367,6 +386,7 @@ export const PathValueController = SocketFunction.register(
|
|
|
367
386
|
hooks: [requiresNetworkTrustHook],
|
|
368
387
|
}),
|
|
369
388
|
{
|
|
370
|
-
|
|
389
|
+
// Most of these functions spend their time waiting on batching or remote calls, so auto-measuring them is misleading — instead the local work inside them is wrapped in individual measureBlocks.
|
|
390
|
+
noFunctionMeasure: true,
|
|
371
391
|
}
|
|
372
392
|
);
|
|
@@ -2,7 +2,7 @@ import { keyByArray, binarySearchIndex } from "socket-function/src/misc";
|
|
|
2
2
|
import { measureFnc } from "socket-function/src/profiling/measure";
|
|
3
3
|
import { isNode } from "typesafecss";
|
|
4
4
|
import { isDiskAudit } from "../config";
|
|
5
|
-
import { getParentPathStr, getPathFromStr } from "../path";
|
|
5
|
+
import { getParentPathStr, getPathFromStr, getPathStr4 } from "../path";
|
|
6
6
|
import { auditLog } from "./auditLogs";
|
|
7
7
|
import { PathRouter } from "./PathRouter";
|
|
8
8
|
import { PathValue, authorityStorage, compareTime, debugPathValuePath, ReadLock, byLockGroup, isCoreQuiet, debugRejections, debugTime, debugPathValue, MAX_CHANGE_AGE, createMissingEpochValue, Time, MISSING_TRANSACTION_PART_TIMEOUT, isOurPrediction, DEFER_LOCK_WINDOW } from "./pathValueCore";
|
|
@@ -21,8 +21,9 @@ class ValidStateComputer {
|
|
|
21
21
|
parentSyncs: { parentPath: string; sourceNodeId: string }[];
|
|
22
22
|
initialTriggers: { values: Set<string>; parentPaths: Set<string> };
|
|
23
23
|
doNotArchive?: boolean;
|
|
24
|
+
forceValidStates?: boolean;
|
|
24
25
|
}) {
|
|
25
|
-
let { pathValues, parentSyncs, doNotArchive } = config;
|
|
26
|
+
let { pathValues, parentSyncs, doNotArchive, forceValidStates } = config;
|
|
26
27
|
let initialTriggers = { ...config.initialTriggers, initialTriggerNonHistoryWatchers: new Set<string>() };
|
|
27
28
|
|
|
28
29
|
// TODO: We might want to add back optimizations for "no watches and no locks"?
|
|
@@ -30,6 +31,13 @@ class ValidStateComputer {
|
|
|
30
31
|
|
|
31
32
|
let now = Date.now();
|
|
32
33
|
|
|
34
|
+
let forcedValidStates = new Map<string, boolean>();
|
|
35
|
+
if (forceValidStates) {
|
|
36
|
+
for (let value of pathValues) {
|
|
37
|
+
forcedValidStates.set(getForcedValidStateKey(value), !!value.valid);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
33
41
|
authorityStorage.addParentSyncs(parentSyncs);
|
|
34
42
|
|
|
35
43
|
// Dedup by (path, time): if multiple values for the same (path, time) arrive in this batch,
|
|
@@ -216,6 +224,7 @@ class ValidStateComputer {
|
|
|
216
224
|
Array.from(dependenciesChanged),
|
|
217
225
|
now,
|
|
218
226
|
initialPrevValidStates,
|
|
227
|
+
forcedValidStates,
|
|
219
228
|
);
|
|
220
229
|
let validStateChanged = result.changed;
|
|
221
230
|
for (let d of result.deferred) {
|
|
@@ -319,6 +328,7 @@ class ValidStateComputer {
|
|
|
319
328
|
valuePaths: PathValue[],
|
|
320
329
|
now: number,
|
|
321
330
|
prevValidStates: Map<PathValue, boolean | undefined>,
|
|
331
|
+
forcedValidStates?: Map<string, boolean>,
|
|
322
332
|
): { changed: PathValue[]; deferred: PathValue[] } {
|
|
323
333
|
let changed: PathValue[] = [];
|
|
324
334
|
let deferred: PathValue[] = [];
|
|
@@ -404,6 +414,11 @@ class ValidStateComputer {
|
|
|
404
414
|
|
|
405
415
|
for (let pathValue of valueGroup) {
|
|
406
416
|
let prevValidState = prevValidStates.get(pathValue);
|
|
417
|
+
let newValid: boolean = valid;
|
|
418
|
+
let forcedValid = forcedValidStates?.get(getForcedValidStateKey(pathValue));
|
|
419
|
+
if (forcedValid !== undefined) {
|
|
420
|
+
newValid = forcedValid;
|
|
421
|
+
}
|
|
407
422
|
/* We used to log these values until 2026 June 28th 6:30 am
|
|
408
423
|
if (valid && prevValidState === false) {
|
|
409
424
|
console.info(`Accepting value that was previously rejected`, {
|
|
@@ -428,31 +443,32 @@ class ValidStateComputer {
|
|
|
428
443
|
}
|
|
429
444
|
*/
|
|
430
445
|
// IMPORTANT! This means if it didn't previously exist and it's presently rejected, we count that as a change, as it this is going from undefined to false. This is actually very useful, as a lot of places want to know if a write is rejected, so they can display it in the UI for the developer.
|
|
431
|
-
if (
|
|
446
|
+
if (newValid === prevValidState && pathValue.valid === newValid) continue;
|
|
432
447
|
|
|
433
448
|
// NOTE: We might remove this logging later as it's pretty heavy, but right now we still have bugs somewhere here.
|
|
434
449
|
{
|
|
435
|
-
console.info(`Changed valid state ${JSON.stringify(prevValidState)} to ${JSON.stringify(
|
|
450
|
+
console.info(`Changed valid state ${JSON.stringify(prevValidState)} to ${JSON.stringify(newValid)} (and path is ${JSON.stringify(pathValue.valid)})`, {
|
|
436
451
|
path: pathValue.path,
|
|
437
452
|
timeId: pathValue.time.time,
|
|
438
453
|
timeIdFull: pathValue.time,
|
|
439
|
-
valid,
|
|
454
|
+
valid: newValid,
|
|
455
|
+
forced: forcedValid !== undefined,
|
|
440
456
|
prevValidState,
|
|
441
457
|
});
|
|
442
458
|
}
|
|
443
459
|
|
|
444
460
|
changed.push(pathValue);
|
|
445
461
|
|
|
446
|
-
// NOTE: This should be the only place we set it, and the read-only flag is only for us to prevent anyone from setting it.
|
|
462
|
+
// NOTE: This should be the only place we set it, and the read-only flag is only for us to prevent anyone from setting it. Anyone who sets the valid state anywhere else will be absolutely fired.
|
|
447
463
|
// @ts-expect-error
|
|
448
|
-
pathValue.valid =
|
|
464
|
+
pathValue.valid = newValid;
|
|
449
465
|
|
|
450
466
|
// HACK: There are times when we might be evaluating the same path value multiple times at once (ex, multiple initial syncs at once). In which case we might not equal the stored path value, so we need to forcefully update the valid state of it.
|
|
451
467
|
let storedPathValue = authorityStorage.getValueExactMaybeRejected(pathValue.path, pathValue.time);
|
|
452
468
|
// I think this is fine. I think it happens if we receive it multiple times due to sharding. It's a bit inefficient, but... SHOULD be fine.
|
|
453
469
|
if (storedPathValue && pathValue !== storedPathValue) {
|
|
454
470
|
// @ts-expect-error
|
|
455
|
-
storedPathValue.valid =
|
|
471
|
+
storedPathValue.valid = newValid;
|
|
456
472
|
}
|
|
457
473
|
}
|
|
458
474
|
}
|
|
@@ -541,3 +557,7 @@ class ValidStateComputer {
|
|
|
541
557
|
}
|
|
542
558
|
}
|
|
543
559
|
export const validStateComputer = new ValidStateComputer();
|
|
560
|
+
|
|
561
|
+
function getForcedValidStateKey(pathValue: { time: Time; path: string }): string {
|
|
562
|
+
return getPathStr4(pathValue.path, String(pathValue.time.time), String(pathValue.time.version), String(pathValue.time.creatorId));
|
|
563
|
+
}
|
|
@@ -656,6 +656,19 @@ export class RemoteWatcher {
|
|
|
656
656
|
return Array.from(new Set(this.remoteWatchPaths.values()));
|
|
657
657
|
}
|
|
658
658
|
|
|
659
|
+
public getWatchedPathCountsPerNodeId(): Map<string, number> {
|
|
660
|
+
let counts = new Map<string, number>();
|
|
661
|
+
for (let nodeId of this.remoteWatchPaths.values()) {
|
|
662
|
+
counts.set(nodeId, (counts.get(nodeId) || 0) + 1);
|
|
663
|
+
}
|
|
664
|
+
for (let watchObj of this.remoteWatchParents2.values()) {
|
|
665
|
+
for (let range of watchObj.ranges) {
|
|
666
|
+
counts.set(range.authorityId, (counts.get(range.authorityId) || 0) + 1);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return counts;
|
|
670
|
+
}
|
|
671
|
+
|
|
659
672
|
|
|
660
673
|
|
|
661
674
|
public async refreshAllWatches(authorityNodeId: string) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { batchFunction, delay, runInfinitePoll } from "socket-function/src/batching";
|
|
2
|
-
import { recordFunctionExecuted } from "../-f-node-discovery/TrafficTracking";
|
|
2
|
+
import { recordFunctionExecuted, recordQuerysubCall } from "../-f-node-discovery/TrafficTracking";
|
|
3
3
|
import { cache, lazy } from "socket-function/src/caching";
|
|
4
4
|
import { blue, magenta, yellow } from "socket-function/src/formatting/logColors";
|
|
5
5
|
import { timeInHour, timeInMinute, timeInSecond } from "socket-function/src/misc";
|
|
@@ -56,6 +56,9 @@ setImmediate(() => {
|
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
export function commitCall(call: CallSpec) {
|
|
59
|
+
// Counted here (not in QuerysubController.addCall) so trusted nodes' direct writes are counted too — both the
|
|
60
|
+
// remote addCall path and the direct-write path funnel through here.
|
|
61
|
+
recordQuerysubCall();
|
|
59
62
|
if (!call.network) {
|
|
60
63
|
throw new Error(`Call has no network, so it would never run (no FunctionRunner would pick it up). Call: ${debugCallSpec(call)}`);
|
|
61
64
|
}
|
|
@@ -357,7 +357,7 @@ async function edgeNodeFunction(config: {
|
|
|
357
357
|
// Probes ALL the given nodes (even private / non-live ones), writing latencies to globalThis.EDGE_NODE_STATS (so the edge node dropdown can show them), and returns a latency-weighted random pick among `pickableHosts` (undefined if none respond).
|
|
358
358
|
async function probeAndPick(probeNodes: EdgeNodeConfig[], pickableHosts: Set<string>): Promise<EdgeNodeConfig | undefined> {
|
|
359
359
|
// Having this much lower latency than another node means we pick it 100% of the time (the weight scales linearly, from 1 at the best latency down to 0 at the best latency + this)
|
|
360
|
-
const LATENCY_WEIGHT_WINDOW =
|
|
360
|
+
const LATENCY_WEIGHT_WINDOW = 300;
|
|
361
361
|
// How many probes are in flight at once. The first batch fires simultaneously, so anything that responds more than LATENCY_WEIGHT_WINDOW after the first response has a latency at least that much higher than it, and so would never have been picked anyway.
|
|
362
362
|
const PROBE_BATCH_SIZE = 5;
|
|
363
363
|
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { SocketFunction } from "socket-function/SocketFunction";
|
|
2
2
|
import { cache, lazy } from "socket-function/src/caching";
|
|
3
|
-
import { recordQuerysubCall } from "../-f-node-discovery/TrafficTracking";
|
|
4
3
|
import { appendToPathStr, getPathDepth, getPathStr1, getPathStr3 } from "../path";
|
|
5
4
|
import { FunctionMetadata } from "../3-path-functions/syncSchema";
|
|
6
5
|
import { RemoteWatcher, remoteWatcher } from "../1-path-client/RemoteWatcher";
|
|
@@ -535,7 +534,6 @@ export class QuerysubControllerBase {
|
|
|
535
534
|
|
|
536
535
|
// NOTE: Calls are going to be temporary and random. Any user can use any call ID, so technically you could clobber other users' call IDs, or your our. There wouldn't really be any benefit. Nothing would really happen if you do that, so I don't believe these need to be kept secret. I think if you know someone else's call ID you might be able to read that data, but also it's securely random, so you're not going to be able to guess the call ID.
|
|
537
536
|
public async addCall(call: CallSpec) {
|
|
538
|
-
recordQuerysubCall();
|
|
539
537
|
if (isBootstrapOnly()) throw new Error(`Cannot add calls to bootstrap only server`);
|
|
540
538
|
if (Querysub.DEBUG_CALLS) {
|
|
541
539
|
console.log(`[Querysub] addCall @${debugTime(call.runAtTime)}: ${call.DomainName}.${call.ModuleId}.${call.FunctionId}`);
|
|
@@ -182,11 +182,11 @@ export class IndexedLogs<T> {
|
|
|
182
182
|
};
|
|
183
183
|
let writeBuffers = async (buffers: Buffer[]) => {
|
|
184
184
|
if (Date.now() > endTime) {
|
|
185
|
+
await newStreamer();
|
|
185
186
|
let timeBlockObj = this.getTimeBlock(Date.now());
|
|
186
187
|
startTime = timeBlockObj.startTime;
|
|
187
188
|
endTime = timeBlockObj.endTime;
|
|
188
189
|
path = new TimeFileTree(this.getLocalLogs()).getNewPendingPath(timeBlockObj);
|
|
189
|
-
await newStreamer();
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
let maxSize = this.config.maxSingleFileData || MAX_SINGLE_FILE_DATA;
|
|
@@ -20,6 +20,7 @@ import { getFunctionRunnerIndex, FunctionRunnerNodeInfo } from "../../4-querysub
|
|
|
20
20
|
import { formatTime, formatNumber } from "socket-function/src/formatting/format";
|
|
21
21
|
import { LatencyGraph, LatencyGraphNode, LatencyGraphLink, LatencyGraphLabelLine } from "../../library-components/LatencyGraph";
|
|
22
22
|
import { URLParam } from "../../library-components/URLParam";
|
|
23
|
+
import { mainResets } from "../../library-components/urlResetGroups";
|
|
23
24
|
|
|
24
25
|
const ID_CHARS = 8;
|
|
25
26
|
// Green means querysub, blue means path value, purple means function runner.
|
|
@@ -31,10 +32,11 @@ const PROBE_TIMEOUT_MS = 5000;
|
|
|
31
32
|
const RANGE_BAR_WIDTH_PX = 360;
|
|
32
33
|
const RANGE_BAR_HEIGHT_PX = 14;
|
|
33
34
|
const LATENCY_GRAPH_HEIGHT_PX = 720;
|
|
35
|
+
const MACHINE_LATENCY_WIDTH_PX = 235;
|
|
34
36
|
|
|
35
37
|
// Clicking a machine node in the graph (or a row) sorts that machine's nodes to the top of the table.
|
|
36
|
-
const selectedMachineParam = new URLParam("rtSelectedMachine", "");
|
|
37
|
-
const trafficWindowParam = new URLParam<number>("rtTrafficWindow", timeInMinute * 60);
|
|
38
|
+
const selectedMachineParam = new URLParam("rtSelectedMachine", "", { reset: [mainResets] });
|
|
39
|
+
const trafficWindowParam = new URLParam<number>("rtTrafficWindow", timeInMinute * 60, { reset: [mainResets] });
|
|
38
40
|
|
|
39
41
|
// The per-node table columns, in order. `color` tints the function/path-value/querysub columns to match the graph;
|
|
40
42
|
// `perMachine` columns (the IP and machine id) only print on the first row of each machine group.
|
|
@@ -227,6 +229,43 @@ function machineIdOf(nodeId: string): string {
|
|
|
227
229
|
return parts?.machineId || nodeId;
|
|
228
230
|
}
|
|
229
231
|
|
|
232
|
+
function threadLabel(nodeId: string): string {
|
|
233
|
+
let parts = decodeNodeId(nodeId, getDomain(), "allowMissingThreadId");
|
|
234
|
+
return `${(parts?.threadId || "?").slice(0, ID_CHARS)}:${parts?.port ?? "?"}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// min · median · max of one source node's latencies to a machine's threads (plus a tooltip naming the farthest
|
|
238
|
+
// thread), and the node's total path values sent/received to that machine (summed — the per-thread split isn't interesting).
|
|
239
|
+
function machineLatencyCell(config: {
|
|
240
|
+
sourceLatencies: { [nodeId: string]: number } | undefined;
|
|
241
|
+
threads: string[];
|
|
242
|
+
traffic: TrafficStats | undefined;
|
|
243
|
+
}): { text: string; tooltip: string } {
|
|
244
|
+
let { sourceLatencies, threads, traffic } = config;
|
|
245
|
+
if (!sourceLatencies) return { text: "", tooltip: "" };
|
|
246
|
+
let entries: { thread: string; ms: number }[] = [];
|
|
247
|
+
for (let thread of threads) {
|
|
248
|
+
let ms = sourceLatencies[thread];
|
|
249
|
+
if (Number.isFinite(ms)) entries.push({ thread, ms });
|
|
250
|
+
}
|
|
251
|
+
if (!entries.length) return { text: "", tooltip: "" };
|
|
252
|
+
sort(entries, entry => entry.ms);
|
|
253
|
+
let farthest = entries[entries.length - 1];
|
|
254
|
+
let text = `${formatTime(entries[0].ms)} · ${formatTime(entries[Math.floor(entries.length / 2)].ms)} · ${formatTime(farthest.ms)}`;
|
|
255
|
+
let pvSent = 0;
|
|
256
|
+
let pvReceived = 0;
|
|
257
|
+
for (let thread of threads) {
|
|
258
|
+
let data = traffic?.perNode?.[thread];
|
|
259
|
+
if (!data) continue;
|
|
260
|
+
pvSent += data.pathValuesSent || 0;
|
|
261
|
+
pvReceived += data.pathValuesReceived || 0;
|
|
262
|
+
}
|
|
263
|
+
if (pvSent || pvReceived) {
|
|
264
|
+
text += ` ↑${formatNumber(pvSent)}/s ↓${formatNumber(pvReceived)}/s values`;
|
|
265
|
+
}
|
|
266
|
+
return { text, tooltip: `${text}\nfarthest: ${threadLabel(farthest.thread)} (${formatTime(farthest.ms)})` };
|
|
267
|
+
}
|
|
268
|
+
|
|
230
269
|
// Summed traffic metrics across a set of nodes (a machine's threads).
|
|
231
270
|
function sumTraffic(threads: string[], trafficMaps: Map<string, TrafficStats>) {
|
|
232
271
|
let dataSent = 0;
|
|
@@ -313,7 +352,7 @@ function buildNodeRow(config: {
|
|
|
313
352
|
if (runner) {
|
|
314
353
|
if (runner.networks.length) fnParts.push(runner.networks.join(" "));
|
|
315
354
|
for (let shard of runner.shards) {
|
|
316
|
-
fnParts.push(
|
|
355
|
+
fnParts.push(`${shard.shardRange.startFraction.toFixed(3)}-${shard.shardRange.endFraction.toFixed(3)}`);
|
|
317
356
|
}
|
|
318
357
|
fnParts.push(`${formatNumber(traffic?.functionsExecuted ?? 0)}/s calls`);
|
|
319
358
|
if (!runner.isPublic) fnParts.push("PRIVATE");
|
|
@@ -322,7 +361,7 @@ function buildNodeRow(config: {
|
|
|
322
361
|
let pvParts: string[] = [];
|
|
323
362
|
let spec = info?.spec;
|
|
324
363
|
if (spec && spec.routeStart >= 0 && spec.routeEnd >= 0) {
|
|
325
|
-
pvParts.push(
|
|
364
|
+
pvParts.push(`${spec.routeStart.toFixed(3)}-${spec.routeEnd.toFixed(3)}`);
|
|
326
365
|
}
|
|
327
366
|
if (traffic && (traffic.pathValuesSent || traffic.pathValuesReceived)) {
|
|
328
367
|
pvParts.push(`↑${formatNumber(traffic.pathValuesSent)}/s ↓${formatNumber(traffic.pathValuesReceived)}/s values`);
|
|
@@ -344,19 +383,30 @@ function buildNodeRow(config: {
|
|
|
344
383
|
};
|
|
345
384
|
}
|
|
346
385
|
|
|
347
|
-
class NodeInfoTable extends qreact.Component<{
|
|
386
|
+
class NodeInfoTable extends qreact.Component<{
|
|
387
|
+
rows: NodeRow[];
|
|
388
|
+
selectedMachine: string;
|
|
389
|
+
machineColumns: { id: string; label: string }[];
|
|
390
|
+
nodeMachineLatency: Map<string, { [machineId: string]: { text: string; tooltip: string } }>;
|
|
391
|
+
}> {
|
|
348
392
|
render() {
|
|
349
393
|
let rows = this.props.rows;
|
|
350
394
|
let selected = this.props.selectedMachine;
|
|
395
|
+
let machineColumns = this.props.machineColumns;
|
|
396
|
+
let nodeMachineLatency = this.props.nodeMachineLatency;
|
|
351
397
|
return <div className={css.vbox(0).fillWidth.overflowAuto.bord2(0, 0, 85)}>
|
|
352
398
|
<div className={css.hbox(0).hsl(0, 0, 96).colorhsl(0, 0, 20).boldStyle}>
|
|
353
399
|
{NODE_TABLE_COLUMNS.map(col =>
|
|
354
400
|
<div className={css.width(col.width).flexShrink0.pad2(6).ellipsis}>{col.label}</div>
|
|
355
401
|
)}
|
|
402
|
+
{machineColumns.map(col =>
|
|
403
|
+
<div className={css.width(MACHINE_LATENCY_WIDTH_PX).flexShrink0.pad2(6).ellipsis} title={`latency and path values to ${col.label}`}>→ {col.label}</div>
|
|
404
|
+
)}
|
|
356
405
|
</div>
|
|
357
406
|
{rows.map((row, i) => {
|
|
358
407
|
let firstOfMachine = i === 0 || rows[i - 1].machineId !== row.machineId;
|
|
359
408
|
let isSelected = row.machineId === selected;
|
|
409
|
+
let latencies = nodeMachineLatency.get(row.nodeId) || {};
|
|
360
410
|
return <div
|
|
361
411
|
className={css.hbox(0).button.fillWidth.hsl(0, 0, isSelected ? 92 : 99)
|
|
362
412
|
.borderTop(firstOfMachine ? "1px solid hsl(0, 0%, 80%)" : "1px solid hsl(0, 0%, 93%)")}
|
|
@@ -370,6 +420,13 @@ class NodeInfoTable extends qreact.Component<{ rows: NodeRow[]; selectedMachine:
|
|
|
370
420
|
title={text}
|
|
371
421
|
>{text}</div>;
|
|
372
422
|
})}
|
|
423
|
+
{machineColumns.map(col => {
|
|
424
|
+
let cell = latencies[col.id];
|
|
425
|
+
return <div
|
|
426
|
+
className={css.width(MACHINE_LATENCY_WIDTH_PX).flexShrink0.pad2(6).ellipsis.colorhsl(0, 0, 25)}
|
|
427
|
+
title={cell?.tooltip || ""}
|
|
428
|
+
>{cell?.text || ""}</div>;
|
|
429
|
+
})}
|
|
373
430
|
</div>;
|
|
374
431
|
})}
|
|
375
432
|
</div>;
|
|
@@ -453,19 +510,56 @@ class LatencyGraphSection extends qreact.Component<{ nodeIds: string[]; infos: N
|
|
|
453
510
|
if (unique.size) machineNetworks.set(machineId, [...unique]);
|
|
454
511
|
}
|
|
455
512
|
|
|
513
|
+
// One latency column per machine: each row (node) gets its own min · median · max latency to that machine's
|
|
514
|
+
// threads (a machine has several threads, so a cell is a range, not one number).
|
|
515
|
+
let machineColumns = [...machineAllThreads.keys()].map(id => {
|
|
516
|
+
let networks = machineNetworks.get(id) || [];
|
|
517
|
+
let label = networks.length ? `${id.slice(0, ID_CHARS)} (${networks.join(" | ")})` : id.slice(0, ID_CHARS);
|
|
518
|
+
return { id, label, threads: machineAllThreads.get(id) || [] };
|
|
519
|
+
});
|
|
520
|
+
sort(machineColumns, column => column.id);
|
|
521
|
+
let nodeMachineLatency = new Map<string, { [machineId: string]: { text: string; tooltip: string } }>();
|
|
522
|
+
for (let nodeId of nodeIds) {
|
|
523
|
+
let sourceLatencies = latencyMaps.get(nodeId);
|
|
524
|
+
let traffic = trafficMaps.get(nodeId);
|
|
525
|
+
let byMachine: { [machineId: string]: { text: string; tooltip: string } } = {};
|
|
526
|
+
for (let column of machineColumns) {
|
|
527
|
+
let cell = machineLatencyCell({ sourceLatencies, threads: column.threads, traffic });
|
|
528
|
+
if (cell.text) byMachine[column.id] = cell;
|
|
529
|
+
}
|
|
530
|
+
nodeMachineLatency.set(nodeId, byMachine);
|
|
531
|
+
}
|
|
532
|
+
|
|
456
533
|
// Total bytes on each thread-pair link (both directions). Both endpoints report the same total, so take the max.
|
|
457
534
|
let pairKey = (a: string, b: string) => a < b ? `${a}|${b}` : `${b}|${a}`;
|
|
458
535
|
let pairTraffic = new Map<string, number>();
|
|
536
|
+
// Path values per thread pair, split by direction (fwd = lower id → higher id). Both endpoints report each
|
|
537
|
+
// direction (one as sent, one as received), so take the max of the two estimates.
|
|
538
|
+
let pairPv = new Map<string, { fwd: number; back: number }>();
|
|
459
539
|
for (let [reporter, traffic] of trafficMaps) {
|
|
460
540
|
for (let [peer, data] of Object.entries(traffic.perNode || {})) {
|
|
461
541
|
if (reporter === peer || !respondedSet.has(peer)) continue;
|
|
462
542
|
let key = pairKey(reporter, peer);
|
|
463
543
|
pairTraffic.set(key, Math.max(pairTraffic.get(key) || 0, (data?.sent || 0) + (data?.received || 0)));
|
|
544
|
+
let pv = pairPv.get(key);
|
|
545
|
+
if (!pv) {
|
|
546
|
+
pv = { fwd: 0, back: 0 };
|
|
547
|
+
pairPv.set(key, pv);
|
|
548
|
+
}
|
|
549
|
+
let sentRate = data?.pathValuesSent || 0;
|
|
550
|
+
let receivedRate = data?.pathValuesReceived || 0;
|
|
551
|
+
if (reporter < peer) {
|
|
552
|
+
pv.fwd = Math.max(pv.fwd, sentRate);
|
|
553
|
+
pv.back = Math.max(pv.back, receivedRate);
|
|
554
|
+
} else {
|
|
555
|
+
pv.fwd = Math.max(pv.fwd, receivedRate);
|
|
556
|
+
pv.back = Math.max(pv.back, sentRate);
|
|
557
|
+
}
|
|
464
558
|
}
|
|
465
559
|
}
|
|
466
560
|
|
|
467
|
-
// Machine-to-machine latency =
|
|
468
|
-
let machinePairLatency = new Map<string,
|
|
561
|
+
// Machine-to-machine latency = minimum over all cross-machine thread-pair latencies; traffic = summed pair traffic.
|
|
562
|
+
let machinePairLatency = new Map<string, number>();
|
|
469
563
|
for (let [sourceId, latencies] of latencyMaps) {
|
|
470
564
|
let sourceMachine = machineIdOf(sourceId);
|
|
471
565
|
for (let [destId, latencyMs] of Object.entries(latencies)) {
|
|
@@ -473,13 +567,8 @@ class LatencyGraphSection extends qreact.Component<{ nodeIds: string[]; infos: N
|
|
|
473
567
|
let destMachine = machineIdOf(destId);
|
|
474
568
|
if (sourceMachine === destMachine) continue;
|
|
475
569
|
let key = pairKey(sourceMachine, destMachine);
|
|
476
|
-
let
|
|
477
|
-
|
|
478
|
-
agg = { sum: 0, count: 0 };
|
|
479
|
-
machinePairLatency.set(key, agg);
|
|
480
|
-
}
|
|
481
|
-
agg.sum += latencyMs;
|
|
482
|
-
agg.count++;
|
|
570
|
+
let existing = machinePairLatency.get(key);
|
|
571
|
+
machinePairLatency.set(key, existing === undefined ? latencyMs : Math.min(existing, latencyMs));
|
|
483
572
|
}
|
|
484
573
|
}
|
|
485
574
|
let machinePairTraffic = new Map<string, number>();
|
|
@@ -491,6 +580,28 @@ class LatencyGraphSection extends qreact.Component<{ nodeIds: string[]; infos: N
|
|
|
491
580
|
let mk = pairKey(ma, mb);
|
|
492
581
|
machinePairTraffic.set(mk, (machinePairTraffic.get(mk) || 0) + weight);
|
|
493
582
|
}
|
|
583
|
+
// Sum thread-pair path value flows up to machine pairs, keeping direction (fwd = lower machine id → higher).
|
|
584
|
+
let machinePairPv = new Map<string, { fwd: number; back: number }>();
|
|
585
|
+
for (let [key, pv] of pairPv) {
|
|
586
|
+
let [a, b] = key.split("|");
|
|
587
|
+
let ma = machineIdOf(a);
|
|
588
|
+
let mb = machineIdOf(b);
|
|
589
|
+
if (ma === mb) continue;
|
|
590
|
+
let mk = pairKey(ma, mb);
|
|
591
|
+
let entry = machinePairPv.get(mk);
|
|
592
|
+
if (!entry) {
|
|
593
|
+
entry = { fwd: 0, back: 0 };
|
|
594
|
+
machinePairPv.set(mk, entry);
|
|
595
|
+
}
|
|
596
|
+
// The thread pair's fwd direction may be flipped relative to the machine pair's ordering.
|
|
597
|
+
if (ma < mb) {
|
|
598
|
+
entry.fwd += pv.fwd;
|
|
599
|
+
entry.back += pv.back;
|
|
600
|
+
} else {
|
|
601
|
+
entry.fwd += pv.back;
|
|
602
|
+
entry.back += pv.fwd;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
494
605
|
|
|
495
606
|
let nodes: LatencyGraphNode[] = [...machineRespondedThreads.keys()].map(machineId => {
|
|
496
607
|
let allThreads = machineAllThreads.get(machineId) || [];
|
|
@@ -502,9 +613,14 @@ class LatencyGraphSection extends qreact.Component<{ nodeIds: string[]; infos: N
|
|
|
502
613
|
};
|
|
503
614
|
});
|
|
504
615
|
let links: LatencyGraphLink[] = [];
|
|
505
|
-
for (let [key,
|
|
616
|
+
for (let [key, latencyMs] of machinePairLatency) {
|
|
506
617
|
let [source, destination] = key.split("|");
|
|
507
|
-
|
|
618
|
+
let pv = machinePairPv.get(key);
|
|
619
|
+
let extraLabel: LatencyGraphLabelLine | undefined;
|
|
620
|
+
if (pv && (pv.fwd || pv.back)) {
|
|
621
|
+
extraLabel = { text: `↑${formatNumber(pv.fwd)}/s ↓${formatNumber(pv.back)}/s values`, color: PATHVALUE_COLOR };
|
|
622
|
+
}
|
|
623
|
+
links.push({ source, destination, latencyMs, weight: machinePairTraffic.get(key) || 0, extraLabel });
|
|
508
624
|
}
|
|
509
625
|
|
|
510
626
|
// One row per thread node, grouped so a machine's threads sit together; the selected machine sorts to the top.
|
|
@@ -539,7 +655,7 @@ class LatencyGraphSection extends qreact.Component<{ nodeIds: string[]; infos: N
|
|
|
539
655
|
</div>
|
|
540
656
|
<div className={css.colorhsl(0, 0, 50)}>Click a node to sort its information to the top of the table below.</div>
|
|
541
657
|
<h2 className={css.margin(0)}>Nodes ({rows.length})</h2>
|
|
542
|
-
<NodeInfoTable rows={rows} selectedMachine={selected} />
|
|
658
|
+
<NodeInfoTable rows={rows} selectedMachine={selected} machineColumns={machineColumns} nodeMachineLatency={nodeMachineLatency} />
|
|
543
659
|
</div>;
|
|
544
660
|
}
|
|
545
661
|
}
|
|
@@ -204,6 +204,7 @@ async function processPendingValidityChecks(now: number) {
|
|
|
204
204
|
let splitIndex = binarySearchBasic2(pendingValidityChecks, check => check.discoveredAt, { discoveredAt: threshold } as PendingValidityCheck);
|
|
205
205
|
if (splitIndex < 0) splitIndex = ~splitIndex;
|
|
206
206
|
|
|
207
|
+
let pathsToResolve = new Set<string>();
|
|
207
208
|
for (let i = 0; i < splitIndex; i++) {
|
|
208
209
|
let pendingCheck = pendingValidityChecks[i];
|
|
209
210
|
let currentValue = authorityStorage.getValueAtOrBeforeTime(pendingCheck.path);
|
|
@@ -215,15 +216,14 @@ async function processPendingValidityChecks(now: number) {
|
|
|
215
216
|
ourValid: pendingCheck.ourValid,
|
|
216
217
|
remoteValid: pendingCheck.remoteValid,
|
|
217
218
|
remoteNodeId: pendingCheck.remoteNodeId,
|
|
218
|
-
reason: "Pending validity check aged past MAX_CHANGE_AGE.
|
|
219
|
+
reason: "Pending validity check aged past MAX_CHANGE_AGE. Resolving the valid state to valid on all authorities.",
|
|
219
220
|
});
|
|
220
|
-
|
|
221
|
-
for (let [authorityNodeId, _] of allAuthorities.entries()) {
|
|
222
|
-
let serialized = await pathValueSerializer.serialize([currentValue]);
|
|
223
|
-
await PathAuditerController.nodes[authorityNodeId].ingestPathValues(serialized);
|
|
224
|
-
}
|
|
221
|
+
pathsToResolve.add(pendingCheck.path);
|
|
225
222
|
}
|
|
226
223
|
}
|
|
224
|
+
if (pathsToResolve.size > 0) {
|
|
225
|
+
await resolveValidStateDisagreements(pathsToResolve);
|
|
226
|
+
}
|
|
227
227
|
|
|
228
228
|
if (splitIndex > 0) {
|
|
229
229
|
for (let i = 0; i < splitIndex; i++) {
|
|
@@ -233,6 +233,73 @@ async function processPendingValidityChecks(now: number) {
|
|
|
233
233
|
}
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
async function resolveValidStateDisagreements(paths: Set<string>) {
|
|
237
|
+
let values: PathValue[] = [];
|
|
238
|
+
for (let path of paths) {
|
|
239
|
+
let value = authorityStorage.getValueAtOrBeforeTime(path);
|
|
240
|
+
if (value) {
|
|
241
|
+
values.push(value);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (values.length === 0) return;
|
|
245
|
+
|
|
246
|
+
console.info(`Resolving valid state disagreements`, { pathCount: values.length });
|
|
247
|
+
|
|
248
|
+
let otherAuthorities = PathRouter.getAllAuthoritiesForValues(values);
|
|
249
|
+
let anyValidByPath = new Map<string, boolean>();
|
|
250
|
+
for (let value of values) {
|
|
251
|
+
anyValidByPath.set(value.path, !!value.valid);
|
|
252
|
+
}
|
|
253
|
+
for (let [authorityNodeId, authorityValues] of otherAuthorities.entries()) {
|
|
254
|
+
let requests = authorityValues.map(value => ({ path: value.path, time: value.time }));
|
|
255
|
+
let responses = await PathAuditerController.nodes[authorityNodeId].getValidStates(requests);
|
|
256
|
+
for (let response of responses) {
|
|
257
|
+
if (response.valid) {
|
|
258
|
+
anyValidByPath.set(response.path, true);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
let forcedByPath = new Map<string, PathValue>();
|
|
264
|
+
for (let value of values) {
|
|
265
|
+
if (!anyValidByPath.get(value.path)) continue;
|
|
266
|
+
console.info(`Resolving valid state disagreement to valid`, {
|
|
267
|
+
path: value.path,
|
|
268
|
+
timeId: value.time.time,
|
|
269
|
+
timeIdFull: value.time,
|
|
270
|
+
ourValid: value.valid,
|
|
271
|
+
});
|
|
272
|
+
if (value.valid) {
|
|
273
|
+
forcedByPath.set(value.path, value);
|
|
274
|
+
} else {
|
|
275
|
+
forcedByPath.set(value.path, { ...value, valid: true });
|
|
276
|
+
}
|
|
277
|
+
stats.fixedCount++;
|
|
278
|
+
}
|
|
279
|
+
if (forcedByPath.size === 0) return;
|
|
280
|
+
|
|
281
|
+
for (let [authorityNodeId, authorityValues] of otherAuthorities.entries()) {
|
|
282
|
+
let toSend: PathValue[] = [];
|
|
283
|
+
for (let value of authorityValues) {
|
|
284
|
+
let forced = forcedByPath.get(value.path);
|
|
285
|
+
if (forced) {
|
|
286
|
+
toSend.push(forced);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (toSend.length === 0) continue;
|
|
290
|
+
let serialized = await pathValueSerializer.serialize(toSend);
|
|
291
|
+
await PathAuditerController.nodes[authorityNodeId].ingestPathValues(serialized, true);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
validStateComputer.ingestValuesAndValidStates({
|
|
295
|
+
pathValues: Array.from(forcedByPath.values()),
|
|
296
|
+
parentSyncs: [],
|
|
297
|
+
initialTriggers: { values: new Set(), parentPaths: new Set() },
|
|
298
|
+
doNotArchive: true,
|
|
299
|
+
forceValidStates: true,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
236
303
|
function trackSyncAge(config: {
|
|
237
304
|
path: string;
|
|
238
305
|
ourTimeId: number;
|
|
@@ -364,7 +431,7 @@ async function auditAuthority(nodeId: string, pathsToAudit: { path: string }[],
|
|
|
364
431
|
ourValid: ourExact.valid,
|
|
365
432
|
remoteValid: response.valid,
|
|
366
433
|
remoteNodeId: nodeId,
|
|
367
|
-
reason: "Remote says our value is invalid, but we think it's valid.
|
|
434
|
+
reason: "Remote says our value is invalid, but we think it's valid. Resolving the valid state to valid on all authorities.",
|
|
368
435
|
});
|
|
369
436
|
} else {
|
|
370
437
|
if (!pendingValidityCheckPaths.has(request.path)) {
|
|
@@ -439,16 +506,7 @@ async function auditAuthority(nodeId: string, pathsToAudit: { path: string }[],
|
|
|
439
506
|
}
|
|
440
507
|
|
|
441
508
|
if (pathsToForceSync.size > 0) {
|
|
442
|
-
|
|
443
|
-
let valueToForce = authorityStorage.getValueAtOrBeforeTime(path);
|
|
444
|
-
if (valueToForce) {
|
|
445
|
-
let allAuthorities = PathRouter.getAllAuthoritiesForValues([valueToForce]);
|
|
446
|
-
for (let [authorityNodeId, _] of allAuthorities.entries()) {
|
|
447
|
-
let serialized = await pathValueSerializer.serialize([valueToForce]);
|
|
448
|
-
await PathAuditerController.nodes[authorityNodeId].ingestPathValues(serialized);
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
}
|
|
509
|
+
await resolveValidStateDisagreements(pathsToForceSync);
|
|
452
510
|
}
|
|
453
511
|
|
|
454
512
|
stats.totalAudited += pathsToAudit.length;
|
|
@@ -505,13 +563,14 @@ class PathAuditerService {
|
|
|
505
563
|
return await pathValueSerializer.serialize(results);
|
|
506
564
|
}
|
|
507
565
|
|
|
508
|
-
public async ingestPathValues(serializedValues: Buffer[]): Promise<void> {
|
|
566
|
+
public async ingestPathValues(serializedValues: Buffer[], forceValidStates?: boolean): Promise<void> {
|
|
509
567
|
let values = await pathValueSerializer.deserialize(serializedValues);
|
|
510
568
|
validStateComputer.ingestValuesAndValidStates({
|
|
511
569
|
pathValues: values,
|
|
512
570
|
parentSyncs: [],
|
|
513
571
|
initialTriggers: { values: new Set(), parentPaths: new Set() },
|
|
514
572
|
doNotArchive: true,
|
|
573
|
+
forceValidStates,
|
|
515
574
|
});
|
|
516
575
|
}
|
|
517
576
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { qreact } from "../4-dom/qreact";
|
|
2
2
|
import { Button } from "./Button";
|
|
3
3
|
import { URLParam } from "./URLParam";
|
|
4
|
+
import { mainResets } from "./urlResetGroups";
|
|
4
5
|
import { InputLabelURL } from "./InputLabel";
|
|
5
6
|
import { css } from "typesafecss";
|
|
6
7
|
import { sort } from "socket-function/src/misc";
|
|
@@ -8,7 +9,7 @@ import { formatTime } from "socket-function/src/formatting/format";
|
|
|
8
9
|
|
|
9
10
|
export type LatencyGraphLabelLine = { text: string; color?: string; };
|
|
10
11
|
export type LatencyGraphNode = { id: string; label?: string; latitude?: number; longitude?: number; labelLines?: LatencyGraphLabelLine[]; weight?: number; };
|
|
11
|
-
export type LatencyGraphLink = { source: string; destination: string; latencyMs: number; weight?: number; };
|
|
12
|
+
export type LatencyGraphLink = { source: string; destination: string; latencyMs: number; weight?: number; extraLabel?: LatencyGraphLabelLine; };
|
|
12
13
|
export type LatencyGraphProps = {
|
|
13
14
|
nodes: LatencyGraphNode[];
|
|
14
15
|
links: LatencyGraphLink[];
|
|
@@ -22,15 +23,15 @@ export type LatencyGraphProps = {
|
|
|
22
23
|
|
|
23
24
|
const DEFAULT_CONNECTIONS = 4;
|
|
24
25
|
// Nearest neighbors drawn per node (the layout still uses every measured latency; this only thins the drawn lines).
|
|
25
|
-
const connectionsParam = new URLParam("lgConnections", DEFAULT_CONNECTIONS);
|
|
26
|
+
const connectionsParam = new URLParam("lgConnections", DEFAULT_CONNECTIONS, { reset: [mainResets] });
|
|
26
27
|
const DEFAULT_LATENCY_EXPONENT = 0.5;
|
|
27
28
|
// Exponent on (latency / reference) when mapping to layout distance, so far nodes push apart harder as it grows.
|
|
28
|
-
const latencyExponentParam = new URLParam("lgLatencyExponent", DEFAULT_LATENCY_EXPONENT);
|
|
29
|
-
const geoParam = new URLParam("lgGeo", false);
|
|
29
|
+
const latencyExponentParam = new URLParam("lgLatencyExponent", DEFAULT_LATENCY_EXPONENT, { reset: [mainResets] });
|
|
30
|
+
const geoParam = new URLParam("lgGeo", false, { reset: [mainResets] });
|
|
30
31
|
// On by default: each drawn connection shows its latency and (when a weight formatter is given) its traffic.
|
|
31
|
-
const showLatenciesParam = new URLParam("lgShowLatencies", true);
|
|
32
|
+
const showLatenciesParam = new URLParam("lgShowLatencies", true, { reset: [mainResets] });
|
|
32
33
|
// The config panel is collapsed by default so it doesn't eat the canvas; clicking the header expands it.
|
|
33
|
-
const configOpenParam = new URLParam("lgConfigOpen", false);
|
|
34
|
+
const configOpenParam = new URLParam("lgConfigOpen", false, { reset: [mainResets] });
|
|
34
35
|
// Pixels per degree of latitude/longitude in geographic mode (equirectangular projection).
|
|
35
36
|
const GEO_SCALE = 6;
|
|
36
37
|
|
|
@@ -62,7 +63,6 @@ const LINE_MAX_WIDTH = 7;
|
|
|
62
63
|
const LABEL_LINE_HEIGHT = 13;
|
|
63
64
|
const ZOOM_STEP = 1.1;
|
|
64
65
|
const MIN_SCALE = 0.05;
|
|
65
|
-
const MAX_SCALE = 12;
|
|
66
66
|
// Screen-space margins left around the content when auto-fitting. The top gets much more room because node labels
|
|
67
67
|
// stack upward above the nodes (and are drawn at a fixed screen size regardless of zoom).
|
|
68
68
|
const FIT_PAD = 60;
|
|
@@ -84,6 +84,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
84
84
|
maxNodeWeight = 0;
|
|
85
85
|
pairWeight = new Map<number, number>();
|
|
86
86
|
maxPairWeight = 0;
|
|
87
|
+
pairExtraLabel = new Map<number, LatencyGraphLabelLine>();
|
|
87
88
|
// Cached from props in render() so draw() (a rAF callback, not synced) never reads this.props.
|
|
88
89
|
formatWeight: ((weight: number) => string) | undefined = undefined;
|
|
89
90
|
selectedId: string | undefined = undefined;
|
|
@@ -135,6 +136,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
135
136
|
this.positionsY = new Float64Array(n);
|
|
136
137
|
this.ingestLinks();
|
|
137
138
|
this.computeWeights();
|
|
139
|
+
this.computePairLabels();
|
|
138
140
|
this.computeSolveEdges();
|
|
139
141
|
this.computeRenderEdges();
|
|
140
142
|
this.applyLayout();
|
|
@@ -166,6 +168,19 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
166
168
|
}
|
|
167
169
|
}
|
|
168
170
|
|
|
171
|
+
computePairLabels() {
|
|
172
|
+
let n = this.nodes.length;
|
|
173
|
+
let index = this.nodeIndex();
|
|
174
|
+
this.pairExtraLabel = new Map();
|
|
175
|
+
for (let link of this.props.links) {
|
|
176
|
+
if (!link.extraLabel) continue;
|
|
177
|
+
let a = index.get(link.source);
|
|
178
|
+
let b = index.get(link.destination);
|
|
179
|
+
if (a === undefined || b === undefined || a === b) continue;
|
|
180
|
+
this.pairExtraLabel.set(Math.min(a, b) * n + Math.max(a, b), link.extraLabel);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
169
184
|
nodeIndex() {
|
|
170
185
|
let index = new Map<string, number>();
|
|
171
186
|
for (let [i, node] of this.nodes.entries()) {
|
|
@@ -519,7 +534,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
519
534
|
let fit = Math.min((width - 2 * FIT_PAD) / worldW, (height - FIT_PAD_TOP - FIT_PAD) / worldH) * FIT_ZOOM;
|
|
520
535
|
// Only apply the fit if it's sane — never let a NaN/degenerate value nuke the whole view.
|
|
521
536
|
if (Number.isFinite(fit) && fit > 0 && Number.isFinite(minX) && Number.isFinite(minY)) {
|
|
522
|
-
this.viewScale = Math.max(MIN_SCALE,
|
|
537
|
+
this.viewScale = Math.max(MIN_SCALE, fit);
|
|
523
538
|
this.panX = -((minX + maxX) / 2 - centroidX) * this.viewScale;
|
|
524
539
|
// Center within the padded band, which sits lower than the middle because of the reserved top margin.
|
|
525
540
|
let targetCenterY = (FIT_PAD_TOP + (height - FIT_PAD)) / 2;
|
|
@@ -558,9 +573,14 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
558
573
|
let midX = (toScreenX(this.positionsX[edge.a]) + toScreenX(this.positionsX[edge.b])) / 2;
|
|
559
574
|
let midY = (toScreenY(this.positionsY[edge.a]) + toScreenY(this.positionsY[edge.b])) / 2;
|
|
560
575
|
|
|
561
|
-
let
|
|
562
|
-
let
|
|
563
|
-
let
|
|
576
|
+
let pk = Math.min(edge.a, edge.b) * this.nodes.length + Math.max(edge.a, edge.b);
|
|
577
|
+
let traffic = this.pairWeight.get(pk) || 0;
|
|
578
|
+
let subLabels: LatencyGraphLabelLine[] = [];
|
|
579
|
+
let trafficLabel = this.formatWeight && traffic > 0 && this.formatWeight(traffic) || undefined;
|
|
580
|
+
if (trafficLabel) subLabels.push({ text: trafficLabel });
|
|
581
|
+
let extraLabel = this.pairExtraLabel.get(pk);
|
|
582
|
+
if (extraLabel) subLabels.push(extraLabel);
|
|
583
|
+
let latencyY = subLabels.length ? midY - 7 : midY;
|
|
564
584
|
|
|
565
585
|
ctx.font = "10px sans-serif";
|
|
566
586
|
let latencyLabel = formatTime(edge.latency);
|
|
@@ -570,13 +590,14 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
570
590
|
ctx.fillStyle = "hsl(0, 0%, 72%)";
|
|
571
591
|
ctx.fillText(latencyLabel, midX, latencyY);
|
|
572
592
|
|
|
573
|
-
|
|
593
|
+
for (let [si, subLabel] of subLabels.entries()) {
|
|
594
|
+
let sy = midY + 7 + si * 12;
|
|
574
595
|
ctx.font = "9px sans-serif";
|
|
575
|
-
let
|
|
596
|
+
let subWidth = ctx.measureText(subLabel.text).width;
|
|
576
597
|
ctx.fillStyle = "hsla(0, 0%, 0%, 0.5)";
|
|
577
|
-
ctx.fillRect(midX -
|
|
578
|
-
ctx.fillStyle = "hsl(0, 0%, 58%)";
|
|
579
|
-
ctx.fillText(
|
|
598
|
+
ctx.fillRect(midX - subWidth / 2 - 2, sy - 6, subWidth + 4, 12);
|
|
599
|
+
ctx.fillStyle = subLabel.color || "hsl(0, 0%, 58%)";
|
|
600
|
+
ctx.fillText(subLabel.text, midX, sy);
|
|
580
601
|
}
|
|
581
602
|
}
|
|
582
603
|
}
|
|
@@ -698,7 +719,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
698
719
|
let worldX = this.centroidX + (e.offsetX - this.viewWidth / 2 - this.panX) / this.viewScale;
|
|
699
720
|
let worldY = this.centroidY + (e.offsetY - this.viewHeight / 2 - this.panY) / this.viewScale;
|
|
700
721
|
let factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
|
|
701
|
-
let newScale = Math.max(MIN_SCALE,
|
|
722
|
+
let newScale = Math.max(MIN_SCALE, this.viewScale * factor);
|
|
702
723
|
this.viewScale = newScale;
|
|
703
724
|
this.panX = e.offsetX - this.viewWidth / 2 - (worldX - this.centroidX) * newScale;
|
|
704
725
|
this.panY = e.offsetY - this.viewHeight / 2 - (worldY - this.centroidY) * newScale;
|
|
@@ -748,8 +769,10 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
748
769
|
let my = e.clientY - rect.top;
|
|
749
770
|
this.mouseX = mx;
|
|
750
771
|
this.mouseY = my;
|
|
751
|
-
//
|
|
752
|
-
|
|
772
|
+
// The move listener is on window (so panning keeps working off-canvas), so only hover while actually over the
|
|
773
|
+
// canvas — otherwise the hover would stick forever once the cursor leaves.
|
|
774
|
+
let inside = 0 <= mx && mx < rect.width && 0 <= my && my < rect.height;
|
|
775
|
+
this.hoverNode = inside ? this.nearestNodeTo(mx, my) : undefined;
|
|
753
776
|
this.scheduleFrame();
|
|
754
777
|
};
|
|
755
778
|
|
|
@@ -825,6 +848,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
|
|
|
825
848
|
// Same node/link counts (same order): refresh the node objects so label text that changed as data streamed
|
|
826
849
|
// in — or when the traffic window toggled — shows up, without disturbing the settled layout/positions.
|
|
827
850
|
this.nodes = this.props.nodes.slice();
|
|
851
|
+
this.computePairLabels();
|
|
828
852
|
if (weightSig !== this.builtWeightSig) {
|
|
829
853
|
this.builtWeightSig = weightSig;
|
|
830
854
|
this.computeWeights();
|