querysub 0.536.0 → 0.538.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.536.0",
3
+ "version": "0.538.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",
@@ -71,8 +71,8 @@
71
71
  "node-forge": "https://github.com/sliftist/forge#e618181b469b07bdc70b968b0391beb8ef5fecd6",
72
72
  "pako": "^2.1.0",
73
73
  "peggy": "^5.0.6",
74
- "sliftutils": "^1.7.20",
75
- "socket-function": "^1.2.25",
74
+ "sliftutils": "^1.7.21",
75
+ "socket-function": "^1.2.26",
76
76
  "terser": "^5.31.0",
77
77
  "typenode": "^6.6.1",
78
78
  "typesafecss": "^0.32.0",
@@ -133,9 +133,9 @@ const SCREEN_SUFFIX = "-dply";
133
133
  export function getScreenName(config: { serviceKey: string; index: number }): string {
134
134
  return `${config.serviceKey}-${config.index}${SCREEN_SUFFIX}`.replace(/[^a-zA-Z0-9\-_]/g, "_");
135
135
  }
136
- // The old instances kept alive during a release overlap. The canonical screens restart with the new config at release time; these keep the OLD version serving until releaseTime + overlapTime, and are killed shortly after.
137
- export function getOldScreenName(config: { serviceKey: string; index: number }): string {
138
- return getScreenName({ serviceKey: config.serviceKey + "-old", index: config.index });
136
+ // The new version's screen during a release, in the SAME folder as the canonical screen: created (just echoing when it will start) shortly before releaseTime, started at releaseTime, and renamed to the canonical screen name once the old screen is killed at releaseTime + overlapTime.
137
+ export function getFutureScreenName(canonicalScreenName: string): string {
138
+ return canonicalScreenName.slice(0, -SCREEN_SUFFIX.length) + "-future" + SCREEN_SUFFIX;
139
139
  }
140
140
 
141
141
  export async function streamScreenOutput(config: {
@@ -293,14 +293,8 @@ done`);
293
293
 
294
294
  // screenName => the shutdown time we last successfully told the service's node about
295
295
  let notifiedShutdowns = new Map<string, number>();
296
- async function notifyServiceShutdown(screenName: string, time: number) {
296
+ async function notifyNodeShutdown(screenName: string, nodeId: string, time: number) {
297
297
  if (notifiedShutdowns.get(screenName) === time) return;
298
- let nodeIdFile = os.homedir() + "/" + SERVICE_FOLDER + screenName + "/" + SERVICE_NODE_FILE_NAME;
299
- if (!await fsExistsAsync(nodeIdFile)) {
300
- console.log(`No nodeId file found for ${screenName}, cannot notify it of its scheduled shutdown yet (will retry on the next resync)`);
301
- return;
302
- }
303
- let nodeId = await fs.promises.readFile(nodeIdFile, "utf8");
304
298
  try {
305
299
  console.log(green(`Notifying ${screenName} (${nodeId}) that it is scheduled to shut down at ${new Date(time).toLocaleString()}`));
306
300
  await scheduleNodeShutdown({ nodeId, time });
@@ -309,6 +303,16 @@ async function notifyServiceShutdown(screenName: string, time: number) {
309
303
  console.warn(`Error notifying ${screenName} (${nodeId}) of its scheduled shutdown (will retry on the next resync): ${e.stack}`);
310
304
  }
311
305
  }
306
+ async function notifyServiceShutdown(screenName: string, time: number) {
307
+ if (notifiedShutdowns.get(screenName) === time) return;
308
+ let nodeIdFile = os.homedir() + "/" + SERVICE_FOLDER + screenName + "/" + SERVICE_NODE_FILE_NAME;
309
+ if (!await fsExistsAsync(nodeIdFile)) {
310
+ console.log(`No nodeId file found for ${screenName}, cannot notify it of its scheduled shutdown yet (will retry on the next resync)`);
311
+ return;
312
+ }
313
+ let nodeId = await fs.promises.readFile(nodeIdFile, "utf8");
314
+ await notifyNodeShutdown(screenName, nodeId, time);
315
+ }
312
316
 
313
317
 
314
318
  const getTmuxPrefix = lazy(() => {
@@ -383,6 +387,15 @@ const getLinuxChildPids = measureWrap(async function getLinuxChildPids(pid: stri
383
387
  return obj.filter(x => x.PPID === pid);
384
388
  }
385
389
  });
390
+ // One process-table read for the whole machine — a pid is "running something" when any process has it as a parent. Used by getScreenState so a resync doesn't spawn one ps per screen (which made the release boundary ticks wait on every idle screen first).
391
+ const getAllParentPids = measureWrap(async function getAllParentPids(): Promise<Set<string>> {
392
+ let prefix = getTmuxPrefix();
393
+ let table = os.platform() === "win32"
394
+ ? await runPromise(`${prefix}ps`, { quiet: true })
395
+ : await runPromise(`ps -eo pid,ppid,cmd`, { quiet: true });
396
+ let obj = textTableLineToObj(table) as { PID: string; PPID: string }[];
397
+ return new Set(obj.map(x => x.PPID));
398
+ });
386
399
  const isScreenRunningProcess = measureWrap(async function isScreenRunningProcess(pid: string): Promise<boolean> {
387
400
  try {
388
401
  let bashChildPids = await getLinuxChildPids(pid);
@@ -418,9 +431,10 @@ const getScreenState = measureWrap(async function getScreenState(populateIsProce
418
431
  ;
419
432
 
420
433
  if (populateIsProcessRunning) {
421
- await Promise.all(screenList.map(async x => {
422
- x.isProcessRunning = await isScreenRunningProcess(x.pid);
423
- }));
434
+ let parentPids = await getAllParentPids();
435
+ for (let x of screenList) {
436
+ x.isProcessRunning = parentPids.has(x.pid);
437
+ }
424
438
  }
425
439
 
426
440
  return screenList;
@@ -439,6 +453,8 @@ async function removeOldNodeId(screenName: string) {
439
453
  const runScreenCommand = measureWrap(async function runScreenCommand(config: {
440
454
  screenName: string;
441
455
  command: string;
456
+ // Defaults to the folder derived from screenName; a future screen passes its canonical screen's folder
457
+ folder?: string;
442
458
  }) {
443
459
  let prefix = getTmuxPrefix();
444
460
  let screenName = config.screenName;
@@ -474,13 +490,14 @@ const runScreenCommand = measureWrap(async function runScreenCommand(config: {
474
490
  await runScreenCommand({
475
491
  screenName,
476
492
  command: config.command,
493
+ folder: config.folder,
477
494
  });
478
495
  return;
479
496
  }
480
497
  }
481
498
  }
482
499
  await removeOldNodeId(screenName);
483
- let folder = os.homedir() + "/" + SERVICE_FOLDER + screenName + "/";
500
+ let folder = config.folder || os.homedir() + "/" + SERVICE_FOLDER + screenName + "/";
484
501
  await runPromise(`${prefix}tmux send-keys -t ${screenName} 'cd ${folder}git' Enter`);
485
502
  let command = `#!/bin/bash
486
503
  ${config.command}
@@ -519,31 +536,63 @@ done`);
519
536
  });
520
537
  const killScreen = measureWrap(async function killScreen(config: {
521
538
  screenName: string;
539
+ // During a takeover the folder's nodeId file already belongs to the NEW process, so the old screen's kill must not remove it
540
+ skipNodeIdRemoval?: boolean;
522
541
  }) {
523
542
  console.log(red(`Killing screen ${config.screenName}`));
524
543
  let prefix = getTmuxPrefix();
525
544
  // Try ctrl+c a few times first
545
+ let pid = (await getScreenState(false)).find(x => x.screenName === config.screenName)?.pid;
526
546
  for (let i = 0; i < 5; i++) {
527
- if (!await isScreenRunningProcess(config.screenName)) {
547
+ if (!pid || !await isScreenRunningProcess(pid)) {
528
548
  break;
529
549
  }
530
550
  await runPromise(`${prefix}tmux send-keys -t ${config.screenName} 'C-c' Enter`);
531
551
  await delay(5000);
532
552
  }
533
553
  await runPromise(`${prefix}tmux kill-session -t ${config.screenName}`);
534
- await removeOldNodeId(config.screenName);
554
+ if (!config.skipNodeIdRemoval) {
555
+ await removeOldNodeId(config.screenName);
556
+ }
535
557
  });
536
558
  // When a present repo fails to sync, retry a few times with backoff before deciding it is actually broken — most failures are transient network blips (ssh-keyscan / fetch), and reacting to those by clobbering the checkout is what stranded running services (they kept running but crashed on the next lazy `require("ws")` once node_modules was gone).
537
559
  const GIT_SYNC_MAX_RETRIES = 4;
538
560
  const GIT_SYNC_RETRY_MIN_DELAY = timeInSecond * 2;
539
561
  const GIT_SYNC_RETRY_MAX_DELAY = timeInSecond * 30;
540
562
 
563
+ // Memoized per resync (the map is cleared at each resync's start), so the prewarm pass can fire every folder's check in parallel and the serial loop just awaits the shared promises. The dirty check (`git status`) is a working-tree traversal, so serializing one per instance would delay the release boundary ticks.
564
+ let gitFastPathChecks = new Map<string, Promise<boolean>>();
565
+ function checkGitSyncedAndClean(gitFolder: string, gitRef: string): Promise<boolean> {
566
+ let key = gitFolder + "|" + gitRef;
567
+ let cached = gitFastPathChecks.get(key);
568
+ if (!cached) {
569
+ cached = (async () => {
570
+ try {
571
+ let [currentRef, status] = await Promise.all([
572
+ getGitRefLive(gitFolder),
573
+ runPromise(`git status --porcelain`, { cwd: gitFolder, quiet: true }),
574
+ ]);
575
+ return currentRef === gitRef && !status.trim();
576
+ } catch {
577
+ // No usable repo yet, the full sync handles it
578
+ return false;
579
+ }
580
+ })();
581
+ gitFastPathChecks.set(key, cached);
582
+ }
583
+ return cached;
584
+ }
585
+
541
586
  const ensureGitSynced = measureWrap(async function ensureGitSynced(config: {
542
587
  // Forces the checkout at gitFolder to gitRef. Escalates through increasingly destructive recoveries, but only ever destroys the working tree as an absolute last resort — a network blip must never wipe node_modules out from under a running service.
543
588
  gitFolder: string;
544
589
  repoUrl: string;
545
590
  gitRef: string;
546
591
  }) {
592
+ // Fast path: nothing to do when HEAD already matches (gitRef is normally an immutable commit sha) and the working tree is clean. This is what keeps idle resyncs (and the release boundary ticks queued behind them) fast — the full sync below does network fetches, and a dirty tree needs its stash + reset --hard repair.
593
+ if (await checkGitSyncedAndClean(config.gitFolder, config.gitRef)) {
594
+ return;
595
+ }
547
596
  let hasGit = await fsExistsAsync(config.gitFolder + ".git");
548
597
 
549
598
  // Repo present: sync to the ref, retrying transient failures with backoff. We do NOT clobber the checkout just because a network call blipped.
@@ -586,71 +635,104 @@ const ensureGitSynced = measureWrap(async function ensureGitSynced(config: {
586
635
  let launchesPerService = new Map<string, number>();
587
636
  let lastLaunchedTimePerService = new Map<string, number>();
588
637
 
589
- // Starts (or verifies) the "-old" instance of a service during a release overlap, running the old (no longer live) parameters. It keeps the old version serving while the new canonical instances start up, and is killed by the unused-screen cleanup afterwards (once releaseTime + overlapTime passes we stop ensuring it).
590
- async function ensureOldInstance(config: {
591
- old: ServiceParameters;
592
- aliveWindow: [number, number];
593
- index: number;
638
+ const RELEASE_PREP_LEAD_TIME = timeInSecond * 30;
639
+
640
+ // In-memory only: the old process's nodeId, captured just before the future instance starts (the new process overwrites the shared folder's nodeId file), so the old node can be force-removed when its screen is finally killed. Lost on apply restart, which is fine — node discovery times dead nodes out anyway.
641
+ let takeoverOldNodeIds = new Map<string, string>();
642
+
643
+ // Syncs the canonical folder to the new parameters (git + yarn install + parameters.json) and creates the future screen, which just echos when it will start. Idempotent.
644
+ async function ensureFuturePrepared(config: {
645
+ canonicalScreenName: string;
646
+ next: ServiceParameters;
647
+ releaseTime: number;
594
648
  screenNamesUsed: Set<string>;
595
649
  screenStateMap: Map<string, { screenName: string; isProcessRunning: boolean; pid: string }>;
596
650
  }) {
597
- let { old, index } = config;
598
- let oldScreenName = getOldScreenName({ serviceKey: old.key, index });
599
- config.screenNamesUsed.add(oldScreenName);
600
- let shutdownTime = config.aliveWindow[1];
601
- const syncTimeline = async () => {
602
- let pid = parseInt(config.screenStateMap.get(oldScreenName)?.pid || "") || undefined;
603
- await syncParametersTimelineFiles(os.homedir() + "/" + SERVICE_FOLDER + oldScreenName + "/", [{ pid, aliveWindow: config.aliveWindow, parameters: old }]);
604
- };
605
-
606
- let root = os.homedir() + "/" + SERVICE_FOLDER;
607
- let folder = root + oldScreenName + "/";
651
+ let { canonicalScreenName, next, releaseTime } = config;
652
+ let futureScreenName = getFutureScreenName(canonicalScreenName);
653
+ config.screenNamesUsed.add(futureScreenName);
654
+ let folder = os.homedir() + "/" + SERVICE_FOLDER + canonicalScreenName + "/";
608
655
  let gitFolder = folder + "git/";
609
656
  await fs.promises.mkdir(gitFolder, { recursive: true });
610
- if (old.gitRef) {
657
+ if (next.gitRef) {
611
658
  let prevGitRef = "";
612
659
  try {
613
660
  prevGitRef = await getGitRefLive(gitFolder);
614
661
  } catch { }
615
662
  await ensureGitSynced({
616
663
  gitFolder,
617
- repoUrl: old.repoUrl,
618
- gitRef: old.gitRef,
664
+ repoUrl: next.repoUrl,
665
+ gitRef: next.gitRef,
619
666
  });
620
667
  let afterGitRef = await getGitRefLive(gitFolder);
621
668
  let nodeModulesMissing = !await fsExistsAsync(gitFolder + "node_modules");
622
669
  if (afterGitRef !== prevGitRef || nodeModulesMissing) {
623
- console.log(green(`Yarn installing for ${magenta(oldScreenName)} (ref ${prevGitRef} -> ${afterGitRef}, nodeModulesMissing=${nodeModulesMissing})`));
670
+ console.log(green(`Yarn installing for ${magenta(futureScreenName)} (ref ${prevGitRef} -> ${afterGitRef}, nodeModulesMissing=${nodeModulesMissing})`));
624
671
  await runPromise(`yarn install --mutex network`, { cwd: gitFolder });
625
672
  }
626
673
  }
627
-
628
- let parameterPath = folder + "/parameters.json";
629
- let prevParameters = "";
630
- if (await fsExistsAsync(parameterPath)) {
631
- prevParameters = await fs.promises.readFile(parameterPath, "utf8");
674
+ await fs.promises.writeFile(folder + "parameters.json", JSON.stringify(next));
675
+ if (!config.screenStateMap.has(futureScreenName)) {
676
+ let prefix = getTmuxPrefix();
677
+ await runPromise(`${prefix}tmux new -s ${futureScreenName} -d`);
678
+ await runPromise(`${prefix}tmux send-keys -t ${futureScreenName} 'echo "Waiting to start the new version at ${new Date(releaseTime).toISOString()} (in ${Math.round((releaseTime - Date.now()) / timeInSecond)} seconds)"' Enter`);
679
+ console.log(magenta(`Prepared future screen ${futureScreenName}, starting at ${new Date(releaseTime).toLocaleString()}`));
632
680
  }
633
- let newParametersString = JSON.stringify(old);
634
- let sameParameters = sameRestartParameters(prevParameters, old);
635
- let screenIsRunning = config.screenStateMap.get(oldScreenName)?.isProcessRunning;
636
- if (sameParameters && screenIsRunning) {
637
- if (prevParameters !== newParametersString) {
638
- await fs.promises.writeFile(parameterPath, newParametersString);
639
- }
640
- await notifyServiceShutdown(oldScreenName, shutdownTime);
641
- await syncTimeline();
642
- console.log(green(`Verified release-overlap old instance ${magenta(oldScreenName)} is running`));
681
+ }
682
+
683
+ // Starts (or verifies) the future screen actually running the new version, in the canonical screen's folder. Idempotent.
684
+ async function ensureFutureStarted(config: {
685
+ canonicalScreenName: string;
686
+ next: ServiceParameters;
687
+ releaseTime: number;
688
+ screenNamesUsed: Set<string>;
689
+ screenStateMap: Map<string, { screenName: string; isProcessRunning: boolean; pid: string }>;
690
+ }) {
691
+ let { canonicalScreenName, next } = config;
692
+ let futureScreenName = getFutureScreenName(canonicalScreenName);
693
+ let existing = config.screenStateMap.get(futureScreenName);
694
+ if (existing?.isProcessRunning) {
695
+ config.screenNamesUsed.add(futureScreenName);
696
+ console.log(green(`Verified future instance ${magenta(futureScreenName)} is running`));
643
697
  return;
644
698
  }
699
+ await ensureFuturePrepared(config);
700
+ let folder = os.homedir() + "/" + SERVICE_FOLDER + canonicalScreenName + "/";
701
+ let nodeIdFile = folder + SERVICE_NODE_FILE_NAME;
702
+ // Only the FIRST start can trust the file to hold the old process's nodeId — on a future-instance crash + restart it may already hold the future's own nodeId
703
+ if (!takeoverOldNodeIds.has(canonicalScreenName) && await fsExistsAsync(nodeIdFile)) {
704
+ takeoverOldNodeIds.set(canonicalScreenName, await fs.promises.readFile(nodeIdFile, "utf8"));
705
+ }
706
+ console.log(green(`Starting future instance ${magenta(futureScreenName)} (release time reached)`));
707
+ await runScreenCommand({ screenName: futureScreenName, folder, command: next.command });
708
+ }
645
709
 
646
- console.log(`Starting release-overlap old instance ${magenta(oldScreenName)}, with ${newParametersString}, isRunning = ${screenIsRunning}, sameParameters = ${sameParameters}`);
647
- await fs.promises.writeFile(parameterPath, newParametersString);
648
- await runScreenCommand({
649
- screenName: oldScreenName,
650
- command: old.command,
651
- });
652
- await notifyServiceShutdown(oldScreenName, shutdownTime);
653
- await syncTimeline();
710
+ // Kills the old canonical screen (if any) and renames the future screen to the canonical name. The rename leaves the new process completely untouched, so the takeover is seamless. No-op when there is no future screen.
711
+ async function takeoverFutureScreen(config: {
712
+ canonicalScreenName: string;
713
+ screenNamesUsed: Set<string>;
714
+ screenStateMap: Map<string, { screenName: string; isProcessRunning: boolean; pid: string }>;
715
+ }) {
716
+ let { canonicalScreenName, screenStateMap } = config;
717
+ let futureScreenName = getFutureScreenName(canonicalScreenName);
718
+ let future = screenStateMap.get(futureScreenName);
719
+ if (!future) return;
720
+ // The rename makes the future name vanish, but the caller's original screen list still has it — mark it used so the unused-screen cleanup doesn't try to kill a nonexistent session
721
+ config.screenNamesUsed.add(futureScreenName);
722
+ if (screenStateMap.has(canonicalScreenName)) {
723
+ await killScreen({ screenName: canonicalScreenName, skipNodeIdRemoval: true });
724
+ screenStateMap.delete(canonicalScreenName);
725
+ let oldNodeId = takeoverOldNodeIds.get(canonicalScreenName);
726
+ if (oldNodeId) {
727
+ await forceRemoveNode(oldNodeId);
728
+ }
729
+ }
730
+ takeoverOldNodeIds.delete(canonicalScreenName);
731
+ let prefix = getTmuxPrefix();
732
+ console.log(green(`Renaming future screen ${magenta(futureScreenName)} to ${magenta(canonicalScreenName)} (takeover complete)`));
733
+ await runPromise(`${prefix}tmux rename-session -t ${futureScreenName} ${canonicalScreenName}`);
734
+ screenStateMap.set(canonicalScreenName, { ...future, screenName: canonicalScreenName });
735
+ screenStateMap.delete(futureScreenName);
654
736
  }
655
737
 
656
738
  function sameRestartParameters(prevParametersJSON: string, next: ServiceParameters): boolean {
@@ -677,32 +759,59 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
677
759
  let screenNamesUsed = new Set<string>();
678
760
  let upcomingReleases: { releaseTime: number; overlapTime: number }[] = [];
679
761
  let root = os.homedir() + "/" + SERVICE_FOLDER;
680
- for (let record of relevantConfigs) {
681
- let config = { ...record, parameters: getLiveServiceParameters(record) };
762
+
763
+ let recordStates = relevantConfigs.map(record => {
682
764
  let releaseTime = record.parameters.releaseTime || 0;
683
765
  // The overlap length always comes from the NEWEST parameters, even though it governs how long the OLD instances outlive the release
684
766
  let overlapTime = record.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME;
685
- let isUpcomingRelease = !!releaseTime && Date.now() < releaseTime && !!record.oldParameters;
686
- let isOverlapRunning = !!releaseTime && Date.now() >= releaseTime && Date.now() < releaseTime + overlapTime && !!record.oldParameters;
687
- if (isUpcomingRelease || isOverlapRunning) {
767
+ let killTime = releaseTime + overlapTime;
768
+ // The release dance only exists while there are old parameters to keep alive; past killTime this is just a normal config (the takeover in the canonical loop below is what gets it there)
769
+ let releaseInFlight = !!releaseTime && !!record.oldParameters && Date.now() < killTime;
770
+ let isPrepTime = releaseInFlight && Date.now() >= releaseTime - RELEASE_PREP_LEAD_TIME;
771
+ let isOverlapTime = releaseInFlight && Date.now() >= releaseTime;
772
+ // The canonical screens keep running the OLD parameters (completely untouched) until killTime; the new version runs in "-future" screens in the SAME folders from releaseTime, and takes over the canonical screen names at killTime
773
+ let config = { ...record, parameters: releaseInFlight && record.oldParameters || getLiveServiceParameters(record) };
774
+ return { record, config, releaseTime, overlapTime, killTime, releaseInFlight, isPrepTime, isOverlapTime };
775
+ });
776
+
777
+ // Fire every instance folder's git fast-path check (HEAD ref + dirty scan) in parallel up front, so the serial loop below just awaits the shared promises instead of paying for one working-tree traversal at a time
778
+ gitFastPathChecks.clear();
779
+ for (let { record, config, isPrepTime } of recordStates) {
780
+ const prewarm = (parameters: ServiceParameters) => {
781
+ if (!parameters.gitRef) return;
782
+ let targets = getMachineTargets(parameters).filter(target => target.machineId === machineId);
783
+ for (let i = 0; i < targets.length; i++) {
784
+ let folder = root + getScreenName({ serviceKey: parameters.key, index: i }) + "/";
785
+ void checkGitSyncedAndClean(folder + "git/", parameters.gitRef);
786
+ }
787
+ };
788
+ if (config.parameters.deploy) {
789
+ prewarm(config.parameters);
790
+ }
791
+ if (isPrepTime && record.parameters.deploy) {
792
+ prewarm(record.parameters);
793
+ }
794
+ }
795
+
796
+ for (let { record, config, releaseTime, overlapTime, killTime, releaseInFlight, isPrepTime, isOverlapTime } of recordStates) {
797
+ if (releaseInFlight) {
688
798
  upcomingReleases.push({ releaseTime, overlapTime });
689
- console.log(magenta(`Release in flight for ${record.serviceId} (${record.parameters.key}): new instances start at ${new Date(releaseTime).toLocaleString()}${isOverlapRunning && " (overlap running)" || ""}, old instances shut down at ${new Date(releaseTime + overlapTime).toLocaleString()}`));
799
+ console.log(magenta(`Release in flight for ${record.serviceId} (${record.parameters.key}): the new version starts at ${new Date(releaseTime).toLocaleString()}${isOverlapTime && " (overlap running)" || ""}, the old instances are killed at ${new Date(killTime).toLocaleString()}`));
690
800
  }
691
- // The old-overlap instances follow the OLD parameters' machineIds, so machines being removed by the release still keep the old version alive through the overlap (their canonical screens die at release time)
692
- if (isOverlapRunning && record.oldParameters?.deploy) {
693
- let oldParameters = record.oldParameters;
694
- let oldTargets = getMachineTargets(oldParameters).filter(target => target.machineId === machineId);
695
- for (let i = 0; i < oldTargets.length; i++) {
801
+
802
+ if (isPrepTime && record.parameters.deploy) {
803
+ let nextTargets = getMachineTargets(record.parameters).filter(target => target.machineId === machineId);
804
+ for (let i = 0; i < nextTargets.length; i++) {
805
+ let next = { ...record.parameters, command: applyCommandTemplate(record.parameters.command, nextTargets[i].variables) };
806
+ let canonicalScreenName = getScreenName({ serviceKey: record.parameters.key, index: i });
696
807
  try {
697
- await ensureOldInstance({
698
- old: { ...oldParameters, command: applyCommandTemplate(oldParameters.command, oldTargets[i].variables) },
699
- aliveWindow: [oldParameters.releaseTime || 0, releaseTime + overlapTime],
700
- index: i,
701
- screenNamesUsed,
702
- screenStateMap,
703
- });
808
+ if (isOverlapTime) {
809
+ await ensureFutureStarted({ canonicalScreenName, next, releaseTime, screenNamesUsed, screenStateMap });
810
+ } else {
811
+ await ensureFuturePrepared({ canonicalScreenName, next, releaseTime, screenNamesUsed, screenStateMap });
812
+ }
704
813
  } catch (e: any) {
705
- console.error(`Error starting release-overlap old instance for ${record.serviceId} (${record.parameters.key}): ${e.stack}`);
814
+ console.error(`Error preparing/starting the future instance for ${record.serviceId} (${record.parameters.key}): ${e.stack}`);
706
815
  }
707
816
  }
708
817
  }
@@ -728,22 +837,72 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
728
837
  let folder = root + screenName + "/";
729
838
  screenNamesUsed.add(screenName);
730
839
 
731
- if (isUpcomingRelease) {
732
- // Tell the running instance when it will be shut down, so it (and everything talking to it) can wind down gracefully.
733
- await notifyServiceShutdown(screenName, releaseTime);
840
+ const syncTimeline = async (pid: string | undefined) => {
841
+ // The windows overlap: a version runs from its releaseTime until the NEXT version's releaseTime + overlapTime (the overlap is when both run)
842
+ let entries: ParametersTimelineEntry[] = [{
843
+ pid: parseInt(pid || "") || undefined,
844
+ aliveWindow: [config.parameters.releaseTime || 0, releaseInFlight ? killTime : ALIVE_WINDOW_FOREVER],
845
+ parameters: instanceParameters,
846
+ }];
847
+ if (releaseInFlight) {
848
+ let futureTargets = getMachineTargets(record.parameters).filter(target => target.machineId === machineId);
849
+ if (i < futureTargets.length) {
850
+ let futureScreen = screenStateMap.get(getFutureScreenName(screenName));
851
+ entries.push({
852
+ pid: futureScreen?.isProcessRunning && parseInt(futureScreen.pid) || undefined,
853
+ aliveWindow: [releaseTime, ALIVE_WINDOW_FOREVER],
854
+ parameters: { ...record.parameters, command: applyCommandTemplate(record.parameters.command, futureTargets[i].variables) },
855
+ });
856
+ }
857
+ }
858
+ await syncParametersTimelineFiles(folder, entries);
859
+ };
860
+
861
+ if (releaseInFlight) {
862
+ // Tell the running instance when it will be shut down, so it (and everything talking to it) can wind down gracefully. Once the future instance may have started, the folder's nodeId file can belong to the NEW process, so from then on only the stashed old nodeId is safe to notify.
863
+ if (!isOverlapTime) {
864
+ await notifyServiceShutdown(screenName, killTime);
865
+ } else {
866
+ let oldNodeId = takeoverOldNodeIds.get(screenName);
867
+ if (oldNodeId) {
868
+ await notifyNodeShutdown(screenName, oldNodeId, killTime);
869
+ }
870
+ }
871
+ } else {
872
+ // A finished release's future screen takes over here: kill the old screen, rename the future one — the new process itself is untouched, so the normal logic below just verifies it
873
+ await takeoverFutureScreen({ canonicalScreenName: screenName, screenNamesUsed, screenStateMap });
874
+ }
875
+ if (isPrepTime) {
876
+ // From prep time on, the canonical (old) screen is left completely untouched — the folder holds the NEW code and parameters by now, so the normal compare/sync logic below must not run against the old process
877
+ if (screenStateMap.get(screenName)?.isProcessRunning) {
878
+ await syncTimeline(screenStateMap.get(screenName)?.pid);
879
+ let nodePathId = folder + SERVICE_NODE_FILE_NAME;
880
+ if (await fsExistsAsync(nodePathId)) {
881
+ machineInfo.services[config.serviceId].nodeId = await fs.promises.readFile(nodePathId, "utf8");
882
+ }
883
+ console.log(green(`Verified ${magenta(screenName)} is running (release in flight, leaving it untouched until ${new Date(killTime).toLocaleString()})`));
884
+ continue;
885
+ }
886
+ // The old instance is dead, so there is nothing to keep alive through the overlap — take the new version over immediately
887
+ console.log(magenta(`Old instance ${screenName} is not running during a release, taking the new version over immediately`));
888
+ await takeoverFutureScreen({ canonicalScreenName: screenName, screenNamesUsed, screenStateMap });
889
+ let nextTargets = getMachineTargets(record.parameters).filter(target => target.machineId === machineId);
890
+ if (record.parameters.deploy && record.parameters.key === config.parameters.key && i < nextTargets.length) {
891
+ instanceParameters = { ...record.parameters, command: applyCommandTemplate(record.parameters.command, nextTargets[i].variables) };
892
+ }
734
893
  }
735
894
 
736
895
  let gitFolder = folder + "git/";
737
896
  await fs.promises.mkdir(gitFolder, { recursive: true });
738
- if (config.parameters.gitRef) {
897
+ if (instanceParameters.gitRef) {
739
898
  let prevGitRef = "";
740
899
  try {
741
900
  prevGitRef = await getGitRefLive(gitFolder);
742
901
  } catch { }
743
902
  await ensureGitSynced({
744
903
  gitFolder: gitFolder,
745
- repoUrl: config.parameters.repoUrl,
746
- gitRef: config.parameters.gitRef,
904
+ repoUrl: instanceParameters.repoUrl,
905
+ gitRef: instanceParameters.gitRef,
747
906
  });
748
907
  let afterGitRef = await getGitRefLive(gitFolder);
749
908
  // Reinstall when the ref changed OR node_modules is missing. The latter is the real fix for the "Cannot find module 'ws'" crash: a recovery re-clone can land on the same commit, so a ref-only check would skip the install and leave the service with no node_modules. Restoring node_modules also self-heals an already-running process, since a failed `require` is never cached and the next reconnect re-resolves it.
@@ -763,34 +922,6 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
763
922
  let sameParameters = sameRestartParameters(prevParameters, instanceParameters);
764
923
  let screenIsRunning = screenStateMap.get(screenName)?.isProcessRunning;
765
924
 
766
- const syncTimeline = async (pid: string | undefined) => {
767
- // The windows overlap: a version runs from its releaseTime until the NEXT version's releaseTime + overlapTime (the overlap is when both run)
768
- let entries: ParametersTimelineEntry[] = [{
769
- pid: parseInt(pid || "") || undefined,
770
- aliveWindow: [config.parameters.releaseTime || 0, isUpcomingRelease ? releaseTime + overlapTime : ALIVE_WINDOW_FOREVER],
771
- parameters: instanceParameters,
772
- }];
773
- if (isUpcomingRelease) {
774
- let futureTargets = getMachineTargets(record.parameters).filter(target => target.machineId === machineId);
775
- if (i < futureTargets.length) {
776
- entries.push({
777
- aliveWindow: [releaseTime, ALIVE_WINDOW_FOREVER],
778
- parameters: { ...record.parameters, command: applyCommandTemplate(record.parameters.command, futureTargets[i].variables) },
779
- });
780
- }
781
- }
782
- if (isOverlapRunning && record.oldParameters) {
783
- let oldTargets = getMachineTargets(record.oldParameters).filter(target => target.machineId === machineId);
784
- if (i < oldTargets.length) {
785
- entries.push({
786
- aliveWindow: [record.oldParameters.releaseTime || 0, releaseTime + overlapTime],
787
- parameters: { ...record.oldParameters, command: applyCommandTemplate(record.oldParameters.command, oldTargets[i].variables) },
788
- });
789
- }
790
- }
791
- await syncParametersTimelineFiles(folder, entries);
792
- };
793
-
794
925
  let nodePathId = folder + SERVICE_NODE_FILE_NAME;
795
926
  if (await fsExistsAsync(nodePathId)) {
796
927
  let nodeId = await fs.promises.readFile(nodePathId, "utf8");
@@ -883,13 +1014,13 @@ async function resyncServices() {
883
1014
  }
884
1015
  }
885
1016
 
886
- // The regular resync poll is too coarse for release boundaries (the release itself and the overlap end), so schedule an extra resync at the next boundary.
1017
+ // The regular resync poll is too coarse for release boundaries (the future-screen prep, the release itself, and the kill time), so schedule an extra resync at the next boundary. The boundaries carry no logic of their own — each resync just re-evaluates the current state.
887
1018
  let cancelReleaseResync: (() => void) | undefined;
888
1019
  function scheduleReleaseResync(upcomingReleases: { releaseTime: number; overlapTime: number }[]) {
889
1020
  let now = Date.now();
890
1021
  let next: number | undefined;
891
1022
  for (let release of upcomingReleases) {
892
- for (let boundary of [release.releaseTime, release.releaseTime + release.overlapTime]) {
1023
+ for (let boundary of [release.releaseTime - RELEASE_PREP_LEAD_TIME, release.releaseTime, release.releaseTime + release.overlapTime]) {
893
1024
  if (boundary <= now) continue;
894
1025
  if (next === undefined || boundary < next) {
895
1026
  next = boundary;
@@ -23,10 +23,9 @@ import { URLParam } from "../../library-components/URLParam";
23
23
  import { mainResets } from "../../library-components/urlResetGroups";
24
24
 
25
25
  const ID_CHARS = 8;
26
- // Green means querysub, blue means path value, purple means function runner.
27
- const FUNCTION_COLOR = "hsl(280, 65%, 72%)";
26
+ // The single place these are defined: purple means function runner (networks, function calls, querysub addCalls), blue means path value.
27
+ const FUNCTION_RUNNER_COLOR = "hsl(280, 65%, 72%)";
28
28
  const PATHVALUE_COLOR = "hsl(210, 75%, 68%)";
29
- const QUERYSUB_COLOR = "hsl(140, 60%, 60%)";
30
29
 
31
30
  const PROBE_TIMEOUT_MS = 5000;
32
31
  const RANGE_BAR_WIDTH_PX = 360;
@@ -42,14 +41,13 @@ const trafficWindowParam = new URLParam<number>("rtTrafficWindow", timeInMinute
42
41
  // `perMachine` columns (the IP and machine id) only print on the first row of each machine group.
43
42
  const NODE_TABLE_COLUMNS: { key: keyof NodeRow; label: string; width: number; color?: string; perMachine?: boolean; }[] = [
44
43
  { key: "ip", label: "IP", width: 130, perMachine: true },
45
- { key: "networks", label: "Networks", width: 180, color: QUERYSUB_COLOR, perMachine: true },
44
+ { key: "networks", label: "Networks", width: 180, color: FUNCTION_RUNNER_COLOR, perMachine: true },
46
45
  { key: "machineShort", label: "Machine", width: 96, perMachine: true },
47
46
  { key: "threadPort", label: "Thread:Port", width: 130 },
48
47
  { key: "name", label: "Name", width: 160 },
49
48
  { key: "data", label: "Data", width: 210 },
50
- { key: "fn", label: "Function", width: 320, color: FUNCTION_COLOR },
49
+ { key: "fn", label: "Function", width: 380, color: FUNCTION_RUNNER_COLOR },
51
50
  { key: "pv", label: "Path Values", width: 250, color: PATHVALUE_COLOR },
52
- { key: "qs", label: "Querysub", width: 120, color: QUERYSUB_COLOR },
53
51
  ];
54
52
 
55
53
  type NodeRow = {
@@ -63,7 +61,6 @@ type NodeRow = {
63
61
  data: string;
64
62
  fn: string;
65
63
  pv: string;
66
- qs: string;
67
64
  };
68
65
 
69
66
  type NodeAuthorityInfo = {
@@ -193,7 +190,7 @@ class FunctionRunnersSection extends qreact.Component {
193
190
  <div className={css.vbox(4).pad2(10).fillWidth.bord2(0, 0, 85).hsl(0, 0, 99)}>
194
191
  <div className={css.hbox(10).fillWidth}>
195
192
  <span className={css.boldStyle}>{node.nodeId}</span>
196
- <span className={css.colorhsl(210, 60, 40)}>networks: {node.networks.join(", ")}</span>
193
+ <span className={css.color(FUNCTION_RUNNER_COLOR)}>networks: {node.networks.join(", ")}</span>
197
194
  {!node.isPublic && <span className={css.colorhsl(0, 70, 35)}>(non-public)</span>}
198
195
  <span>latency {formatTime(node.averageLatency)}</span>
199
196
  <span>up for {formatTime(Date.now() - node.startupTime)}</span>
@@ -304,7 +301,7 @@ function machineLabelLines(config: {
304
301
  lines.push({ text: ip });
305
302
  }
306
303
  if (networks.length) {
307
- lines.push({ text: networks.join(" | "), color: QUERYSUB_COLOR });
304
+ lines.push({ text: networks.join(" | "), color: FUNCTION_RUNNER_COLOR });
308
305
  }
309
306
  lines.push({ text: `${machineId.slice(0, ID_CHARS)} ${threads.length} threads` });
310
307
  if (totals.dataSent + totals.dataReceived > 0) {
@@ -314,10 +311,10 @@ function machineLabelLines(config: {
314
311
  lines.push({ text: `↑${formatNumber(totals.valuesSent)}/s ↓${formatNumber(totals.valuesReceived)}/s values`, color: PATHVALUE_COLOR });
315
312
  }
316
313
  if (totals.calls) {
317
- lines.push({ text: `${formatNumber(totals.calls)}/s calls`, color: FUNCTION_COLOR });
314
+ lines.push({ text: `${formatNumber(totals.calls)}/s calls`, color: FUNCTION_RUNNER_COLOR });
318
315
  }
319
316
  if (totals.addCalls) {
320
- lines.push({ text: `${formatNumber(totals.addCalls)}/s addCalls`, color: QUERYSUB_COLOR });
317
+ lines.push({ text: `${formatNumber(totals.addCalls)}/s querysub addCalls`, color: FUNCTION_RUNNER_COLOR });
321
318
  }
322
319
  return lines;
323
320
  }
@@ -357,6 +354,10 @@ function buildNodeRow(config: {
357
354
  fnParts.push(`${formatNumber(traffic?.functionsExecuted ?? 0)}/s calls`);
358
355
  if (!runner.isPublic) fnParts.push("PRIVATE");
359
356
  }
357
+ // A slightly different source than function calls, but still function calls, so it lives in the function column
358
+ if (traffic?.querysubCalls) {
359
+ fnParts.push(`${formatNumber(traffic.querysubCalls)}/s querysub addCalls`);
360
+ }
360
361
 
361
362
  let pvParts: string[] = [];
362
363
  let spec = info?.spec;
@@ -367,7 +368,6 @@ function buildNodeRow(config: {
367
368
  pvParts.push(`↑${formatNumber(traffic.pathValuesSent)}/s ↓${formatNumber(traffic.pathValuesReceived)}/s values`);
368
369
  }
369
370
 
370
- let qs = traffic?.querysubCalls ? `${formatNumber(traffic.querysubCalls)}/s addCalls` : "";
371
371
  return {
372
372
  nodeId,
373
373
  machineId,
@@ -379,7 +379,6 @@ function buildNodeRow(config: {
379
379
  data,
380
380
  fn: fnParts.join(" "),
381
381
  pv: pvParts.join(" "),
382
- qs,
383
382
  };
384
383
  }
385
384