querysub 0.508.0 → 0.509.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.
Files changed (48) hide show
  1. package/bin/function-public.js +2 -0
  2. package/bin/server.js +0 -2
  3. package/package.json +3 -4
  4. package/spec.txt +5 -0
  5. package/src/-f-node-discovery/NodeDiscovery.ts +1 -1
  6. package/src/-g-core-values/NodeCapabilities.ts +4 -0
  7. package/src/-g-core-values/scheduledShutdown.ts +48 -0
  8. package/src/0-path-value-core/AuthorityLookup.ts +50 -9
  9. package/src/0-path-value-core/PathRouter.ts +10 -31
  10. package/src/0-path-value-core/PathRouterRouteOverride.ts +14 -98
  11. package/src/0-path-value-core/PathRouterServerAuthoritySpec.tsx +1 -4
  12. package/src/0-path-value-core/PathValueCommitter.ts +1 -6
  13. package/src/0-path-value-core/PathValueController.ts +5 -7
  14. package/src/0-path-value-core/PathWatcher.ts +8 -11
  15. package/src/0-path-value-core/hackedPackedPathParentFiltering.ts +8 -22
  16. package/src/0-path-value-core/pathValueCore.ts +4 -5
  17. package/src/1-path-client/RemoteWatcher.ts +47 -34
  18. package/src/2-proxy/archiveMoveHarness.ts +2 -1
  19. package/src/3-path-functions/PathFunctionHelpers.ts +11 -10
  20. package/src/3-path-functions/PathFunctionRunner.ts +165 -24
  21. package/src/3-path-functions/functionReplay.ts +2 -0
  22. package/src/3-path-functions/syncSchema.ts +0 -2
  23. package/src/4-deploy/edgeBootstrap.ts +10 -0
  24. package/src/4-deploy/edgeClientWatcher.tsx +4 -6
  25. package/src/4-deploy/edgeNodes.ts +52 -12
  26. package/src/4-querysub/FunctionRunnerTracking.ts +290 -0
  27. package/src/4-querysub/Querysub.ts +11 -16
  28. package/src/4-querysub/QuerysubController.ts +13 -11
  29. package/src/4-querysub/permissions.ts +3 -0
  30. package/src/4-querysub/querysubPrediction.ts +2 -2
  31. package/src/config.ts +1 -3
  32. package/src/deployManager/components/ServiceDetailPage.tsx +127 -73
  33. package/src/deployManager/components/ServicesListPage.tsx +3 -3
  34. package/src/deployManager/machineApplyMainCode.ts +139 -153
  35. package/src/deployManager/machineController.ts +12 -61
  36. package/src/deployManager/machineSchema.ts +84 -3
  37. package/src/deployManager/setupMachineMain.ts +2 -1
  38. package/src/diagnostics/grossStats/GrossStatsController.ts +3 -2
  39. package/src/diagnostics/logs/errorTickets/autoFixer.ts +3 -2
  40. package/src/diagnostics/logs/logGitHashes.ts +1 -1
  41. package/src/diagnostics/managementPages.tsx +2 -0
  42. package/src/diagnostics/misc-pages/AuthoritySpecPage.tsx +36 -28
  43. package/src/diagnostics/misc-pages/FunctionCaptureAnalyzePage.tsx +1 -1
  44. package/src/library-components/NetworkSelector.tsx +77 -0
  45. package/src/misc.ts +19 -0
  46. package/src/user-implementation/userData.ts +1 -1
  47. package/src/zipThreaded.ts +3 -2
  48. package/bin/server-dev.js +0 -11
@@ -1,7 +1,7 @@
1
1
  import { batchFunction, delay, runInfinitePoll } from "socket-function/src/batching";
2
- import { cache } from "socket-function/src/caching";
2
+ import { cache, lazy } from "socket-function/src/caching";
3
3
  import { blue, magenta, yellow } from "socket-function/src/formatting/logColors";
4
- import { timeInHour, timeInSecond } from "socket-function/src/misc";
4
+ import { timeInHour, timeInMinute, timeInSecond } from "socket-function/src/misc";
5
5
  import { measureFnc } from "socket-function/src/profiling/measure";
