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
@@ -4,7 +4,7 @@ import { measureWrap } from "socket-function/src/profiling/measure";
4
4
  import { getOwnMachineId } from "sliftutils/misc/https/certs";
5
5
  import { forceRemoveNode, getOurNodeId, getOurNodeIdAssert } from "../-f-node-discovery/NodeDiscovery";
6
6
  import { Querysub } from "../4-querysub/Querysub";
7
- import { MACHINE_RESYNC_INTERVAL, MachineServiceControllerBase, MachineInfo, ServiceConfig, serviceConfigs, SERVICE_FOLDER, machineInfos, SERVICE_NODE_FILE_NAME, getEffectiveServiceConfigs, recordLaunch } from "./machineSchema";
7
+ import { MACHINE_RESYNC_INTERVAL, MachineServiceControllerBase, MachineInfo, ServiceConfig, ServiceSwitchover, serviceConfigs, pendingServiceConfigs, serviceSwitchovers, SERVICE_FOLDER, machineInfos, SERVICE_NODE_FILE_NAME, getEffectiveServiceConfigs, recordLaunch, DEFAULT_OVERLAP_TIME } from "./machineSchema";
8
8
  import { runPromise } from "../functional/runCommand";
9
9
  import { getExternalIP } from "socket-function/src/networking";
10
10
  import { errorToUndefined, errorToUndefinedSilent } from "../errors";
@@ -21,7 +21,7 @@ import { lazy } from "socket-function/src/caching";
21
21
  import { getGitRefLive, getGitURLLive, setGitRef, rebuildGitFolder, nuclearReclone } from "../4-deploy/git";
22
22
  import { blue, green, magenta, red } from "socket-function/src/formatting/logColors";
23
23
  import { shutdown } from "../diagnostics/periodic";
24
- import { onServiceConfigChange, triggerRollingUpdate } from "./machineController";
24
+ import { onServiceConfigChange, scheduleNodeShutdown } from "./machineController";
25
25
  import { PromiseObj } from "../promise";
26
26
  import path from "path";
27
27
  import { fsExistsAsync } from "../fs";
@@ -132,6 +132,10 @@ const SCREEN_SUFFIX = "-dply";
132
132
  export function getScreenName(config: { serviceKey: string; index: number }): string {
133
133
  return `${config.serviceKey}-${config.index}${SCREEN_SUFFIX}`.replace(/[^a-zA-Z0-9\-_]/g, "_");
134
134
  }
135
+ // The new instances started during a switchover overlap. They cover the service while the canonical screens are restarted with the new config at switch time, and are killed shortly after.
136
+ export function getNextScreenName(config: { serviceKey: string; index: number }): string {
137
+ return getScreenName({ serviceKey: config.serviceKey + "-next", index: config.index });
138
+ }
135
139
 
