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.
|
|
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.
|
|
75
|
-
"socket-function": "^1.2.
|
|
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
|
|
137
|
-
export function
|
|
138
|
-
return
|
|
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
|
|
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
|
-
|
|
422
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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 {
|
|
598
|
-
let
|
|
599
|
-
config.screenNamesUsed.add(
|
|
600
|
-
let
|
|
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 (
|
|
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:
|
|
618
|
-
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(
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
681
|
-
|
|
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
|
|
686
|
-
|
|
687
|
-
|
|
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
|
|
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
|
-
|
|
692
|
-
if (
|
|
693
|
-
let
|
|
694
|
-
let
|
|
695
|
-
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
|
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
|
-
|
|
732
|
-
//
|
|
733
|
-
|
|
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 (
|
|
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:
|
|
746
|
-
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
|
|
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
|
-
//
|
|
27
|
-
const
|
|
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:
|
|
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:
|
|
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.
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|