6
6
  import { registerDynamicResource } from "../diagnostics/trackResources";
7
7
  import { errorToUndefined, logErrors } from "../errors";
@@ -16,6 +16,8 @@ import { parseArgs } from "./PathFunctionHelpers";
16
16
  import { FunctionMetadata, PERMISSIONS_FUNCTION_ID, addRoutingPrefixForDeploy, getAllDevelopmentModulesIds, getDevelopmentModule, getExportPath, getModuleRelativePath, getSchemaObject } from "./syncSchema";
17
17
  import { formatTime } from "socket-function/src/formatting/format";
18
18
  import { getControllerNodeIdList, set_debug_getFunctionRunnerShards } from "../-g-core-values/NodeCapabilities";
19
+ import { getOwnNodeId } from "../-f-node-discovery/NodeDiscovery";
20
+ import { getScheduledShutdownTime } from "../-g-core-values/scheduledShutdown";
19
21
  import { SocketFunction } from "socket-function/SocketFunction";
20
22
  import { requiresNetworkTrustHook } from "../-d-trust/NetworkTrust2";
21
23
  import { getDomain, isPublic, DEFAULT_NETWORK } from "../config";
@@ -37,8 +39,8 @@ export const functionSchema = rawSchema<{
37
39
  [ModuleId: string]: {
38
40
  Data: unknown;
39
41
  Sources: { [FunctionId: string]: FunctionSpec | undefined; };
40
- Calls: { [CallId: string]: CallSpec | undefined; };
41
- Results: { [CallId: string]: FunctionResult | undefined; };
42
+ Calls: { [Network: string]: { [CallId: string]: CallSpec | undefined; }; };
43
+ Results: { [Network: string]: { [CallId: string]: FunctionResult | undefined; }; };
42
44
  };
43
45
  };
44
46
  };
@@ -53,9 +55,14 @@ setImmediate(() => {
53
55
  });
54
56
 