136
140
  export async function streamScreenOutput(config: {
137
141
  key: string;
@@ -286,42 +290,22 @@ done`);
286
290
  })();
287
291
  }
288
292
 
289
- export async function getRollingServiceInfo(config: {
290
- serviceKey: string;
291
- }): Promise<{ rollingScreenName: string; originalScreenName: string; expiresAt: number }[]> {
292
- let rollingInfo: { rollingScreenName: string; originalScreenName: string; expiresAt: number }[] = [];
293
-
294
- for (let [screenName, rolling] of rollingKeepScreenAlive) {
295
- if (screenName.startsWith(config.serviceKey + "-")) {
296
- rollingInfo.push({
297
- rollingScreenName: rolling.rollingScreenName,
298
- originalScreenName: rolling.originalScreenName,
299
- expiresAt: rolling.pinnedTime + rolling.pinnedDuration,
300
- });
301
- }
302
- }
303
-
304
- return rollingInfo;
305
- }
306
-
307
- export async function killRollingServicesForService(config: {
308
- serviceKey: string;
309
- }): Promise<void> {
310
- let toDelete: string[] = [];
311
-
312
- for (let [screenName, rolling] of rollingKeepScreenAlive) {
313
- if (screenName.startsWith(config.serviceKey + "-")) {
314
- console.log(red(`Explicitly killing rolling screen ${rolling.rollingScreenName} for service ${config.serviceKey}`));
315
- await killScreen({
316
- screenName: rolling.rollingScreenName,
317
- });
318
- rollingScreens.delete(rolling.rollingScreenName);
319
- toDelete.push(screenName);
320
- }
293
+ // screenName => the shutdown time we last successfully told the service's node about
294
+ let notifiedShutdowns = new Map<string, number>();
295
+ async function notifyServiceShutdown(screenName: string, time: number) {
296
+ if (notifiedShutdowns.get(screenName) === time) return;
297
+ let nodeIdFile = os.homedir() + "/" + SERVICE_FOLDER + screenName + "/" + SERVICE_NODE_FILE_NAME;
298
+ if (!await fsExistsAsync(nodeIdFile)) {
299
+ console.log(`No nodeId file found for ${screenName}, cannot notify it of its scheduled shutdown yet (will retry on the next resync)`);
300
+ return;
321
301
  }
322
-
323
- for (let screenName of toDelete) {
324
- rollingKeepScreenAlive.delete(screenName);
302
+ let nodeId = await fs.promises.readFile(nodeIdFile, "utf8");
303
+ try {
304
+ console.log(green(`Notifying ${screenName} (${nodeId}) that it is scheduled to shut down at ${new Date(time).toLocaleString()}`));
305
+ await scheduleNodeShutdown({ nodeId, time });
306
+ notifiedShutdowns.set(screenName, time);
307
+ } catch (e: any) {
308
+ console.warn(`Error notifying ${screenName} (${nodeId}) of its scheduled shutdown (will retry on the next resync): ${e.stack}`);
325
309
  }
326
310
  }
327
311
 
@@ -451,71 +435,13 @@ async function removeOldNodeId(screenName: string) {
451
435
  }
452
436
 
453
437
 
454
- // originalName => info
455
- let rollingKeepScreenAlive = new Map<string, {
456
- rollingScreenName: string;
457
- originalScreenName: string;
458
- pinnedDuration: number;
459
- pinnedTime: number;
460
- }>();
461
- // rollingScreenName => originalScreenName
462
- let rollingScreens = new Map<string, string>();
463
-
464
438
  const runScreenCommand = measureWrap(async function runScreenCommand(config: {
465
439
  screenName: string;
466
440
  command: string;
467
- rollingWindow: number | undefined;
468
441
  }) {
469
442
  let prefix = getTmuxPrefix();
470
443
  let screenName = config.screenName;
471
444
 
472
- if (config.rollingWindow !== undefined) {
473
- let screens = await getScreenState();
474
- let rollingObj = rollingKeepScreenAlive.get(screenName);
475
- if (rollingObj) {
476
- rollingObj.pinnedDuration = config.rollingWindow;
477
- }
478
- let existingScreen = screens.find(x => x.screenName === screenName);
479
- if (existingScreen && !rollingObj && await isScreenRunningProcess(existingScreen.pid)) {
480
- let nodeIdPath = os.homedir() + "/" + SERVICE_FOLDER + screenName + "/" + SERVICE_NODE_FILE_NAME;
481
- let rollingFinalTime = Date.now() + config.rollingWindow;
482
- if (await fsExistsAsync(nodeIdPath)) {
483
- let nodeId = await fs.promises.readFile(nodeIdPath, "utf8");
484
- // REMOVE the nodeId file, so we the node isn't terminated!
485
- await fs.promises.unlink(nodeIdPath);
486
- try {
487
- console.log(green(`Triggering rolling update notification to ${nodeId} for ${screenName} at ${new Date().toLocaleString()}`));
488
- await triggerRollingUpdate({
489
- nodeId,
490
- time: rollingFinalTime,
491
- });
492
- } catch (e: any) {
493
- console.warn(`Error triggering rolling update notification to ${nodeId} for ${screenName}: ${e.stack}`);
494
- }
495
- } else {
496
- console.log(red(`No nodeId file found for ${screenName}, not triggering rolling update notification`));
497
- }
498
- // Stop the pipe-pane, and delete the pipe file
499
- try {
500
- await runPromise(`${prefix}tmux pipe-pane -O -t ${screenName}`);
501
- await fs.promises.unlink(os.homedir() + "/" + SERVICE_FOLDER + screenName + "/pipe.txt");
502
- } catch (e: any) {
503
- console.warn(`Error stopping pipe-pane for ${screenName}: ${e.stack}`);
504
- }
505
-
506
- let rollingScreenName = screenName + "-rolling" + SCREEN_SUFFIX;
507
- console.log(green(`Renaming screen ${screenName} to ${rollingScreenName} for rolling interval ${config.rollingWindow} at ${new Date().toLocaleString()}`));
508
- await runPromise(`${prefix}tmux rename-session -t ${screenName} ${rollingScreenName}`);
509
- rollingScreens.set(rollingScreenName, screenName);
510
- rollingKeepScreenAlive.set(screenName, {
511
- rollingScreenName,
512
- originalScreenName: screenName,
513
- pinnedDuration: config.rollingWindow,
514
- pinnedTime: Date.now(),
515
- });
516
- }
517
- }
518
-
519
445
  try {
520
446
  // Throw if it already exists
521
447
  await runPromise(`${prefix}tmux new -s ${screenName} -d`);
@@ -547,7 +473,6 @@ const runScreenCommand = measureWrap(async function runScreenCommand(config: {
547
473
  await runScreenCommand({
548
474
  screenName,
549
475
  command: config.command,
550
- rollingWindow: config.rollingWindow,
551
476
  });
552
477
  return;
553
478
  }
@@ -660,44 +585,90 @@ const ensureGitSynced = measureWrap(async function ensureGitSynced(config: {
660
585
  let launchesPerService = new Map<string, number>();
661
586
  let lastLaunchedTimePerService = new Map<string, number>();
662
587
 
663
- async function quickIsOutdated() {
664
- let machineId = getOwnMachineId(getDomain());
665
- // If any rolling are ready to restart, we are outdated
666
- for (let [rollingScreenName, rollingInfo] of rollingKeepScreenAlive) {
667
- if (rollingInfo.pinnedTime + rollingInfo.pinnedDuration < Date.now()) {
668
- console.log(red(`Rolling screen ${rollingScreenName} is finished, doing a full resync`));
669
- return true;
588
+ // Promotes any switchovers that have reached their switch time (the pending config becomes live), and returns the switchovers still in flight.
589
+ async function processSwitchovers(): Promise<Map<string, { switchover: ServiceSwitchover; pending: ServiceConfig }>> {
590
+ let active = new Map<string, { switchover: ServiceSwitchover; pending: ServiceConfig }>();
591
+ let switchovers = await serviceSwitchovers.values();
592
+ for (let switchover of switchovers) {
593
+ let pending = await pendingServiceConfigs.get(switchover.serviceId);
594
+ if (!pending) {
595
+ console.warn(`Switchover for ${switchover.serviceId} has no pending config, removing it`);
596
+ await serviceSwitchovers.delete(switchover.serviceId);
597
+ continue;
598
+ }
599
+ if (Date.now() >= switchover.switchTime) {
600
+ // Multiple apply nodes may race to promote, which is fine, as they all write the same pending config.
601
+ console.log(green(`Promoting pending config for ${switchover.serviceId} (switchover scheduled for ${new Date(switchover.switchTime).toLocaleString()})`));
602
+ await serviceConfigs.set(switchover.serviceId, pending);
603
+ await pendingServiceConfigs.delete(switchover.serviceId);
604
+ await serviceSwitchovers.delete(switchover.serviceId);
605
+ continue;
670
606
  }
607
+ let overlapStart = switchover.switchTime - switchover.overlapTime;
608
+ console.log(magenta(`Switchover in flight for ${switchover.serviceId} (${pending.parameters.key}): new instances start at ${new Date(overlapStart).toLocaleString()}${Date.now() >= overlapStart && " (overlap running)" || ""}, old instances shut down at ${new Date(switchover.switchTime).toLocaleString()}`));
609
+ active.set(switchover.serviceId, { switchover, pending });
671
610
  }
672
- let configs = await getEffectiveServiceConfigs();
673
- let relevantConfigs = configs.filter(config => config.machineIds.includes(machineId)).filter(x => x.parameters.deploy);
674
- let screens = await getScreenState();
611
+ return active;
612
+ }
613
+
614
+ // Starts (or verifies) the "-next" instance of a service during a switchover overlap, running the pending config. It covers the service while the canonical screens restart with the new config at switch time, and is killed by the unused-screen cleanup once the switchover promotes.
615
+ async function ensureNextInstance(config: {
616
+ pending: ServiceConfig;
617
+ index: number;
618
+ screenNamesUsed: Set<string>;
619
+ screenStateMap: Map<string, { screenName: string; isProcessRunning: boolean; pid: string }>;
620
+ }) {
621
+ let { pending, index } = config;
622
+ let nextScreenName = getNextScreenName({ serviceKey: pending.parameters.key, index });
623
+ config.screenNamesUsed.add(nextScreenName);
624
+
675
625
  let root = os.homedir() + "/" + SERVICE_FOLDER;
676
- for (let config of relevantConfigs) {
677
- let newParametersString = JSON.stringify(config.parameters);
678
- let matchedCount = config.machineIds.filter(id => id === machineId).length;
679
- for (let i = 0; i < matchedCount; i++) {
680
- let screenName = getScreenName({
681
- serviceKey: config.parameters.key,
682
- index: i,
683
- });
684
- let screen = screens.find(x => x.screenName === screenName);
685
- if (!screen?.isProcessRunning) return true;
686
-
687
- let folder = root + screenName + "/";
688
- await fs.promises.mkdir(folder, { recursive: true });
689
- let parameterPath = folder + "/parameters.json";
690
- if (!await fsExistsAsync(parameterPath)) return true;
691
- let prevParameters = await fs.promises.readFile(parameterPath, "utf8");
692
- if (prevParameters !== newParametersString) return true;
626
+ let folder = root + nextScreenName + "/";
627
+ let gitFolder = folder + "git/";
628
+ await fs.promises.mkdir(gitFolder, { recursive: true });
629
+ if (pending.parameters.gitRef) {
630
+ let prevGitRef = "";
631
+ try {
632
+ prevGitRef = await getGitRefLive(gitFolder);
633
+ } catch { }
634
+ await ensureGitSynced({
635
+ gitFolder,
636
+ repoUrl: pending.parameters.repoUrl,
637
+ gitRef: pending.parameters.gitRef,
638
+ });
639
+ let afterGitRef = await getGitRefLive(gitFolder);
640
+ let nodeModulesMissing = !await fsExistsAsync(gitFolder + "node_modules");
641
+ if (afterGitRef !== prevGitRef || nodeModulesMissing) {
642
+ console.log(green(`Yarn installing for ${magenta(nextScreenName)} (ref ${prevGitRef} -> ${afterGitRef}, nodeModulesMissing=${nodeModulesMissing})`));
643
+ await runPromise(`yarn install --mutex network`, { cwd: gitFolder });
693
644
  }
694
645
  }
695
- return false;
646
+
647
+ let parameterPath = folder + "/parameters.json";
648
+ let prevParameters = "";
649
+ if (await fsExistsAsync(parameterPath)) {
650
+ prevParameters = await fs.promises.readFile(parameterPath, "utf8");
651
+ }
652
+ let newParametersString = JSON.stringify(pending.parameters);
653
+ let sameParameters = prevParameters === newParametersString;
654
+ let screenIsRunning = config.screenStateMap.get(nextScreenName)?.isProcessRunning;
655
+ if (sameParameters && screenIsRunning) {
656
+ console.log(green(`Verified switchover instance ${magenta(nextScreenName)} is running`));
657
+ return;
658
+ }
659
+
660
+ console.log(`Starting switchover instance ${magenta(nextScreenName)}, with ${newParametersString}, isRunning = ${screenIsRunning}, sameParameters = ${sameParameters}`);
661
+ await fs.promises.writeFile(parameterPath, newParametersString);
662
+ await runScreenCommand({
663
+ screenName: nextScreenName,
664
+ command: pending.parameters.command,
665
+ });
696
666
  }
697
667
 
698
668
  const resyncServicesBase = runInSerial(measureWrap(async function resyncServices() {
699
669
  console.log(magenta("Resyncing services"));
700
670
  let machineId = getOwnMachineId(getDomain());
671
+ let activeSwitchovers = await processSwitchovers();
701
672
  let configs = await getEffectiveServiceConfigs();
702
673
  let relevantConfigs = configs.filter(config => config.machineIds.includes(machineId)).filter(x => x.parameters.deploy);
703
674
 
@@ -726,6 +697,23 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
726
697
  try {
727
698
  let folder = root + screenName + "/";
728
699
  screenNamesUsed.add(screenName);
700
+
701
+ let switchoverObj = activeSwitchovers.get(config.serviceId);
702
+ if (switchoverObj) {
703
+ // Tell the running instance when it will be shut down, so it (and everything talking to it) can wind down gracefully.
704
+ await notifyServiceShutdown(screenName, switchoverObj.switchover.switchTime);
705
+ let overlapStart = switchoverObj.switchover.switchTime - switchoverObj.switchover.overlapTime;
706
+ let pendingMatchedCount = switchoverObj.pending.machineIds.filter(id => id === machineId).length;
707
+ if (Date.now() >= overlapStart && switchoverObj.pending.parameters.deploy && i < pendingMatchedCount) {
708
+ await ensureNextInstance({
709
+ pending: switchoverObj.pending,
710
+ index: i,
711
+ screenNamesUsed,
712
+ screenStateMap,
713
+ });
714
+ }
715
+ }
716
+
729
717
  let gitFolder = folder + "git/";
730
718
  await fs.promises.mkdir(gitFolder, { recursive: true });
731
719
  if (config.parameters.gitRef) {
@@ -790,7 +778,6 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
790
778
  await runScreenCommand({
791
779
  screenName,
792
780
  command: config.parameters.command,
793
- rollingWindow: config.parameters.rollingWindow,
794
781
  });
795
782
  await delay(2000);
796
783
  let newScreens = await getScreenState(false);
@@ -824,39 +811,13 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
824
811
 
825
812
  for (let { screenName } of screenState) {
826
813
  if (screenNamesUsed.has(screenName)) continue;
827
- let rollingScreenRemainder = rollingScreens.get(screenName);
828
- if (rollingScreenRemainder) {
829
- let rollingInfo = rollingKeepScreenAlive.get(rollingScreenRemainder);
830
- if (!rollingInfo) {
831
- rollingScreens.delete(screenName);
832
- }
833
- if (rollingInfo && rollingInfo.pinnedTime + rollingInfo.pinnedDuration < Date.now()) {
834
- console.log(green(`Killing rolling screen ${screenName} because it's expired at ${new Date().toLocaleString()}`));
835
- rollingScreens.delete(screenName);
836
- rollingKeepScreenAlive.delete(rollingScreenRemainder);
837
- rollingInfo = undefined;
838
- }
839
- if (rollingInfo) {
840
- console.log(green(`Skipping killing rolling screen ${screenName} because it's still alive at ${new Date().toLocaleString()}. Keeping it alive until ${new Date(rollingInfo.pinnedTime + rollingInfo.pinnedDuration).toLocaleString()}. Was pinned at ${new Date(rollingInfo.pinnedTime).toLocaleString()}, rolling duration is ${formatTime(rollingInfo.pinnedDuration)}`));
841
- continue;
842
- }
843
- }
844
-
845
- let rollingInfo = rollingKeepScreenAlive.get(screenName);
846
- if (rollingInfo) {
847
- console.log(red(`Service ${screenName} was disabled, killing associated rolling screen ${rollingInfo.rollingScreenName}`));
848
- await killScreen({
849
- screenName: rollingInfo.rollingScreenName,
850
- });
851
- rollingScreens.delete(rollingInfo.rollingScreenName);
852
- rollingKeepScreenAlive.delete(screenName);
853
- }
854
-
855
814
  await killScreen({
856
815
  screenName,
857
816
  });
