querysub 0.514.0 → 0.516.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.514.0",
3
+ "version": "0.516.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",
@@ -423,7 +423,7 @@ async function fastMemorySync() {
423
423
  let checkNodes = shuffle(Array.from(aliveNodes), Date.now()).slice(0, API_AUDIT_COUNT);
424
424
  let perPeerNodes = await Promise.all(
425
425
  checkNodes.map(async peerNodeId => {
426
- let nodes = await timeoutToUndefinedSilent(200, NodeDiscoveryController.nodes[peerNodeId].getAllNodeIds());
426
+ let nodes = await timeoutToUndefinedSilent(2000, NodeDiscoveryController.nodes[peerNodeId].getAllNodeIds());
427
427
  if (!nodes) {
428
428
  deadNodes.set(peerNodeId, Date.now());
429
429
  }
@@ -350,6 +350,12 @@ export class PathFunctionRunner {
350
350
  public static DEBUG_WATCHES_THRESHOLD = 5;
351
351
  public static MAX_WATCH_LOOPS = 1000;
352
352
 
353
+ public static hostControllers = lazy(() => {
354
+ SocketFunction.expose(FunctionPreloadController);
355
+ SocketFunction.expose(FunctionCaptureController);
356
+ SocketFunction.expose(FunctionRunnerInfoController);
357
+ });
358
+
353
359
  constructor(private config: {
354
360
  domainName: string;
355
361
  shardRange: { startFraction: number, endFraction: number };
@@ -358,9 +364,7 @@ export class PathFunctionRunner {
358
364
  // The networks we listen to calls on. Unset is equivalent to ["default"].
359
365
  networks?: string[];
360
366
  }) {
361
- SocketFunction.expose(FunctionPreloadController);
362
- SocketFunction.expose(FunctionCaptureController);
363
- SocketFunction.expose(FunctionRunnerInfoController);
367
+ PathFunctionRunner.hostControllers();
364
368
  debugFunctionRunnerShards.push({
365
369
  domainName: config.domainName,
366
370
  shardRange: config.shardRange,
@@ -35,6 +35,7 @@ async function main() {
35
35
  PathFunctionRunner.DEBUG_CALLS = true;
36
36
  // debugCoreMode();
37
37
 
38
+ PathFunctionRunner.hostControllers();
38
39
  await Querysub.hostService("PathFunctionRunnerMain");
39
40
 
40
41
  // Use a fairly high stick time (the default is 10s), because having wait to sync data is very slow,
@@ -354,7 +354,161 @@ async function edgeNodeFunction(config: {
354
354
  return true;
355
355
  });
356
356
 
357
- // If they are using an IP domain, that's the only node we accept (we still need to pick the node
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
+ async function probeAndPick(probeNodes: EdgeNodeConfig[], pickableHosts: Set<string>): Promise<EdgeNodeConfig | undefined> {
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 = 1000;
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
+ const PROBE_BATCH_SIZE = 5;
363
+
364
+ let stats = probeNodes.map(node => ({
365
+ host: node.host,
366
+ nodeId: node.nodeId,
367
+ public: node.public,
368
+ live: node.gitHash === edgeIndex.liveHash,
369
+ latency: undefined as number | undefined,
370
+ alive: undefined as boolean | undefined,
371
+ finished: false,
372
+ }));
373
+ let statsObj = { nodes: stats, pickedHost: "", autoPickedHost: "" };
374
+ globalThis.EDGE_NODE_STATS = statsObj;
375
+
376
+ let responded: { node: EdgeNodeConfig; latency: number }[] = [];
377
+ let queue = probeNodes.slice();
378
+ let outstanding = 0;
379
+ let timerStarted = false;
380
+ let decideResolve = () => { };
381
+ let decidePromise = new Promise<void>(resolve => { decideResolve = resolve; });
382
+
383
+ function launchProbes() {
384
+ while (outstanding < PROBE_BATCH_SIZE && queue.length > 0) {
385
+ let node = queue.shift();
386
+ if (!node) break;
387
+ void launchProbe(node);
388
+ }
389
+ }
390
+ async function launchProbe(node: EdgeNodeConfig) {
391
+ outstanding++;
392
+ let start = Date.now();
393
+ let alive = false;
394
+ try {
395
+ alive = await isNodeAlive(node);
396
+ } catch {
397
+ alive = false;
398
+ }
399
+ outstanding--;
400
+ let latency = Date.now() - start;
401
+ let stat = stats.find(x => x.host === node.host);
402
+ if (stat) {
403
+ stat.finished = true;
404
+ stat.alive = alive;
405
+ stat.latency = latency;
406
+ }
407
+ if (alive && pickableHosts.has(node.host)) {
408
+ responded.push({ node, latency });
409
+ if (!timerStarted) {
410
+ timerStarted = true;
411
+ // Once the first node responds we decide shortly after (anything slower than the window would never be picked anyway). The probing keeps going in the background though, purely for the stats.
412
+ setTimeout(decideResolve, LATENCY_WEIGHT_WINDOW);
413
+ }
414
+ }
415
+ if (outstanding === 0 && queue.length === 0) {
416
+ decideResolve();
417
+ }
418
+ launchProbes();
419
+ }
420
+ launchProbes();
421
+ if (outstanding === 0) return undefined;
422
+ await decidePromise;
423
+
424
+ if (responded.length === 0) return undefined;
425
+ let bestLatency = Math.min(...responded.map(x => x.latency));
426
+ let weighted = responded.map(x => ({ node: x.node, weight: Math.max(0, 1 - (x.latency - bestLatency) / LATENCY_WEIGHT_WINDOW) }));
427
+ let totalWeight = weighted.reduce((sum, x) => sum + x.weight, 0);
428
+ let remaining = Math.random() * totalWeight;
429
+ let picked = responded[0].node;
430
+ for (let obj of weighted) {
431
+ remaining -= obj.weight;
432
+ if (remaining <= 0) {
433
+ picked = obj.node;
434
+ break;
435
+ }
436
+ }
437
+ statsObj.autoPickedHost = picked.host;
438
+ return picked;
439
+ }
440
+
441
+ // What the automatic pick chooses from: public nodes on the live hash (falling back to the newest hash), best candidates first. Never a private node by default (they can still be picked explicitly, via the url override / the edge node dropdown).
442
+ let autoCandidates = edgeNodes.filter(x => x.public);
443
+ {
444
+ let liveNodes = autoCandidates.filter(x => x.gitHash === edgeIndex.liveHash);
445
+ if (liveNodes.length === 0 && !liveHashForced && autoCandidates.length > 0) {
446
+ let latestHash = autoCandidates[0].gitHash;
447
+ console.warn(`Could not find any live nodes (${edgeIndex.liveHash}), falling back to latest hash: ${latestHash}`);
448
+ liveNodes = autoCandidates.filter(x => x.gitHash === latestHash);
449
+ }
450
+ autoCandidates = liveNodes;
451
+ }
452
+ autoCandidates = shuffle(autoCandidates);
453
+ // Deprioritize (but don't exclude, in case there is nothing else) nodes that are scheduled to shut down, and nodes that just booted (they might still be warming up, and if they crash on startup we would follow them down). This only orders the probing (the best candidates go in the first batch), the actual pick is weighted by latency.
454
+ const EDGE_MIN_UP_TIME = 60_000;
455
+ const edgePickPenalty = (node: EdgeNodeConfig) => {
456
+ let penalty = 0;
457
+ if (node.scheduledShutdownTime) penalty += 2;
458
+ if (Date.now() - node.bootTime < EDGE_MIN_UP_TIME) penalty += 1;
459
+ return penalty;
460
+ };
461
+ autoCandidates.sort((a, b) => edgePickPenalty(a) - edgePickPenalty(b));
462
+
463
+ // Probe the pickable candidates first (the first batch sets the decision window), then everything else purely for the dropdown's stats
464
+ let pickableHosts = new Set(autoCandidates.map(x => x.host));
465
+ let probeOrder = [...autoCandidates, ...edgeNodes.filter(x => !pickableHosts.has(x.host))];
466
+ // Started once, even when the pick is overridden (so the dropdown still gets latencies AND what we would have picked automatically)
467
+ let probePromise: Promise<EdgeNodeConfig | undefined> | undefined;
468
+ function ensureProbeStarted() {
469
+ if (!probePromise) {
470
+ probePromise = probeAndPick(probeOrder, pickableHosts);
471
+ }
472
+ return probePromise;
473
+ }
474
+
475
+ // The url override wins over everything (including the IP domain default). If the host isn't in the list, it is ignored.
476
+ {
477
+ let overrideHost = new URL(document.location.href).searchParams.get("edgenode");
478
+ if (overrideHost) {
479
+ let overrideNode = edgeNodes.find(x => x.host === overrideHost);
480
+ if (overrideNode) {
481
+ // We wait longer than the regular decision window for a forcefully selected node, as we really want to obey the override. If it still doesn't respond, we fall back to the automatic pick (which the dropdown shows a warning about).
482
+ const FORCED_PROBE_TIMEOUT = 5000;
483
+ console.log(`MATCH url edgenode override: ${getNodeDebugString(overrideNode)}`);
484
+ void ensureProbeStarted();
485
+ let statsObj = globalThis.EDGE_NODE_STATS;
486
+ if (statsObj) {
487
+ statsObj.requestedHost = overrideNode.host;
488
+ }
489
+ let alive = false;
490
+ try {
491
+ alive = await Promise.race([
492
+ isNodeAlive(overrideNode),
493
+ new Promise<boolean>(resolve => setTimeout(() => resolve(false), FORCED_PROBE_TIMEOUT)),
494
+ ]);
495
+ } catch {
496
+ alive = false;
497
+ }
498
+ if (alive) {
499
+ if (statsObj) {
500
+ statsObj.pickedHost = overrideNode.host;
501
+ }
502
+ return overrideNode;
503
+ }
504
+ console.warn(`The url edgenode override ${overrideNode.host} did not respond within ${FORCED_PROBE_TIMEOUT}ms, falling back to the automatic pick`);
505
+ } else {
506
+ console.warn(`The url edgenode override ${JSON.stringify(overrideHost)} is not in the edge node list, ignoring it`);
507
+ }
508
+ }
509
+ }
510
+
511
+ // If they are using an IP domain, that's the default node (we still need to pick the node
358
512
  // though, as we need to know the entryPaths to import)
359
513
  {
360
514
  let curHost = document.location.host;
@@ -365,6 +519,10 @@ async function edgeNodeFunction(config: {
365
519
  let exactNode = edgeNodes.find(x => x.host === curHost);
366
520
  if (exactNode) {
367
521
  console.log(`MATCH exact host: ${getNodeDebugString(exactNode)}`);
522
+ void ensureProbeStarted();
523
+ if (globalThis.EDGE_NODE_STATS) {
524
+ globalThis.EDGE_NODE_STATS.pickedHost = exactNode.host;
525
+ }
368
526
  return exactNode;
369
527
  }
370
528
 
@@ -374,69 +532,21 @@ async function edgeNodeFunction(config: {
374
532
  }
375
533
  }
376
534
 
377
- // I guess... only allow private nodes, if they specify the exact host (which would match above).
378
- edgeNodes = edgeNodes.filter(x => x.public);
379
- if (edgeNodes.length === 0) {
535
+ if (edgeNodes.filter(x => x.public).length === 0) {
380
536
  throw new Error(`No public nodes found`);
381
537
  }
382
-
383
- let liveNodes = edgeNodes.filter(x => x.gitHash === edgeIndex.liveHash);
384
- if (liveNodes.length === 0) {
385
- if (liveHashForced) {
386
- throw new Error(`Could not find any live nodes (${edgeIndex.liveHash}), and the live hash is forced.`);
387
- }
388
- let latestHash = edgeNodes[0].gitHash;
389
- console.warn(`Could not find any live nodes (${edgeIndex.liveHash}), falling back to latest hash: ${latestHash}`);
390
- liveNodes = edgeNodes.filter(x => x.gitHash === latestHash);
538
+ if (autoCandidates.length === 0) {
539
+ throw new Error(`Could not find any live nodes (${edgeIndex.liveHash}), and the live hash is forced.`);
391
540
  }
392
- edgeNodes = liveNodes;
393
-
394
- // TODO: Instead of randomly shuffling, use the node's current load when picking a node.
395
- // All our future traffic (such as syncing) goes through the edge node we use, so it preferrable
396
- // to pick a node with low utilization.
397
- edgeNodes = shuffle(edgeNodes);
398
-
399
- // Deprioritize (but don't exclude, in case there is nothing else) nodes that are scheduled to shut down, and nodes that just booted (they might still be warming up, and if they crash on startup we would follow them down).
400
- const EDGE_MIN_UP_TIME = 60_000;
401
- const edgePickPenalty = (node: EdgeNodeConfig) => {
402
- let penalty = 0;
403
- if (node.scheduledShutdownTime) penalty += 2;
404
- if (Date.now() - node.bootTime < EDGE_MIN_UP_TIME) penalty += 1;
405
- return penalty;
406
- };
407
- edgeNodes.sort((a, b) => edgePickPenalty(a) - edgePickPenalty(b));
408
-
409
- // We check multiple at a time, so a node being unresponsive doesn't cause lag
410
- let PARALLEL_FACTOR = 3;
411
- for (let i = 0; i < edgeNodes.length; i += PARALLEL_FACTOR) {
412
- let node = await new Promise<EdgeNodeConfig | undefined>(resolve => {
413
- let finished = 0;
414
- for (let j = 0; j < PARALLEL_FACTOR; j++) {
415
- ((async () => {
416
- try {
417
- let node = edgeNodes[i + j];
418
- if (!node) return;
419
- let alive = await isNodeAlive(node);
420
- if (alive) {
421
- resolve(node);
422
- }
423
- }
424
- catch { }
425
- finally {
426
- finished++;
427
- if (finished >= PARALLEL_FACTOR) {
428
- resolve(undefined);
429
- }
430
- }
431
- return undefined;
432
- })()).catch(() => { });
433
- }
434
- });
435
- if (node) {
436
- return node;
437
- }
541
+ let picked = await ensureProbeStarted();
542
+ if (!picked) {
543
+ throw new Error(`No alive nodes found`);
544
+ }
545
+ if (globalThis.EDGE_NODE_STATS) {
546
+ globalThis.EDGE_NODE_STATS.pickedHost = picked.host;
438
547
  }
439
- throw new Error(`No alive nodes found`);
548
+ console.log(`PICKED edge node by latency: ${getNodeDebugString(picked)}`);
549
+ return picked;
440
550
  }
441
551
 
442
552
  function shuffle<T>(array: T[]): T[] {
@@ -79,6 +79,30 @@ export type EdgeNodeConfig = {
79
79
  // The function runner index at registration time, so clients have it immediately at startup (they refresh it by polling QuerysubController.getFunctionRunnerIndex).
80
80
  functionRunnerIndex?: FunctionRunnerIndex;
81
81
  };
82
+
83
+ export type EdgeNodeStat = {
84
+ host: string;
85
+ nodeId: string;
86
+ public: boolean;
87
+ live: boolean;
88
+ // Only set once the probe finishes
89
+ latency?: number;
90
+ alive?: boolean;
91
+ finished: boolean;
92
+ };
93
+ export type EdgeNodeStats = {
94
+ nodes: EdgeNodeStat[];
95
+ /** The node we actually booted from */
96
+ pickedHost: string;
97
+ /** What the picking algorithm chose (or would have chosen, when overridden) on its own. Empty until its probe decides. */
98
+ autoPickedHost: string;
99
+ /** Set when a url override requested a specific host, even if we couldn't connect to it (compare with pickedHost to detect that) */
100
+ requestedHost?: string;
101
+ };
102
+ declare global {
103
+ // Written by the edge bootstrapper as it probes the edge nodes, so the edge node dropdown can show what there was to pick from (and their latencies)
104
+ var EDGE_NODE_STATS: EdgeNodeStats | undefined;
105
+ }
82
106
  let registeredEdgeNode: { host: string; entryPaths: string[] } | boolean | undefined;
83
107
  export async function registerEdgeNode(config: {
84
108
  host: string;
@@ -7,20 +7,21 @@
7
7
  import { SocketFunction } from "socket-function/SocketFunction";
8
8
  import { timeInMinute, timeInSecond, sort } from "socket-function/src/misc";
9
9
  import { lazy } from "socket-function/src/caching";
10
- import { runInfinitePollCallAtStart } from "socket-function/src/batching";
10
+ import { delay, runInfinitePollCallAtStart } from "socket-function/src/batching";
11
11
  import { isClient } from "../config2";
12
12
  import { t } from "../2-proxy/schema2";
13
13
  import { createLocalSchema } from "./schemaHelpers";
14
14
  import { Querysub } from "./Querysub";
15
- import { getAllNodeIds, getOwnNodeId } from "../-f-node-discovery/NodeDiscovery";
15
+ import { getAllNodeIds, getOwnNodeId, watchDeltaNodeIds } from "../-f-node-discovery/NodeDiscovery";
16
16
  import { FunctionRunnerInfoController, FunctionStatsSummary } from "../3-path-functions/PathFunctionRunner";
17
17
  import { URLParam } from "../library-components/URLParam";
18
- import { timeoutToUndefinedSilent } from "../errors";
18
+ import { logErrors, timeoutToUndefined, timeoutToUndefinedSilent } from "../errors";
19
19
  import { spreadCallsOverTime } from "../misc";
20
- import { formatTime } from "socket-function/src/formatting/format";
21
- import type { DebugFunctionShardInfo } from "../3-path-functions/PathFunctionRunner";
20
+ import { formatDateTime, formatTime } from "socket-function/src/formatting/format";
21
+ import type { DebugFunctionShardInfo, FunctionRunnerInfo } from "../3-path-functions/PathFunctionRunner";
22
22
  import type { EdgeNodeConfig } from "../4-deploy/edgeNodes";
23
23
  import { isPublic } from "../config";
24
+ import { blue, green, yellow } from "socket-function/src/formatting/logColors";
24
25
 
25
26
  const POLL_INTERVAL = timeInMinute;
26
27
  const INDEX_POLL_INTERVAL = timeInMinute * 5;
@@ -144,47 +145,24 @@ export const startFunctionRunnerTracking = lazy(async () => {
144
145
  console.log(`Starting function runner tracking (polling every ${formatTime(POLL_INTERVAL)})`);
145
146
  await runInfinitePollCallAtStart(POLL_INTERVAL, pollFunctionRunners);
146
147
  finishedStartup = true;
148
+ // Pull newly discovered nodes immediately, instead of waiting up to a full poll interval to find out they are function runners
149
+ watchDeltaNodeIds(({ newNodeIds }) => {
150
+ for (let nodeId of newNodeIds) {
151
+ if (polledNodeIds.has(nodeId)) continue;
152
+ console.log(blue(`[${formatDateTime(Date.now())}] New node ${nodeId} discovered, immediately checking if it is a function runner`), { nodeId });
153
+ logErrors(pollFunctionRunner(nodeId));
154
+ }
155
+ });
147
156
  });
148
157
 
149
158
  let finishedStartup = false;
159
+ // Every node we have already pulled (runner or not), so the new-node watcher only pulls genuinely new nodes
160
+ let polledNodeIds = new Set<string>();
150
161
  async function pollFunctionRunners() {
151
- let now = Date.now();
152
162
  let nodeIds = await getAllNodeIds();
153
- await spreadCallsOverTime(nodeIds, finishedStartup ? POLL_INTERVAL : 0, async nodeId => {
154
- let start = Date.now();
155
- // Only function runners expose this, so a failed call just means the node isn't a runner
156
- let runnerInfo = await timeoutToUndefinedSilent(POLL_INTERVAL, FunctionRunnerInfoController.nodes[nodeId].getRunnerInfo({
157
- writeNodeId: getOwnNodeId(),
158
- }));
159
- if (!runnerInfo) return;
160
- let latency = Date.now() - start;
161
- if (runnerInfo.shards.length === 0) return;
162
-
163
- let networks = new Set<string>();
164
- for (let shard of runnerInfo.shards) {
165
- for (let network of shard.networks) {
166
- networks.add(network);
167
- }
168
- }
169
-
170
- let prev = nodeInfos.get(nodeId);
171
- let sampleCount = Math.min(prev?.latencySampleCount || 0, LATENCY_SAMPLE_LIMIT);
172
- nodeInfos.set(nodeId, {
173
- nodeId,
174
- entryPoint: runnerInfo.entryPoint,
175
- startupTime: runnerInfo.startupTime,
176
- lastSeen: now,
177
- isPublic: runnerInfo.shards.some(x => x.isPublic),
178
- networks: Array.from(networks),
179
- shards: runnerInfo.shards,
180
- averageLatency: ((prev?.averageLatency || 0) * sampleCount + latency) / (sampleCount + 1),
181
- latencySampleCount: sampleCount + 1,
182
- scheduledShutdownTime: runnerInfo.scheduledShutdownTime,
183
- });
184
- nodeTimings.set(nodeId, runnerInfo.timings);
185
- watchRunnerDisconnect(nodeId);
186
- });
163
+ await spreadCallsOverTime(nodeIds, finishedStartup ? POLL_INTERVAL : 0, pollFunctionRunner);
187
164
 
165
+ let now = Date.now();
188
166
  for (let [nodeId, info] of Array.from(nodeInfos)) {
189
167
  if (info.lastSeen < now - NODE_EXPIRY_TIME) {
190
168
  nodeInfos.delete(nodeId);
@@ -197,6 +175,59 @@ async function pollFunctionRunners() {
197
175
  }
198
176
  }
199
177
 
178
+ async function pollFunctionRunner(nodeId: string, logWarnings?: "logWarnings" | number) {
179
+ // Wait a bit so the node has time to set itself up, also to stagger the polling a little bit more.
180
+ await delay(2000 + Math.random() * 3000);
181
+ polledNodeIds.add(nodeId);
182
+ let start = Date.now();
183
+ // Only function runners expose this, so a failed call just means the node isn't a runner
184
+ let runnerInfoPromise = FunctionRunnerInfoController.nodes[nodeId].getRunnerInfo({
185
+ writeNodeId: getOwnNodeId(),
186
+ }) as Promise<FunctionRunnerInfo | undefined>;
187
+ if (logWarnings !== "logWarnings") {
188
+ runnerInfoPromise = timeoutToUndefinedSilent(POLL_INTERVAL, runnerInfoPromise);
189
+ } else {
190
+ runnerInfoPromise = timeoutToUndefined(POLL_INTERVAL, runnerInfoPromise);
191
+ }
192
+ let runnerInfo = await runnerInfoPromise;
193
+ if (!runnerInfo || !runnerInfo.shards.length) {
194
+ if (nodeInfos.has(nodeId) || nodeId.includes("bfbtchfql2fgrelem62qe")) {
195
+ runnerInfoPromise.catch(e => console.error("bfbtchfql2fgrelem62qe", e.stack));
196
+ console.log(yellow(`[${formatDateTime(Date.now())}] Function runner ${nodeId} disconnected, removing it from the runner index`), { nodeId });
197
+ nodeInfos.delete(nodeId);
198
+ }
199
+ return;
200
+ }
201
+ let latency = Date.now() - start;
202
+
203
+ let networks = new Set<string>();
204
+ for (let shard of runnerInfo.shards) {
205
+ for (let network of shard.networks) {
206
+ networks.add(network);
207
+ }
208
+ }
209
+
210
+ let prev = nodeInfos.get(nodeId);
211
+ let sampleCount = Math.min(prev?.latencySampleCount || 0, LATENCY_SAMPLE_LIMIT);
212
+ if (!nodeInfos.has(nodeId)) {
213
+ console.log(green(`[${formatDateTime(Date.now())}] Function runner ${nodeId} discovered, adding it to the runner index`), { nodeId });
214
+ }
215
+ nodeInfos.set(nodeId, {
216
+ nodeId,
217
+ entryPoint: runnerInfo.entryPoint,
218
+ startupTime: runnerInfo.startupTime,
219
+ lastSeen: Date.now(),
220
+ isPublic: runnerInfo.shards.some(x => x.isPublic),
221
+ networks: Array.from(networks),
222
+ shards: runnerInfo.shards,
223
+ averageLatency: ((prev?.averageLatency || 0) * sampleCount + latency) / (sampleCount + 1),
224
+ latencySampleCount: sampleCount + 1,
225
+ scheduledShutdownTime: runnerInfo.scheduledShutdownTime,
226
+ });
227
+ nodeTimings.set(nodeId, runnerInfo.timings);
228
+ watchRunnerDisconnect(nodeId);
229
+ }
230
+
200
231
  // Remove disconnected runners immediately, otherwise we would keep recommending their networks for up to NODE_EXPIRY_TIME after every runner on them is gone. If the runner comes back, the poll re-adds it (and re-registers this watch).
201
232
  let disconnectWatchedNodes = new Set<string>();
202
233
  function watchRunnerDisconnect(nodeId: string) {
@@ -206,7 +237,7 @@ function watchRunnerDisconnect(nodeId: string) {
206
237
  disconnectWatchedNodes.delete(nodeId);
207
238
  if (nodeInfos.delete(nodeId)) {
208
239
  nodeTimings.delete(nodeId);
209
- console.log(`Function runner ${nodeId} disconnected, removing it from the runner index immediately`);
240
+ console.log(yellow(`[${formatDateTime(Date.now())}] Function runner ${nodeId} disconnected, removing it from the runner index immediately`), { nodeId });
210
241
  }
211
242
  }, "iKnowThatServerNodeIdsMayReconnect_andIHandleReconnections");
212
243
  }
@@ -53,6 +53,7 @@ import * as typesafecss from "typesafecss";
53
53
  import "../library-components/urlResetGroups";
54
54
  import { createLocalSchema } from "./schemaHelpers";
55
55
 
56
+ setTimeout(() => import("./FunctionRunnerTracking"));
56
57
 
57
58
 
58
59
  typesafecss.setMeasureBlock(measureBlock);
package/src/config.ts CHANGED
@@ -181,7 +181,7 @@ export function getAuthorityPrefix() {
181
181
 
182
182
  export function isPublic() {
183
183
  if (!isNode()) {
184
- return !location.hostname.startsWith("127-0-0-1.");
184
+ return !(globalThis.BOOTED_EDGE_NODE?.host || location.hostname).startsWith("127-0-0-1.");
185
185
  }
186
186
  return !!yargObj.public;
187
187
  }
@@ -149,7 +149,7 @@ export class ServiceDetailPage extends qreact.Component {
149
149
  let modal: { close: () => void } | undefined;
150
150
  modal = showModal({
151
151
  content: <div className={css.fixed.pos(0, 0).size("100vw", "100vh").hsla(0, 0, 0, 0.5).display("flex").alignItems("center").justifyContent("center")}>
152
- <div className={css.vbox(12).pad2(20).hsl(0, 0, 98).bord2(0, 0, 40) + " keepModalsOpen"}>
152
+ <div className={css.vbox(12).pad2(20).hsl(0, 0, 12).colorhsl(0, 0, 90).bord2(0, 0, 40) + " keepModalsOpen"}>
153
153
  <div className={css.boldStyle.fontSize(16)}>⚡ Force deploy now?</div>
154
154
  <div>This config becomes live immediately. The old instances are shut down right away, with no overlap.</div>
155
155
  <div className={css.hbox(10)}>
@@ -1,16 +1,16 @@
1
1
  import { SocketFunction } from "socket-function/SocketFunction";
2
2
  import { qreact } from "../../4-dom/qreact";
3
- import { DEFAULT_OVERLAP_TIME, MachineServiceController, ServiceConfig } from "../machineSchema";
3
+ import { DEFAULT_OVERLAP_TIME, MachineServiceController, ServiceConfig, getLiveServiceParameters } from "../machineSchema";
4
4
  import { css } from "typesafecss";
5
5
  import { t } from "../../2-proxy/schema2";
6
6
  import { Querysub } from "../../4-querysub/Querysub";
7
7
  import { currentViewParam, selectedServiceIdParam } from "../urlParams";
8
- import { formatNiceDateTime, formatTime, formatVeryNiceDateTime } from "socket-function/src/formatting/format";
9
- import { sort, timeInMinute } from "socket-function/src/misc";
8
+ import { formatDateTimeDetailed, formatNiceDateTime, formatTime, formatVeryNiceDateTime } from "socket-function/src/formatting/format";
9
+ import { sort, timeInMinute, timeInSecond } from "socket-function/src/misc";
10
10
  import { cache } from "socket-function/src/caching";
11
11
  import { Anchor } from "../../library-components/ATag";
12
12
  import { isPublic } from "../../config";
13
- import { UpdateButtons, UpdateServiceButtons } from "./deployButtons";
13
+ import { PendingDeployInfo, UpdateButtons, UpdateServiceButtons } from "./deployButtons";
14
14
  import { isDefined } from "../../misc";
15
15
  import { formatDateJSX } from "../../misc/formatJSX";
16
16
  import { Tools } from "./Tools";
@@ -26,6 +26,8 @@ export class ServicesListPage extends qreact.Component {
26
26
 
27
27
  if (!serviceList) return <div>Loading services...</div>;
28
28
 
29
+ let now = Querysub.nowDelayed(timeInSecond);
30
+
29
31
  let services = serviceList.map(serviceId => [serviceId, controller.getServiceConfig(serviceId)] as const);
30
32
  sort(services, x => -(x[1]?.info.lastUpdatedTime || Date.now()) - (x[1]?.parameters.deploy && (Number.MAX_SAFE_INTEGER / 2) || 0));
31
33
 
@@ -140,10 +142,32 @@ export class ServicesListPage extends qreact.Component {
140
142
  <div className={css.vbox(4)}>
141
143
  <div>Updated {formatDateJSX(config.info.lastUpdatedTime)} AGO</div>
142
144
  {config.parameters.overlapTime && <div className={css.colorhsl(210, 50, 50).fontSize(14).fontWeight("bold")}>Deploy overlap: {formatTime(config.parameters.overlapTime)}</div> || undefined}
145
+ {(() => {
146
+ let releaseTime = config.parameters.releaseTime || 0;
147
+ if (!releaseTime || releaseTime <= now || !config.oldParameters) return undefined;
148
+ let live = getLiveServiceParameters(config) as Record<string, unknown>;
149
+ let next = config.parameters as Record<string, unknown>;
150
+ let diffLines: string[] = [];
151
+ for (let key of new Set([...Object.keys(live), ...Object.keys(next)])) {
152
+ if (key === "releaseTime") continue;
153
+ let oldValue = JSON.stringify(live[key]);
154
+ let newValue = JSON.stringify(next[key]);
155
+ if (oldValue !== newValue) {
156
+ diffLines.push(`${key}: ${oldValue} -> ${newValue}`);
157
+ }
158
+ }
159
+ return <div
160
+ className={css.colorhsl(35, 80, 45).fontSize(14).fontWeight("bold")}
161
+ title={`Deploys at ${formatDateTimeDetailed(releaseTime)}\n\n${diffLines.join("\n")}`}
162
+ >
163
+ 🕐 Deploys in {formatTime(releaseTime - now)}
164
+ </div>;
165
+ })()}
143
166
  </div>
144
167
  </div>
145
168
  </Anchor>
146
169
  <UpdateServiceButtons service={config} />
170
+ <PendingDeployInfo service={config} />
147
171
  </div>
148
172
  ;
149
173
  })}
@@ -5,7 +5,8 @@ import { Querysub } from "../../4-querysub/Querysub";
5
5
  import { DEFAULT_OVERLAP_TIME, MachineServiceController, MachineInfo, ServiceConfig } from "../machineSchema";
6
6
  import { showFullscreenModal } from "../../5-diagnostics/FullscreenModal";
7
7
  import { css } from "../../4-dom/css";
8
- import { formatTime } from "socket-function/src/formatting/format";
8
+ import { formatDateTimeDetailed, formatTime } from "socket-function/src/formatting/format";
9
+ import { timeInSecond } from "socket-function/src/misc";
9
10
  import { formatDateJSX } from "../../misc/formatJSX";
10
11
  import { MachineController } from "../machineController";
11
12
  import { deepCloneJSON } from "socket-function/src/misc";
@@ -192,6 +193,36 @@ export class UpdateServiceButtons extends qreact.Component<{
192
193
  }
193
194
  }
194
195
 
196
+ /** Shows a service's scheduled (not yet live) deploy, in the same style as the update buttons (but blue, and not clickable): when it goes live, and the new vs currently live commit. */
197
+ export class PendingDeployInfo extends qreact.Component<{
198
+ service: ServiceConfig;
199
+ }> {
200
+ render() {
201
+ let service = this.props.service;
202
+ let releaseTime = service.parameters.releaseTime || 0;
203
+ let now = Querysub.nowDelayed(timeInSecond);
204
+ const oldParameters = service.oldParameters;
205
+ if (!releaseTime || releaseTime <= now || !oldParameters) return undefined;
206
+
207
+ return <div
208
+ className={css.pad2(12, 8).bord2(210, 60, 40).fontWeight("bold").vbox(2).alignItems("center").alignSelf("stretch").hsl(210, 70, 90)}
209
+ title={formatDateTimeDetailed(releaseTime)}
210
+ >
211
+ <div>
212
+ {bigEmoji("🕐", -4)} <span>Deploys in {formatTime(releaseTime - now)}</span>
213
+ </div>
214
+ <div className={css.hbox(5)}>
215
+ <b>New</b>
216
+ <RenderGitRefInfo gitRef={service.parameters.gitRef} />
217
+ </div>
218
+ <div className={css.hbox(5)}>
219
+ <b>Current</b>
220
+ <RenderGitRefInfo gitRef={oldParameters.gitRef} />
221
+ </div>
222
+ </div>;
223
+ }
224
+ }
225
+
195
226
  export class DeployMachineButtons extends qreact.Component<{
196
227
  machines: MachineInfo[];
197
228
  }> {
@@ -0,0 +1,78 @@
1
+ import { css } from "typesafecss";
2
+ import { qreact } from "../4-dom/qreact";
3
+ import { Querysub } from "../4-querysub/Querysub";
4
+ import { timeInSecond } from "socket-function/src/misc";
5
+ import { formatTime } from "socket-function/src/formatting/format";
6
+ import { isCurrentUserSuperUser } from "../user-implementation/userData";
7
+ import type { EdgeNodeConfig, EdgeNodeStat } from "../4-deploy/edgeNodes";
8
+
9
+ const EDGE_NODE_URL_PARAM = "edgenode";
10
+
11
+ /** A dropdown showing all the edge nodes the bootstrapper had to pick from (with their probed latencies), which one we booted from, and allowing forcing a specific edge node. Forcing sets a url parameter and refreshes the page, as the edge node is used by the bootstrapper, so it can only apply on a fresh page load. Super users only. */
12
+ export class EdgeNodeSelector extends qreact.Component<{}> {
13
+ render() {
14
+ if (!isCurrentUserSuperUser()) return undefined;
15
+ // The stats are a plain global the bootstrapper updates as its probes finish, so we re-read them every second
16
+ Querysub.nowDelayed(timeInSecond);
17
+ let stats = globalThis.EDGE_NODE_STATS;
18
+ const booted = (globalThis as any).BOOTED_EDGE_NODE as EdgeNodeConfig | undefined;
19
+ if (!booted) return undefined;
20
+ let forced = new URL(document.location.href).searchParams.get(EDGE_NODE_URL_PARAM) || "";
21
+
22
+ const setEdgeNode = (host: string) => {
23
+ let url = new URL(document.location.href);
24
+ if (host) {
25
+ url.searchParams.set(EDGE_NODE_URL_PARAM, host);
26
+ } else {
27
+ url.searchParams.delete(EDGE_NODE_URL_PARAM);
28
+ }
29
+ document.location.href = url.toString();
30
+ };
31
+
32
+ let nodes = stats?.nodes || [];
33
+ if (!nodes.some(x => x.host === booted.host)) {
34
+ nodes = [...nodes, { host: booted.host, nodeId: booted.nodeId, public: booted.public, live: true, finished: false }];
35
+ }
36
+ let autoStat = nodes.find(x => x.host === stats?.autoPickedHost);
37
+
38
+ return <div className={css.hbox(6)}>
39
+ <span className={css.opacity(0.7)}>Edge</span>
40
+ <select
41
+ value={booted.host}
42
+ className={css.pad2(4, 2).hsl(0, 0, 16).colorhsl(0, 0, 90).bord2(0, 0, 30)}
43
+ onChange={e => {
44
+ setEdgeNode(e.currentTarget.value);
45
+ }}
46
+ >
47
+ {nodes.map(node => <option value={node.host}>{formatStatLabel(node)}</option>)}
48
+ </select>
49
+ {stats?.requestedHost && stats.requestedHost !== booted.host && <span className={css.pad2(6, 2).bord2(0, 85, 55).colorhsl(0, 85, 55).boldStyle}>
50
+ ⚠️ couldn't connect to {stats.requestedHost}, using {booted.host}
51
+ </span>}
52
+ {forced && <span
53
+ className={css.button.pad2(6, 2).hsl(35, 60, 22).colorhsl(0, 0, 90).bord2(35, 60, 40)}
54
+ title="The edge node is forced. Click to restore the automatically picked edge node (refreshes the page)."
55
+ onMouseDown={() => setEdgeNode("")}
56
+ >
57
+ restore {autoStat && formatStatLabel(autoStat) || stats?.autoPickedHost || "auto"}
58
+ </span>}
59
+ </div>;
60
+ }
61
+ }
62
+
63
+ function formatStatLabel(stat: EdgeNodeStat): string {
64
+ let label = stat.host;
65
+ if (stat.finished) {
66
+ label += ` (${stat.alive && `${formatTime(stat.latency || 0)} latency` || "dead"}`;
67
+ } else {
68
+ label += ` (checking...`;
69
+ }
70
+ if (!stat.public) {
71
+ label += `, private`;
72
+ }
73
+ if (!stat.live) {
74
+ label += `, old version`;
75
+ }
76
+ label += `)`;
77
+ return label;
78
+ }
@@ -2,11 +2,12 @@ import { css } from "typesafecss";
2
2
  import { qreact } from "../4-dom/qreact";
3
3
  import { formatTime } from "socket-function/src/formatting/format";
4
4
  import { isCurrentUserSuperUser } from "../user-implementation/userData";
5
- import { forcedNetworkURL, getAutoSelectedNetwork, getNetworkSelectionInfos, getSelectedNetwork } from "../4-querysub/FunctionRunnerTracking";
5
+ import { forcedNetworkURL, getAutoSelectedNetwork, getNetworkSelectionInfos, getSelectedNetwork, NetworkSelectionInfo } from "../4-querysub/FunctionRunnerTracking";
6
6
 
7
- /** A dropdown showing all the function runner networks, which one our function calls are being put on, and allowing forcing a specific network (and unforcing it, going back to the automatic selection). */
7
+ /** A dropdown showing all the function runner networks, which one our function calls are being put on, and allowing forcing a specific network (and unforcing it, going back to the automatic selection). Super users only. */
8
8
  export class NetworkSelector extends qreact.Component<{}> {
9
9
  render() {
10
+ if (!isCurrentUserSuperUser()) return undefined;
10
11
  let infos = getNetworkSelectionInfos();
11
12
  let forced = forcedNetworkURL.value;
12
13
  let selected = getSelectedNetwork({ noThrow: true });
@@ -37,35 +38,22 @@ export class NetworkSelector extends qreact.Component<{}> {
37
38
  >
38
39
  {networks.map(network => {
39
40
  let info = infos.find(x => x.network === network);
40
- let label = network;
41
- if (info) {
42
- label += ` (${info.nodeCount} runner${info.nodeCount !== 1 && "s" || ""}, ${formatTime(info.averageLatency)} latency`;
43
- if (info.averageCallTime) {
44
- label += `, ${formatTime(info.averageCallTime)} avg call`;
45
- }
46
- if (!info.isPublic) {
47
- label += `, non-public`;
48
- }
49
- if (info.dying) {
50
- label += `, shutting down`;
51
- }
52
- label += `)`;
53
- }
54
41
  let disabled = !!info && !info.isPublic && !isSuperUser;
55
- return <option value={network} disabled={disabled}>{label}</option>;
42
+ return <option value={network} disabled={disabled}>{networkLabel(network, info)}</option>;
56
43
  })}
57
44
  </select>
58
45
  {forced && <span
59
46
  className={css.button.pad2(6, 2).hsl(35, 60, 22).colorhsl(0, 0, 90).bord2(35, 60, 40)}
60
- title="The network is forced. Click to reset back to the automatically picked network."
47
+ title="The network is forced. Click to restore the automatically picked network."
61
48
  onMouseDown={() => forcedNetworkURL.value = ""}
62
49
  >
63
- auto: {auto}
50
+ restore {auto && networkLabel(auto, infos.find(x => x.network === auto)) || "auto"}
64
51
  </span>}
65
52
  {(() => {
66
53
  let selectedInfo = infos.find(x => x.network === selected);
67
- if (!selectedInfo || selectedInfo.nodeCount === 0) {
68
- return <span className={css.pad2(6, 2).bord2(0, 85, 55).colorhsl(0, 85, 55).boldStyle}>⛔ no function runners on this network</span>;
54
+ // The selection infos only contain networks that have at least one runner, so a missing entry means the selected network doesn't exist at all
55
+ if (!selectedInfo) {
56
+ return <span className={css.pad2(6, 2).bord2(0, 85, 55).colorhsl(0, 85, 55).boldStyle}>⛔ the network {JSON.stringify(selected)} does not exist (no function runner is on it)</span>;
69
57
  }
70
58
  if (selectedInfo.dying) {
71
59
  return <span className={css.colorhsl(35, 85, 55)}>⚠️ this network is shutting down</span>;
@@ -75,3 +63,21 @@ export class NetworkSelector extends qreact.Component<{}> {
75
63
  </div>;
76
64
  }
77
65
  }
66
+
67
+ function networkLabel(network: string, info: NetworkSelectionInfo | undefined): string {
68
+ let label = network;
69
+ if (info) {
70
+ label += ` (${info.nodeCount} runner${info.nodeCount !== 1 && "s" || ""}, ${formatTime(info.averageLatency)} latency`;
71
+ if (info.averageCallTime) {
72
+ label += `, ${formatTime(info.averageCallTime)} avg call`;
73
+ }
74
+ if (!info.isPublic) {
75
+ label += `, non-public`;
76
+ }
77
+ if (info.dying) {
78
+ label += `, shutting down`;
79
+ }
80
+ label += `)`;
81
+ }
82
+ return label;
83
+ }