55
57
  export function commitCall(call: CallSpec) {
58
+ if (!call.network) {
59
+ throw new Error(`Call has no network, so it would never run (no FunctionRunner would pick it up). Call: ${debugCallSpec(call)}`);
60
+ }
61
+ call.writeNodeId = getOwnNodeId();
56
62
  let domainName = call.DomainName;
57
63
  let moduleId = call.ModuleId;
58
64
  let callId = call.CallId;
65
+ let network = call.network;
59
66
  proxyWatcher.writeOnly({
60
67
  canWrite: true,
61
68
  eventWrite: true,
@@ -63,7 +70,7 @@ export function commitCall(call: CallSpec) {
63
70
  // NOTE: We write the call in the present, it is callSpec.runAtTime that is in the past. No one cares
64
71
  // when the call was added, only when it wants to run at.
65
72
  watchFunction: function writeCall() {
66
- functionSchema()[domainName].PathFunctionRunner[moduleId].Calls[callId] = atomicObjectWrite(call);
73
+ functionSchema()[domainName].PathFunctionRunner[moduleId].Calls[network][callId] = atomicObjectWrite(call);
67
74
  },
68
75
  });
69
76
  }
@@ -107,8 +114,11 @@ export interface CallSpec {
107
114
  // Not just used for debugging, also used to add special proxy-related warnings.
108
115
  fromProxy?: string;
109
116
 
110
- // The network the call is on ("default" if unset). Only FunctionRunners on this network will run the call.
111
- network?: string;
117
+ // Only FunctionRunners on this network will run the call. If no FunctionRunner is on this network, the call never runs.
118
+ network: string;
119
+
120
+ // The node that committed the call. FunctionRunners record call timings tagged with this, so the node that wrote a call can pull the timings of its own calls.
121
+ writeNodeId?: string;
112
122
  }
113
123
  export function debugCallSpec(spec: CallSpec): string {
114
124
  return `${spec.DomainName}/${spec.ModuleId}/${spec.FunctionId}`;
@@ -126,6 +136,121 @@ export type FunctionResult = ({
126
136
  totalTime: number;
127
137
  };
128
138
 
139
+ export type FunctionStatsSummary = {
140
+ count: number;
141
+ totalTime: number;
142
+ minTime: number;
143
+ maxTime: number;
144
+ };
145
+ const TIMINGS_KEPT_TIME = timeInHour;
146
+ const TIMINGS_BUCKET_SIZE = timeInMinute * 5;
147
+ // Kept PER writing node: how long a call takes varies by the node it was called from, so each node only wants the timings of its own calls. Bucketed (bucket start time => stats) so old data can age out without keeping per-call data; only summaries ever leave this file.
148
+ let functionTimings = new Map<string, Map<string, Map<number, FunctionStatsSummary>>>();
149
+ // Writing nodes get new nodeIds when they restart, so without this their old timings would be kept forever.
150
+ const startTimingsSweep = lazy(() => {
151
+ runInfinitePoll(TIMINGS_KEPT_TIME, function sweepExpiredTimings() {
152
+ let expiry = Date.now() - TIMINGS_KEPT_TIME;
153
+ for (let [writeNodeId, networks] of functionTimings) {
154
+ for (let [network, buckets] of networks) {
155
+ for (let start of buckets.keys()) {
156
+ if (start < expiry) {
157
+ buckets.delete(start);
158
+ }
159
+ }
160
+ if (buckets.size === 0) {
161
+ networks.delete(network);
162
+ }
163
+ }
164
+ if (networks.size === 0) {
165
+ functionTimings.delete(writeNodeId);
166
+ }
167
+ }
168
+ });
169
+ });
170
+ function recordFunctionTiming(config: {
171
+ writeNodeId: string | undefined;
172
+ network: string;
173
+ durationMs: number;
174
+ }) {
175
+ if (!config.writeNodeId) return;
176
+ startTimingsSweep();
177
+ let networks = functionTimings.get(config.writeNodeId);
178
+ if (!networks) {
179
+ networks = new Map();
180
+ functionTimings.set(config.writeNodeId, networks);
181
+ }
182
+ let buckets = networks.get(config.network);
183
+ if (!buckets) {
184
+ buckets = new Map();
185
+ networks.set(config.network, buckets);
186
+ }
187
+ let now = Date.now();
188
+ let bucketStart = Math.floor(now / TIMINGS_BUCKET_SIZE) * TIMINGS_BUCKET_SIZE;
189
+ let bucket = buckets.get(bucketStart);
190
+ if (!bucket) {
191
+ bucket = { count: 0, totalTime: 0, minTime: config.durationMs, maxTime: config.durationMs };
192
+ buckets.set(bucketStart, bucket);
193
+ }
194
+ bucket.count++;
195
+ bucket.totalTime += config.durationMs;
196
+ bucket.minTime = Math.min(bucket.minTime, config.durationMs);
197
+ bucket.maxTime = Math.max(bucket.maxTime, config.durationMs);
198
+ }
199
+
200
+ const runnerStartupTime = Date.now();
201
+ export type FunctionRunnerInfo = {
202
+ entryPoint: string;
203
+ startupTime: number;
204
+ scheduledShutdownTime?: number;
205
+ shards: DebugFunctionShardInfo[];
206
+ // network => stats over the last hour of the calling node's own calls
207
+ timings: { [network: string]: FunctionStatsSummary };
208
+ };
209
+
210
+ // Everything the function runner tracking poll needs, in a single cheap call: what we run (networks/shards), our lifecycle (startup/shutdown), and how long the caller's own calls took. Only exposed by function runners, so a failed call doubles as "not a runner".
211
+ class FunctionRunnerInfoControllerBase {
212
+ public async getRunnerInfo(config: {
213
+ writeNodeId: string;
214
+ }): Promise<FunctionRunnerInfo> {
215
+ let expiry = Date.now() - TIMINGS_KEPT_TIME;
216
+ let timings: FunctionRunnerInfo["timings"] = {};
217
+ for (let [network, buckets] of functionTimings.get(config.writeNodeId) || []) {
218
+ let summary: FunctionStatsSummary = { count: 0, totalTime: 0, minTime: 0, maxTime: 0 };
219
+ for (let [bucketStart, bucket] of buckets) {
220
+ if (bucketStart < expiry) continue;
221
+ if (summary.count === 0) {
222
+ summary.minTime = bucket.minTime;
223
+ }
224
+ summary.count += bucket.count;
225
+ summary.totalTime += bucket.totalTime;
226
+ summary.minTime = Math.min(summary.minTime, bucket.minTime);
227
+ summary.maxTime = Math.max(summary.maxTime, bucket.maxTime);
228
+ }
229
+ timings[network] = summary;
230
+ }
231
+ return {
232
+ entryPoint: process.argv[1],
233
+ startupTime: runnerStartupTime,
234
+ scheduledShutdownTime: getScheduledShutdownTime(),
235
+ shards: debugFunctionRunnerShards,
236
+ timings,
237
+ };
238
+ }
239
+ }
240
+ export const FunctionRunnerInfoController = SocketFunction.register(
241
+ "FunctionRunnerInfoController-3f7a1c2e-8d94-4b6a-9e51-c0d72fa8b143",
242
+ new FunctionRunnerInfoControllerBase(),
243
+ () => ({
244
+ getRunnerInfo: {},
245
+ }),
246
+ () => ({
247
+ hooks: [requiresNetworkTrustHook],
248
+ }),
249
+ {
250
+ noAutoExpose: true,
251
+ }
252
+ );
253
+
129
254
  let currentCallSpec: { spec: CallSpec; fnc: FunctionSpec; } | undefined;
130
255
  export function getCurrentCall() {
131
256
  if (!currentCallSpec) {
@@ -195,6 +320,9 @@ export type DebugFunctionShardInfo = {
195
320
  domainName: string;
196
321
  shardRange: { startFraction: number, endFraction: number };
197
322
  secondaryShardRange?: { startFraction: number, endFraction: number };
323
+ // The networks this runner listens to calls on
324
+ networks: string[];
325
+ isPublic: boolean;
198
326
  };
199
327
  let debugFunctionRunnerShards: DebugFunctionShardInfo[] = [];
200
328
  export function debug_getFunctionRunnerShards() {
@@ -232,10 +360,13 @@ export class PathFunctionRunner {
232
360
  }) {
233
361
  SocketFunction.expose(FunctionPreloadController);
234
362
  SocketFunction.expose(FunctionCaptureController);
363
+ SocketFunction.expose(FunctionRunnerInfoController);
235
364
  debugFunctionRunnerShards.push({
236
365
  domainName: config.domainName,
237
366
  shardRange: config.shardRange,
238
367
  secondaryShardRange: config.secondaryShardRange,
368
+ networks: config.networks && config.networks.length > 0 && config.networks || [DEFAULT_NETWORK],
369
+ isPublic: isPublic(),
239
370
  });
240
371
  logErrors(this.startWatching());
241
372
  }
@@ -269,9 +400,8 @@ export class PathFunctionRunner {
269
400
 
270
401
  let watchModuleCalls = cache((moduleId: string) => {
271
402
  for (let network of networks) {
272
- let networkConfig = network === DEFAULT_NETWORK && fullFraction || { ...fullFraction, network };
273
- Querysub.keys(functionSchema()[domainName].PathFunctionRunner[moduleId].Calls, networkConfig);
274
- Querysub.keys(functionSchema()[domainName].PathFunctionRunner[moduleId].Results, networkConfig);
403
+ Querysub.keys(functionSchema()[domainName].PathFunctionRunner[moduleId].Calls[network], fullFraction);
404
+ Querysub.keys(functionSchema()[domainName].PathFunctionRunner[moduleId].Results[network], fullFraction);
275
405
  }
276
406
  });
277
407
 
@@ -326,7 +456,10 @@ export class PathFunctionRunner {
326
456
  if (getPathIndex(path, 1) !== "PathFunctionRunner") continue;
327
457
  if (getPathIndex(path, 0) !== domainName) continue;
328
458
 
329
- const callId = getPathIndex(path, 4);
459
+ const network = getPathIndex(path, 4);
460
+ if (!network) continue;
461
+ if (!networks.includes(network)) continue;
462
+ const callId = getPathIndex(path, 5);
330
463
  if (!callId) continue;
331
464
  let moduleId = getPathIndex(path, 2);
332
465
  if (!moduleId) continue;
@@ -334,10 +467,10 @@ export class PathFunctionRunner {
334
467
 
335
468
  let moduleData = data()[moduleId];
336
469
 
337
- let result = atomicObjectRead(moduleData.Results[callId]);
470
+ let result = atomicObjectRead(moduleData.Results[network][callId]);
338
471
  if (result) continue;
339
472
 
340
- let callData = atomicObjectRead(moduleData.Calls[callId]);
473
+ let callData = atomicObjectRead(moduleData.Calls[network][callId]);
341
474
  if (!callData) continue;
342
475
 
343
476
  // Ignore invalid data
@@ -363,7 +496,7 @@ export class PathFunctionRunner {
363
496
  // If we haven't synced the result, we can't know if it is ready or not
364
497
  // (we'll run anyways once it is synced). Most other parts are immutable,
365
498
  // but the result is by definition not, so... it is one of the values we check!
366
- if (!isSynced(moduleData.Results[callId])) continue;
499
+ if (!isSynced(moduleData.Results[network][callId])) continue;
367
500
 
368
501
  if (runningCalls.has(callId)) {
369
502
  console.log(`Skipping run of already running call ${callId} at ${debugTime(callData.runAtTime)}`);
@@ -401,7 +534,7 @@ export class PathFunctionRunner {
401
534
  // NOTE: If this starts but never runs, it means the call's network didn't match ours, which either means it was a dev call and so not relevant for us, or we are dev and it's a production call, so not relevant for us.
402
535
  // - The network is set by --network when running serverside, or, clientside, by --network set on the command line of the querysub server.
403
536
  console.log(`QUEUING ${getDebugName(callData, functionSpec, true)}`);
404
- let resultsPath = getProxyPath(() => moduleData.Results[callId]);
537
+ let resultsPath = getProxyPath(() => moduleData.Results[network][callId]);
405
538
  let history = authorityStorage.getValuePlusHistory(resultsPath);
406
539
  console.log(` History: ${history.length}`);
407
540
  for (let { valid, time, canGCValue } of history) {
@@ -551,7 +684,7 @@ export class PathFunctionRunner {
551
684
  skipPermissions = PermissionsChecker.skipPermissionsChecks.bind(PermissionsChecker);
552
685
  }
553
686
 
554
- let callNetwork = callSpec.network || DEFAULT_NETWORK;
687
+ let callNetwork = callSpec.network;
555
688
  let ourNetworks = this.config.networks && this.config.networks.length > 0 && this.config.networks || [DEFAULT_NETWORK];
556
689
  if (!ourNetworks.includes(callNetwork)) {
557
690
  return;
@@ -577,7 +710,7 @@ export class PathFunctionRunner {
577
710
  let syncedModule = skipPermissions(() =>
578
711
  functionSchema()[callSpec.DomainName].PathFunctionRunner[callSpec.ModuleId]
579
712
  );
580
- return !!atomicObjectRead(syncedModule.Results[callSpec.CallId]);
713
+ return !!atomicObjectRead(syncedModule.Results[callNetwork][callSpec.CallId]);
581
714
  },
582
715
  });
583
716
  }
@@ -740,7 +873,7 @@ export class PathFunctionRunner {
740
873
  // know figure out the dependencies for the call, which makes function call prediction
741
874
  // possible.
742
875
  skipPermissions(() => {
743
- let result = doProxyOptions({ forceReadLatest: true }, () => atomicObjectRead(syncedModule.Results[callSpec.CallId]));
876
+ let result = doProxyOptions({ forceReadLatest: true }, () => atomicObjectRead(syncedModule.Results[callNetwork][callSpec.CallId]));
744
877
  if (result) {
745
878
  nooped = true;
746
879
  // It is important to NOOP if it has already been written. Otherwise there could be multiple
@@ -757,7 +890,7 @@ export class PathFunctionRunner {
757
890
  let currentTimeTaken = Date.now() - startTime;
758
891
  stats.timeTaken = currentTimeTaken;
759
892
  let totalTime = Date.now() - stats.firstQueuedTime;
760
- syncedModule.Results[callSpec.CallId] = atomicObjectWrite({
893
+ syncedModule.Results[callNetwork][callSpec.CallId] = atomicObjectWrite({
761
894
  lastInternalLoopCount: runCount,
762
895
  outerLoopCount: stats.outerLoopCount,
763
896
  totalInternalLoopCount: stats.totalInternalLoopCount,
@@ -765,7 +898,7 @@ export class PathFunctionRunner {
765
898
  evalTime,
766
899
  totalTime,
767
900
  });
768
- proxyWatcher.setEventPath(() => syncedModule.Results[callSpec.CallId]);
901
+ proxyWatcher.setEventPath(() => syncedModule.Results[callNetwork][callSpec.CallId]);
769
902
  });
770
903
 
771
904
  let watcher = proxyWatcher.getTriggeredWatcher();
@@ -786,7 +919,7 @@ export class PathFunctionRunner {
786
919
  // Capture only runs that actually authored the Result (not nooped/partial runs),
787
920
  // so a replayed call maps to exactly one recorded output state.
788
921
  if (isCapturing()) {
789
- let resultsPathStr = getProxyPath(() => functionSchema()[callSpec.DomainName].PathFunctionRunner[callSpec.ModuleId].Results[callSpec.CallId]);
922
+ let resultsPathStr = getProxyPath(() => functionSchema()[callSpec.DomainName].PathFunctionRunner[callSpec.ModuleId].Results[callNetwork][callSpec.CallId]);
790
923
  let resultWrite = writes.find(w => w.path === resultsPathStr);
791
924
  if (resultWrite) {
792
925
  recordCapturedCall({
@@ -871,6 +1004,13 @@ export class PathFunctionRunner {
871
1004
  wallTime, syncTime, evalTime,
872
1005
  loops: runCount,
873
1006
  });
1007
+ if (!nooped) {
1008
+ recordFunctionTiming({
1009
+ writeNodeId: callSpec.writeNodeId,
1010
+ network: callSpec.network,
1011
+ durationMs: Date.now() - callSpec.runAtTime.time,
1012
+ });
1013
+ }
874
1014
  PathFunctionRunner.RUN_FINISH_COUNT++;
875
1015
  }
876
1016
 
@@ -908,11 +1048,12 @@ export class PathFunctionRunner {
908
1048
  eventWrite: true,
909
1049
  doNotStoreWritesAsPredictions: true,
910
1050
  watchFunction() {
1051
+ let network = callSpec.network;
911
1052
  let syncedModule = functionSchema()[callSpec.DomainName].PathFunctionRunner[callSpec.ModuleId];
912
- // Don't clobber the value. I'm pretty sure event writes still read values, so we should still do a synchronization to see if there is an existing result.
913
- let result = doProxyOptions({ forceReadLatest: true }, () => atomicObjectRead(syncedModule.Results[callSpec.CallId]));
1053
+ // Don't clobber the value. I'm pretty sure event writes still read values, so we should still do a synchronization to see if there is an existing result.
1054
+ let result = doProxyOptions({ forceReadLatest: true }, () => atomicObjectRead(syncedModule.Results[network][callSpec.CallId]));
914
1055
  if (result) return;
915
- syncedModule.Results[callSpec.CallId] = atomicObjectWrite({
1056
+ syncedModule.Results[network][callSpec.CallId] = atomicObjectWrite({
916
1057
  error,
917
1058
  lastInternalLoopCount: runCount,
918
1059
  outerLoopCount,
@@ -17,6 +17,8 @@ export function makeReplayCallSpec(record: CaptureRecord): CallSpec {
17
17
  callerMachineId: record.callerMachineId,
18
18
  callerIP: record.callerIP,
19
19
  runAtTime: record.runAtTime,
20
+ // Replays run locally and are never committed, so the network is irrelevant
21
+ network: "",
20
22
  };
21
23
  }
22
24
 
@@ -579,7 +579,6 @@ export function getCallIdOverride(config: {
579
579
  functionId: string;
580
580
  callId: string;
581
581
  args: unknown[];
582
- network?: string;
583
582
  }): string {
584
583
  try {
585
584
  if (querysub) {
@@ -594,7 +593,6 @@ export function getCallIdOverride(config: {
594
593
  remappedPrefix: def.prefix,
595
594
  originalKey: config.callId,
596
595
  routeKey: def.getKey(...config.args),
597
- network: config.network,
598
596
  });
599
597
  } catch (e: any) {
600
598
  console.error(`Error getting call id override for ${config.moduleId}.${config.functionId}, falling back to original call id`, { error: e.stack });
@@ -396,6 +396,16 @@ async function edgeNodeFunction(config: {
396
396
  // to pick a node with low utilization.
397
397
  edgeNodes = shuffle(edgeNodes);
398
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
+
399
409
  // We check multiple at a time, so a node being unresponsive doesn't cause lag
400
410
  let PARALLEL_FACTOR = 3;
401
411
  for (let i = 0; i < edgeNodes.length; i += PARALLEL_FACTOR) {
@@ -42,11 +42,9 @@ export function startEdgeNotifier() {
42
42
  });
43
43
  void (async () => {
44
44
  await delay(1);
45
- let { watchOnRollingUpdate } = await import("../deployManager/machineController");
46
- watchOnRollingUpdate({
47
- callback: (time) => {
48
- void notifyClients(`forcedupdate-at-${time}`, time);
49
- }
45
+ let { watchScheduledShutdown } = await import("../-g-core-values/scheduledShutdown");
46
+ watchScheduledShutdown((time) => {
47
+ void notifyClients(`forcedupdate-at-${time}`, time);
50
48
  });
51
49
  })();
52
50
  }
@@ -191,7 +189,7 @@ const EdgeNotifierClientController = SocketFunction.register(
191
189
 
192
190
  if (!isNode()) {
193
191
  setImmediate(() => {
194
- void Querysub.optionalStartupWait().finally(async () => {
192
+ void Querysub.startupWait().finally(async () => {
195
193
  curHash = await EdgeNotifierController.nodes[SocketFunction.browserNodeId()].watchUpdates();
196
194
  void runRefreshLoop();
197
195
  });
@@ -26,6 +26,7 @@ import { startEdgeNotifier } from "./edgeClientWatcher";
26
26
  import { getGitRefLive, getGitURLLive } from "./git";
27
27
  import { DeployProgress } from "./deployFunctions";
28
28
  import { PromiseObj } from "../promise";
29
+ import type { FunctionRunnerIndex } from "../4-querysub/FunctionRunnerTracking";
29
30
 
30
31
  const UPDATE_POLL_INTERVAL = timeInMinute * 15;
31
32
  const DEAD_NODE_COUNT_THRESHOLD = 15;
@@ -73,6 +74,10 @@ export type EdgeNodeConfig = {
73
74
  bootTime: number;
74
75
  entryPaths: string[];
75
76
  public: boolean;
77
+ // Set once this node has been told it will shut down (scheduled switchover). Clients deprioritize these nodes when picking an edge node.
78
+ scheduledShutdownTime?: number;
79
+ // The function runner index at registration time, so clients have it immediately at startup (they refresh it by polling QuerysubController.getFunctionRunnerIndex).
80
+ functionRunnerIndex?: FunctionRunnerIndex;
76
81
  };
77
82
  let registeredEdgeNode: { host: string; entryPaths: string[] } | boolean | undefined;
78
83
  export async function registerEdgeNode(config: {
@@ -100,19 +105,20 @@ export async function registerEdgeNode(config: {
100
105
  }));
101
106
  await updateEdgeNodesFile();
102
107
  });
103
- const { watchOnRollingUpdate } = await import("../deployManager/machineController");
104
- watchOnRollingUpdate({
105
- callback: async (time) => {
108
+ const { watchScheduledShutdown } = await import("../-g-core-values/scheduledShutdown");
109
+ watchScheduledShutdown((time) => {
110
+ void (async () => {
111
+ // Immediately publish our shutdown time, so clients can deprioritize us when picking an edge node.
112
+ await publishOwnScheduledShutdown(time);
106
113
  let duration = time - Date.now();
107
- // Wait a bit to remove ourself from the edge node list
108
- void delay(duration * 0.2).finally(async () => {
109
- console.log(magenta(`Removing node from edge node list due to rolling update at ${new Date().toLocaleString()}`));
110
- await Promise.all(Array.from(registeredNodePaths).map(async nodePath => {
111
- await edgeNodeStorage.del(nodePath);
112
- }));
113
- await updateEdgeNodesFile();
114
- });
115
- },
114
+ // Wait a bit to fully remove ourself from the edge node list
115
+ await delay(Math.max(0, duration * 0.2));
116
+ console.log(magenta(`Removing node from edge node list due to scheduled shutdown at ${new Date(time).toLocaleString()}`));
117
+ await Promise.all(Array.from(registeredNodePaths).map(async nodePath => {
118
+ await edgeNodeStorage.del(nodePath);
119
+ }));
120
+ await updateEdgeNodesFile();
121
+ })();
116
122
  });
117
123
 
118
124
  await waitForFirstTimeSync();
@@ -154,6 +160,7 @@ export async function registerEdgeNode(config: {
154
160
  bootTime: Date.now(),
155
161
  entryPaths: config.entryPaths,
156
162
  public: isPublic(),
163
+ functionRunnerIndex: await getInitialFunctionRunnerIndex(),
157
164
  };
158
165
 
159
166
  await edgeNodeStorage.set(getNextNodePath(), Buffer.from(JSON.stringify(edgeNodeConfig)));
@@ -204,6 +211,7 @@ const loadEntryPointsByHash = runInSerial(async function loadEntryPointsByHash(c
204
211
  bootTime: Date.now(),
205
212
  entryPaths,
206
213
  public: isPublic(),
214
+ functionRunnerIndex: await getInitialFunctionRunnerIndex(),
207
215
  };
208
216
 
209
217
  await edgeNodeStorage.set(getNextNodePath(), Buffer.from(JSON.stringify(edgeNodeConfig)));
@@ -214,6 +222,25 @@ const loadEntryPointsByHash = runInSerial(async function loadEntryPointsByHash(c
214
222
  onEdgeNodesChanged();
215
223
  });
216
224
 
225
+ async function getInitialFunctionRunnerIndex(): Promise<FunctionRunnerIndex | undefined> {
226
+ const { startFunctionRunnerTracking, getFunctionRunnerIndex } = await import("../4-querysub/FunctionRunnerTracking");
227
+ await startFunctionRunnerTracking();
228
+ return getFunctionRunnerIndex();
229
+ }
230
+
231
+ async function publishOwnScheduledShutdown(time: number) {
232
+ for (let nodePath of Array.from(registeredNodePaths)) {
233
+ let buffer = await edgeNodeStorage.get(nodePath);
234
+ if (!buffer) continue;
235
+ let config: EdgeNodeConfig = JSON.parse(buffer.toString());
236
+ if (config.scheduledShutdownTime === time) continue;
237
+ config.scheduledShutdownTime = time;
238
+ await edgeNodeStorage.set(nodePath, Buffer.from(JSON.stringify(config)));
239
+ getEdgeNodeConfig.clearKey(nodePath);
240
+ }
241
+ await updateEdgeNodesFile();
242
+ }
243
+
217
244
  export const getEdgeNodeConfigURL = lazy(async () => {
218
245
  let { getURL } = await hostArchives({
219
246
  archives: edgeNodeStorage,
@@ -291,6 +318,19 @@ async function updateEdgeNodesFile() {
291
318
  diff = true;
292
319
  console.log(`Detected live hash changed. Updating ${edgeNodeIndexFile}`, { liveHash, prevHash: prevEdgeNodeIndex.liveHash });
293
320
  }
321
+ if (!diff) {
322
+ // Node configs are otherwise immutable, but the scheduled shutdown time is written into them after the fact, and clients need it to avoid picking dying edge nodes.
323
+ let prevShutdownTimes = new Map(prevEdgeNodeIndex.edgeNodes.map(x => [x.nodeId, x.scheduledShutdownTime]));
324
+ for (let nodeFile of edgeNodeFiles) {
325
+ let config = await getEdgeNodeConfig(nodeFile);
326
+ if (!config) continue;
327
+ if (prevShutdownTimes.get(config.nodeId) !== config.scheduledShutdownTime) {
328
+ diff = true;
329
+ console.log(`Detected scheduled shutdown time changed for ${config.nodeId}. Updating ${edgeNodeIndexFile}`);
330
+ break;
331
+ }
332
+ }
333
+ }
294
334
  if (!diff) return;
295
335
 
296
336