858
817
  }
859
818
 
819
+ scheduleSwitchoverResync(activeSwitchovers);
820
+
860
821
  await machineInfos.set(machineId, machineInfo);
861
822
  console.log(`${magenta(`Resynced ${relevantConfigs.length} services`)}:\n${JSON.stringify(machineInfo, null, 2)}`);
862
823
 
@@ -871,6 +832,31 @@ async function resyncServices() {
871
832
  }
872
833
  }
873
834
 
835
+ // The regular resync poll is too coarse for switchover boundaries (the overlap start and the switch itself), so schedule an extra resync at the next boundary.
836
+ let switchoverResyncTimer: ReturnType<typeof setTimeout> | undefined;
837
+ function scheduleSwitchoverResync(activeSwitchovers: Map<string, { switchover: ServiceSwitchover; pending: ServiceConfig }>) {
838
+ let now = Date.now();
839
+ let next: number | undefined;
840
+ for (let { switchover } of activeSwitchovers.values()) {
841
+ for (let boundary of [switchover.switchTime - switchover.overlapTime, switchover.switchTime]) {
842
+ if (boundary <= now) continue;
843
+ if (next === undefined || boundary < next) {
844
+ next = boundary;
845
+ }
846
+ }
847
+ }
848
+ if (switchoverResyncTimer) {
849
+ clearTimeout(switchoverResyncTimer);
850
+ switchoverResyncTimer = undefined;
851
+ }
852
+ if (next === undefined) return;
853
+ console.log(`Scheduling a resync for the next switchover boundary at ${new Date(next).toLocaleString()}`);
854
+ switchoverResyncTimer = setTimeout(() => {
855
+ switchoverResyncTimer = undefined;
856
+ void resyncServices();
857
+ }, next - now + 1000);
858
+ }
859
+
874
860
 
