querysub 0.523.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.
@@ -46,6 +46,32 @@ export class UpdateButtons extends qreact.Component<{
46
46
  state = t.state({
47
47
  isDeploying: t.type(false),
48
48
  });
49
+ // Deploys all outdated services to latestRef. Scheduled releases use the configured overlap so the new instances are running before the old ones shut down; immediate skips the release entirely (old instances restart right away, no overlap).
50
+ private deployAll(outdatedServices: ServiceConfig[], latestRef: string, immediate: boolean) {
51
+ this.state.isDeploying = true;
52
+ // Props are synchronized state, so everything the async work needs is cloned into plain locals HERE, in the synced part
53
+ let toDeploy = outdatedServices.map(service => {
54
+ let updated = deepCloneJSON(service);
55
+ updated.parameters.gitRef = latestRef;
56
+ if (immediate) {
57
+ updated.parameters.releaseTime = undefined;
58
+ } else {
59
+ let overlap = updated.parameters.overlapTime || DEFAULT_OVERLAP_TIME;
60
+ updated.parameters.releaseTime = Date.now() + overlap;
61
+ }
62
+ return updated;
63
+ });
64
+ let controller = MachineServiceController(SocketFunction.browserNodeId());
65
+ Querysub.onCommitFinished(async () => {
66
+ try {
67
+ await controller.setServiceConfigs.promise(toDeploy);
68
+ } finally {
69
+ Querysub.commit(() => {
70
+ this.state.isDeploying = false;
71
+ });
72
+ }
73
+ });
74
+ }
49
75
  render() {
50
76
  let controller = MachineServiceController(SocketFunction.browserNodeId());
51
77
  const gitInfo = controller.getGitInfo();
@@ -98,30 +124,12 @@ export class UpdateButtons extends qreact.Component<{
98
124
  {bigEmoji("⬆️")} <span>Commit & Push ({gitInfo?.uncommitted.length} Files Changed)</span>
99
125
  </button>}
100
126
 
101
- {outdatedServices.length > 0 && gitInfo &&
127
+ {outdatedServices.length > 0 && gitInfo && <>
102
128
  <button
103
129
  className={buttonStyle.hsl(120, 70, 90)}
104
130
  disabled={this.state.isDeploying}
105
131
  onClick={() => {
106
- this.state.isDeploying = true;
107
- // Props are synchronized state, so everything the async work needs is cloned into plain locals HERE, in the synced part
108
- let toDeploy = outdatedServices.map(service => {
109
- let updated = deepCloneJSON(service);
110
- updated.parameters.gitRef = gitInfo.latestRef;
111
- // Release with the configured overlap, so the new instances are running before the old ones shut down
112
- let overlap = updated.parameters.overlapTime || DEFAULT_OVERLAP_TIME;
113
- updated.parameters.releaseTime = Date.now() + overlap;
114
- return updated;
115
- });
116
- Querysub.onCommitFinished(async () => {
117
- try {
118
- await controller.setServiceConfigs.promise(toDeploy);
119
- } finally {
120
- Querysub.commit(() => {
121
- this.state.isDeploying = false;
122
- });
123
- }
124
- });
132
+ this.deployAll(outdatedServices, gitInfo.latestRef, false);
125
133
  }}
126
134
  >
127
135
  <div>
@@ -136,7 +144,19 @@ export class UpdateButtons extends qreact.Component<{
136
144
  <RenderGitRefInfo gitRef={ref} />
137
145
  </div>)}
138
146
  </button>
139
- }
147
+ <button
148
+ className={buttonStyle.hsl(0, 70, 90)}
149
+ disabled={this.state.isDeploying}
150
+ title="Deploys immediately, with no overlap: the old instances are shut down right away"
151
+ onClick={() => {
152
+ this.deployAll(outdatedServices, gitInfo.latestRef, true);
153
+ }}
154
+ >
155
+ <div>
156
+ {bigEmoji("⚡")} <span>{this.state.isDeploying && "⏳ Deploying..." || `Deploy All (${outdatedServices.length}) Now (no delay)`}</span>
157
+ </div>
158
+ </button>
159
+ </>}
140
160
  </>;
141
161
  }
142
162
  }
@@ -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(`fn ${shard.shardRange.startFraction.toFixed(3)}-${shard.shardRange.endFraction.toFixed(3)}`);
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(`pv ${spec.routeStart.toFixed(3)}-${spec.routeEnd.toFixed(3)}`);
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<{ rows: NodeRow[]; selectedMachine: string }> {
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 = mean over all cross-machine thread-pair latencies; traffic = summed pair traffic.
468
- let machinePairLatency = new Map<string, { sum: number; count: number; }>();
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 agg = machinePairLatency.get(key);
477
- if (!agg) {
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, agg] of machinePairLatency) {
616
+ for (let [key, latencyMs] of machinePairLatency) {
506
617
  let [source, destination] = key.split("|");
507
- links.push({ source, destination, latencyMs: agg.sum / agg.count, weight: machinePairTraffic.get(key) || 0 });
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. Forcing sync to all nodes.",
219
+ reason: "Pending validity check aged past MAX_CHANGE_AGE. Resolving the valid state to valid on all authorities.",
219
220
  });
220
- let allAuthorities = PathRouter.getAllAuthoritiesForValues([currentValue]);
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. Telling all nodes about this value to ensure it's in sync everywhere.",
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
- for (let path of pathsToForceSync) {
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, Math.min(MAX_SCALE, fit));
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 traffic = this.pairWeight.get(Math.min(edge.a, edge.b) * this.nodes.length + Math.max(edge.a, edge.b)) || 0;
562
- let trafficLabel = this.formatWeight && traffic > 0 ? this.formatWeight(traffic) : undefined;
563
- let latencyY = trafficLabel ? midY - 7 : midY;
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
- if (trafficLabel) {
593
+ for (let [si, subLabel] of subLabels.entries()) {
594
+ let sy = midY + 7 + si * 12;
574
595
  ctx.font = "9px sans-serif";
575
- let trafficWidth = ctx.measureText(trafficLabel).width;
596
+ let subWidth = ctx.measureText(subLabel.text).width;
576
597
  ctx.fillStyle = "hsla(0, 0%, 0%, 0.5)";
577
- ctx.fillRect(midX - trafficWidth / 2 - 2, midY + 7 - 6, trafficWidth + 4, 12);
578
- ctx.fillStyle = "hsl(0, 0%, 58%)";
579
- ctx.fillText(trafficLabel, midX, midY + 7);
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, Math.min(MAX_SCALE, this.viewScale * factor));
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
- // Always hover whichever node the cursor is closest to, so moving anywhere explores the graph.
752
- this.hoverNode = this.nearestNodeTo(mx, my);
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();