875
861
  async function isPIDRunning(pid: string) {
876
862
  try {
@@ -5,14 +5,14 @@ import { requiresNetworkTrustHook } from "../-d-trust/NetworkTrust2";
5
5
  import { timeInMinute } from "socket-function/src/misc";
6
6
  import { assertIsManagementUser } from "../diagnostics/managementPages";
7
7
  import { isNode } from "typesafecss";
8
- import { streamScreenOutput, getRollingServiceInfo, killRollingServicesForService } from "./machineApplyMainCode";
8
+ import { streamScreenOutput } from "./machineApplyMainCode";
9
9
  import { Querysub } from "../4-querysub/Querysub";
10
10
  import { getPathStr2 } from "../path";
11
11
  import { getGitURLLive, setGitRef } from "../4-deploy/git";
12
12
  import os from "os";
13
13
  import { runPromise } from "../functional/runCommand";
14
14
  import { getSyncedController } from "../library-components/SyncedController";
15
- import { logErrors } from "../errors";
15
+ import { setScheduledShutdownTime } from "../-g-core-values/scheduledShutdown";
16
16
 
17
17
  const POLL_INTERVAL = timeInMinute * 15;
18
18
 
@@ -38,33 +38,21 @@ export function onServiceConfigChange(callback: () => Promise<void>): () => void
38
38
  }
39
39
 
40
40
 
41
- let rollingUpdateWatchers = new Set<(time: number) => void>();
42
- export function watchOnRollingUpdate(config: {
43
- callback: (time: number) => void;
44
- }) {
45
- rollingUpdateWatchers.add(config.callback);
46
- }
47
- // TODO: PathValueServer should use this to remove itself from the node pool, and then after 50% of the time until time tell it's clients to find a new server to get values from
48
- // TODO: FunctionRunner needs to reshard when it sees this (somehow?)
49
- export async function triggerRollingUpdate(config: {
41
+ /** Tells a service's node that it will be shut down at the given time (due to a scheduled switchover). */
42
+ export async function scheduleNodeShutdown(config: {
50
43
  nodeId: string;
51
44
  time: number;
52
45
  }) {
53
- await OnServiceChange.nodes[config.nodeId].onRollingUpdate(config.time);
46
+ await OnServiceChange.nodes[config.nodeId].scheduleShutdown(config.time);
54
47
  }
55
48
  class OnServiceChangeBase {
56
49
  public async onServiceConfigChange() {
57
50
  await triggerServiceConfigChangeCallbacks();
58
51
  }
59
52
 
60
- public async onRollingUpdate(time: number) {
61
- for (let callback of rollingUpdateWatchers) {
62
- try {
63
- callback(time);
64
- } catch (e) {
65
- logErrors(e);
66
- }
67
- }
53
+ public async scheduleShutdown(time: number) {
54
+ console.log(`Received scheduled shutdown notification (service switchover): shutting down at ${new Date(time).toLocaleString()}`);
55
+ setScheduledShutdownTime(time);
68
56
  }
69
57
  }
70
58
  export const OnServiceChange = SocketFunction.register(
@@ -72,7 +60,7 @@ export const OnServiceChange = SocketFunction.register(
72
60
  () => new OnServiceChangeBase(),
73
61
  () => ({
74
62
  onServiceConfigChange: {},
75
- onRollingUpdate: {},
63
+ scheduleShutdown: {},
76
64
  }),
77
65
  () => ({
78
66
  hooks: [requiresNetworkTrustHook],
@@ -140,36 +128,6 @@ class MachineControllerBase {
140
128
  await runPromise("bash machine-startup.sh", { cwd: os.homedir(), detach: true });
141
129
  }
142
130
 
143
- public async getRollingServiceInfo(config: {
144
- serviceKey: string;
145
- }): Promise<{ rollingScreenName: string; originalScreenName: string; expiresAt: number }[]> {
146
- return await getRollingServiceInfo(config);
147
- }
148
-
149
- public async getRollingServiceInfoFromBrowser(config: {
150
- machineNodeId: string;
151
- serviceKey: string;
152
- }): Promise<{ rollingScreenName: string; originalScreenName: string; expiresAt: number }[]> {
153
- return await MachineController(config.machineNodeId).getRollingServiceInfo.promise({
154
- serviceKey: config.serviceKey,
155
- });
156
- }
157
-
158
- public async killRollingServices(config: {
159
- serviceKey: string;
160
- }): Promise<void> {
161
- await killRollingServicesForService(config);
162
- }
163
-
164
- public async killRollingServicesFromBrowser(config: {
165
- machineNodeId: string;
166
- serviceKey: string;
167
- }): Promise<void> {
168
- await MachineController(config.machineNodeId).killRollingServices.promise({
169
- serviceKey: config.serviceKey,
170
- });
171
- }
172
-
173
131
  }
174
132
 
175
133
  let forwardedCallbacks = new Map<string, string>();
@@ -182,23 +140,16 @@ export const MachineController = getSyncedController(SocketFunction.register(
182
140
  watchOtherScreenOutput: {},
183
141
  deployMachineFromBrowser: {},
184
142
  deployMachine: {},
185
- getRollingServiceInfo: {},
186
- getRollingServiceInfoFromBrowser: {},
187
- killRollingServices: {},
188
- killRollingServicesFromBrowser: {},
189
143
  }),
190
144
  () => ({
191
145
  hooks: [assertIsManagementUser],
192
146
  }),
193
147
  ), {
194
148
  writes: {
195
- deployMachineFromBrowser: ["MachineInfo", "RollingInfo"],
196
- deployMachine: ["MachineInfo", "RollingInfo"],
197
- killRollingServicesFromBrowser: ["RollingInfo"],
198
- },
199
- reads: {
200
- getRollingServiceInfoFromBrowser: ["RollingInfo"],
149
+ deployMachineFromBrowser: ["MachineInfo"],
150
+ deployMachine: ["MachineInfo"],
201
151
  },
152
+ reads: {},
202
153
  });
203
154
 
204
155
  let callbacks = new Map<string, (data: string) => Promise<void>>();
@@ -86,8 +86,8 @@ export type ServiceConfig = {
86
86
  /** Not set by default, so we can setup the configuration before deploying it (or so we can undeploy easily without deleting it) */
87
87
  deploy?: boolean;
88
88
 
89
- /** Keep the instances of the service alive for this long after we start a new one. Only keeps the oldest alive, so multiple updates won't break things (or update the oldest one, so if the oldest one is bad, set this to 0 to make sure it dies immediately). */
90
- rollingWindow?: number;
89
+ /** How long the new and old instances run simultaneously during a switchover. The new instances start at switchTime - overlapTime, and the old instances are shut down at switchTime. This is the default; each switchover can override it. */
90
+ overlapTime?: number;
91
91
  };
92
92
  info: {
93
93
  title: string;
@@ -95,12 +95,26 @@ export type ServiceConfig = {
95
95
  lastUpdatedTime: number;
96
96
  };
97
97
  };
98
+
99
+ export const DEFAULT_OVERLAP_TIME = timeInMinute;
100
+
101
+ export type ServiceSwitchover = {
102
+ serviceId: string;
103
+ /** When the old instances are shut down and the pending config becomes live */
104
+ switchTime: number;
105
+ /** The new instances start at switchTime - overlapTime */
106
+ overlapTime: number;
107
+ scheduledAt: number;
108
+ };
98
109
  export type MachineConfig = {
99
110
  machineId: string;
100
111
  disabled: boolean;
101
112
  };
102
113
  export const machineInfos = archiveJSONT<MachineInfo>(() => nestArchives("machines/machine-heartbeats/", getArchivesBackblaze(getDomain())));
103
114
  export const serviceConfigs = archiveJSONT<ServiceConfig>(() => nestArchives("machines/service-configs/", getArchivesBackblaze(getDomain())));
115
+ // Edits in the UI save here (still on disk), and only become live when a switchover is deployed / scheduled
116
+ export const pendingServiceConfigs = archiveJSONT<ServiceConfig>(() => nestArchives("machines/service-configs-pending/", getArchivesBackblaze(getDomain())));
117
+ export const serviceSwitchovers = archiveJSONT<ServiceSwitchover>(() => nestArchives("machines/service-switchovers/", getArchivesBackblaze(getDomain())));
104
118
  export const machineConfigs = archiveJSONT<MachineConfig>(() => nestArchives("machines/machine-configs/", getArchivesBackblaze(getDomain())));
105
119
 
106
120
  export type LaunchRecord = {
@@ -309,8 +323,63 @@ export class MachineServiceControllerBase {
309
323
 
310
324
  // Remove the service config
311
325
  await serviceConfigs.delete(serviceId);
326
+ await pendingServiceConfigs.delete(serviceId);
327
+ await serviceSwitchovers.delete(serviceId);
312
328
  await this.notifyMachines([], serviceConfig.machineIds);
313
329
  }
330
+
331
+ /** Saves edits WITHOUT deploying them. They only take effect once a switchover is scheduled (scheduleSwitchover). */
332
+ public async setPendingServiceConfigs(configs: ServiceConfig[]) {
333
+ for (let config of configs) {
334
+ let serviceId = config.serviceId;
335
+ config.info.lastUpdatedTime = Date.now();
336
+ let serviceConfig = await serviceConfigs.get(serviceId);
337
+ if (!serviceConfig) {
338
+ throw new Error(`Service ${serviceId} does not exist`);
339
+ }
340
+ await pendingServiceConfigs.set(serviceId, config);
341
+ }
342
+ }
343
+ public async getPendingServiceConfig(serviceId: string): Promise<ServiceConfig | undefined> {
344
+ return await pendingServiceConfigs.get(serviceId);
345
+ }
346
+ public async discardPendingServiceConfig(serviceId: string) {
347
+ await pendingServiceConfigs.delete(serviceId);
348
+ await serviceSwitchovers.delete(serviceId);
349
+ }
350
+
351
+ /** Deploys the pending config: the new instances start at switchTime - overlapTime, the old instances are told they will shut down at switchTime, and at switchTime the pending config becomes live. */
352
+ public async scheduleSwitchover(config: {
353
+ serviceId: string;
354
+ switchTime: number;
355
+ overlapTime: number;
356
+ }) {
357
+ let pending = await pendingServiceConfigs.get(config.serviceId);
358
+ if (!pending) {
359
+ throw new Error(`No pending config for service ${config.serviceId}. Save changes first, then deploy them.`);
360
+ }
361
+ let live = await serviceConfigs.get(config.serviceId);
362
+ if (!live) {
363
+ throw new Error(`Service ${config.serviceId} does not exist`);
364
+ }
365
+ await serviceSwitchovers.set(config.serviceId, {
366
+ serviceId: config.serviceId,
367
+ switchTime: config.switchTime,
368
+ overlapTime: config.overlapTime,
369
+ scheduledAt: Date.now(),
370
+ });
371
+ await this.notifyMachines([...new Set([...live.machineIds, ...pending.machineIds])], []);
372
+ }
373
+ public async cancelSwitchover(serviceId: string) {
374
+ let live = await serviceConfigs.get(serviceId);
375
+ await serviceSwitchovers.delete(serviceId);
376
+ if (live) {
377
+ await this.notifyMachines(live.machineIds, []);
378
+ }
379
+ }
380
+ public async getServiceSwitchover(serviceId: string): Promise<ServiceSwitchover | undefined> {
381
+ return await serviceSwitchovers.get(serviceId);
382
+ }
314
383
  public async getServiceConfigType(): Promise<string> {
315
384
  return extractType(module, "ServiceConfig");
316
385
  }
@@ -538,6 +607,12 @@ export const MachineServiceController = getSyncedController(
538
607
  setMachineConfig: {},
539
608
  addServiceConfig: {},
540
609
  setServiceConfigs: {},
610
+ setPendingServiceConfigs: {},
611
+ getPendingServiceConfig: {},
612
+ discardPendingServiceConfig: {},
613
+ scheduleSwitchover: {},
614
+ cancelSwitchover: {},
615
+ getServiceSwitchover: {},
541
616
  getServiceConfigType: {},
542
617
  deleteServiceConfig: {},
543
618
  getGitInfo: {},
@@ -562,7 +637,11 @@ export const MachineServiceController = getSyncedController(
562
637
  setMachineConfig: ["MachineConfig", "MachineConfigList"],
563
638
  addServiceConfig: ["ServiceConfig", "ServiceConfigList"],
564
639
  setServiceConfigs: ["ServiceConfig"],
565
- deleteServiceConfig: ["ServiceConfig", "ServiceConfigList"],
640
+ setPendingServiceConfigs: ["PendingServiceConfig"],
641
+ discardPendingServiceConfig: ["PendingServiceConfig", "ServiceSwitchover"],
642
+ scheduleSwitchover: ["ServiceSwitchover"],
643
+ cancelSwitchover: ["ServiceSwitchover"],
644
+ deleteServiceConfig: ["ServiceConfig", "ServiceConfigList", "PendingServiceConfig", "ServiceSwitchover"],
566
645
  commitPushService: ["gitInfo", "ServiceConfig"],
567
646
  commitPushAndPublishQuerysub: ["gitInfo", "ServiceConfig"],
568
647
  deployFunctions: ["gitInfo"],
@@ -574,6 +653,8 @@ export const MachineServiceController = getSyncedController(
574
653
  getMachineConfig: ["MachineConfig"],
575
654
  getServiceList: ["ServiceConfigList"],
576
655
  getServiceConfig: ["ServiceConfig"],
656
+ getPendingServiceConfig: ["PendingServiceConfig"],
657
+ getServiceSwitchover: ["ServiceSwitchover"],
577
658
  getGitInfo: ["gitInfo"],
578
659
  getPendingFunctions: ["gitInfo"],
579
660
  getLiveFunctions: ["gitInfo"],