querysub 0.571.0 → 0.572.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
|
@@ -10,7 +10,7 @@ import { getExternalIP } from "socket-function/src/networking";
|
|
|
10
10
|
import { errorToUndefined, errorToUndefinedSilent } from "../errors";
|
|
11
11
|
import { getDomain } from "../config";
|
|
12
12
|
import { formatTime } from "socket-function/src/formatting/format";
|
|
13
|
-
import { sort, timeInMinute, timeInSecond } from "socket-function/src/misc";
|
|
13
|
+
import { sort, timeInMinute, timeInSecond, watchSlowPromise } from "socket-function/src/misc";
|
|
14
14
|
import { isDefined, setPreciseTimeout } from "../misc";
|
|
15
15
|
import { logLoadTime } from "../logModuleLoadTimes";
|
|
16
16
|
import { delay, retryFunctional, runInSerial, runInfinitePoll, runInfinitePollCallAtStart } from "socket-function/src/batching";
|
|
@@ -26,7 +26,7 @@ import { PromiseObj } from "../promise";
|
|
|
26
26
|
import path from "path";
|
|
27
27
|
import { fsExistsAsync } from "../fs";
|
|
28
28
|
import { ALIVE_WINDOW_FOREVER, ParametersTimelineEntry, syncParametersTimelineFiles } from "./parametersTimeline";
|
|
29
|
-
import { getScreenName, getFutureScreenName, isRetiring, getTmuxPrefix, getScreenState, isScreenRunningProcess, getProcessStartTime, getScreenPanePid, ensureProcessRecord, runScreenCommand, killScreen, readServiceNodeId, removeServiceNode,
|
|
29
|
+
import { getScreenName, getFutureScreenName, isRetiring, getTmuxPrefix, getScreenState, isScreenRunningProcess, getProcessStartTime, getScreenPanePid, ensureProcessRecord, runScreenCommand, killScreen, readServiceNodeId, removeServiceNode, takeoverScreenLocked, scheduleTakeover, killScreenNow, withScreenLock } from "./processManager";
|
|
30
30
|
import { ProcessRecord, syncProcessRecords, listProcessRecords } from "./processLogs";
|
|
31
31
|
|
|
32
32
|
|
|
@@ -222,86 +222,65 @@ let lastLaunchedTimePerService = new Map<string, number>();
|
|
|
222
222
|
|
|
223
223
|
const LAUNCH_VERIFY_DELAY = 2000;
|
|
224
224
|
|
|
225
|
-
/**
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
screenName: string;
|
|
229
|
-
/** The slot this belongs to - its own name, or the one it is replacing */
|
|
230
|
-
canonicalScreenName: string;
|
|
231
|
-
/** The instance folder, which is always the canonical screen's folder (an incoming version runs out of the same one) */
|
|
232
|
-
folder: string;
|
|
233
|
-
serviceId: string;
|
|
234
|
-
serviceKey: string;
|
|
235
|
-
index: number;
|
|
236
|
-
machineId: string;
|
|
237
|
-
/** The parameters it runs, with template variables already resolved */
|
|
238
|
-
parameters: ServiceParameters;
|
|
239
|
-
aliveWindow: [number, number];
|
|
240
|
-
/** The version being replaced during an overlap: it must keep running and must not be touched, because the folder already belongs to its replacement. */
|
|
241
|
-
outgoing?: boolean;
|
|
242
|
-
/** On an incoming version, when it takes the canonical name over. Absent means take over as soon as it is up. */
|
|
243
|
-
takeoverAt?: number;
|
|
244
|
-
};
|
|
245
|
-
|
|
246
|
-
/** Every screen that should exist on this machine, worked out purely from the configs and the clock. */
|
|
247
|
-
function getDesiredScreens(configs: ServiceConfig[], machineId: string, root: string): DesiredScreen[] {
|
|
248
|
-
let desired: DesiredScreen[] = [];
|
|
249
|
-
let now = Date.now();
|
|
225
|
+
/** Every instance of every service that belongs to this machine, with its config already resolved for that instance - the command has this entry's template variables substituted, and a version with no entry at this index is marked as deploying nothing here. */
|
|
226
|
+
function getMachineInstances(configs: ServiceConfig[], machineId: string): { config: ServiceConfig; index: number }[] {
|
|
227
|
+
let instances: { config: ServiceConfig; index: number }[] = [];
|
|
250
228
|
for (let config of configs) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
let
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
...extra,
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
};
|
|
278
|
-
|
|
279
|
-
if (releaseInFlight && config.oldParameters) {
|
|
280
|
-
// The old version keeps running under the canonical name until its overlap runs out
|
|
281
|
-
add(config.oldParameters, { outgoing: true, aliveWindow: [config.oldParameters.releaseTime || 0, killTime] });
|
|
282
|
-
// The new version runs alongside it from the release, and takes the canonical name at killTime
|
|
283
|
-
if (now >= releaseTime) {
|
|
284
|
-
add(config.parameters, { takeoverAt: killTime, aliveWindow: [releaseTime, ALIVE_WINDOW_FOREVER] });
|
|
285
|
-
}
|
|
286
|
-
} else {
|
|
287
|
-
add(getLiveServiceParameters(config), {});
|
|
229
|
+
// A release can add or remove instances, so the count is whichever version asks for the most
|
|
230
|
+
let count = 0;
|
|
231
|
+
for (let parameters of [config.parameters, config.oldParameters]) {
|
|
232
|
+
if (!parameters) continue;
|
|
233
|
+
count = Math.max(count, getMachineTargets(parameters).filter(x => x.machineId === machineId).length);
|
|
234
|
+
}
|
|
235
|
+
for (let index = 0; index < count; index++) {
|
|
236
|
+
const resolve = (parameters: ServiceParameters) => {
|
|
237
|
+
let target = getMachineTargets(parameters).filter(x => x.machineId === machineId)[index];
|
|
238
|
+
return {
|
|
239
|
+
...parameters,
|
|
240
|
+
command: target && applyCommandTemplate(parameters.command, target.variables) || parameters.command,
|
|
241
|
+
deploy: !!parameters.deploy && !!target,
|
|
242
|
+
};
|
|
243
|
+
};
|
|
244
|
+
instances.push({
|
|
245
|
+
index,
|
|
246
|
+
config: {
|
|
247
|
+
...config,
|
|
248
|
+
parameters: resolve(config.parameters),
|
|
249
|
+
oldParameters: config.oldParameters && resolve(config.oldParameters),
|
|
250
|
+
},
|
|
251
|
+
});
|
|
288
252
|
}
|
|
289
253
|
}
|
|
290
|
-
|
|
291
|
-
for (let screen of desired) {
|
|
292
|
-
if (screen.takeoverAt === undefined) continue;
|
|
293
|
-
screen.screenName = getFutureScreenName(screen.canonicalScreenName);
|
|
294
|
-
}
|
|
295
|
-
return desired;
|
|
254
|
+
return instances;
|
|
296
255
|
}
|
|
297
256
|
|
|
298
|
-
/** Puts one
|
|
257
|
+
/** Puts one instance of a service into the state its configuration describes: the folder synced, the process running what it should be, and the records and timeline saying so. Returns the screens it is using, so anything nobody claimed can be killed. Everything about one screen happens under its lock, so a resync and a scheduled takeover can never interleave. */
|
|
299
258
|
async function ensureScreen(config: {
|
|
300
|
-
|
|
259
|
+
config: ServiceConfig;
|
|
260
|
+
index: number;
|
|
301
261
|
machineInfo: MachineInfo;
|
|
302
|
-
}): Promise<
|
|
303
|
-
let {
|
|
304
|
-
let
|
|
262
|
+
}): Promise<string[]> {
|
|
263
|
+
let { machineInfo, index } = config;
|
|
264
|
+
let machineId = machineInfo.machineId;
|
|
265
|
+
let serviceId = config.config.serviceId;
|
|
266
|
+
let serviceConfig = config.config;
|
|
267
|
+
|
|
268
|
+
let releaseTime = serviceConfig.parameters.releaseTime || 0;
|
|
269
|
+
// The overlap length always comes from the NEWEST parameters, even though it governs how long the OLD instances outlive the release
|
|
270
|
+
let killTime = releaseTime + (serviceConfig.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME);
|
|
271
|
+
// Only in flight while there is an old version to keep alive, and only until its overlap runs out
|
|
272
|
+
let releaseInFlight = !!releaseTime && !!serviceConfig.oldParameters && Date.now() < killTime;
|
|
273
|
+
|
|
274
|
+
// What should be running under the canonical name now, and the version waiting to take it over
|
|
275
|
+
let current: ServiceParameters | undefined = releaseInFlight && serviceConfig.oldParameters || getLiveServiceParameters(serviceConfig);
|
|
276
|
+
if (!current?.deploy) current = undefined;
|
|
277
|
+
let incoming: ServiceParameters | undefined = releaseInFlight && Date.now() >= releaseTime && serviceConfig.parameters || undefined;
|
|
278
|
+
if (!incoming?.deploy) incoming = undefined;
|
|
279
|
+
let canonicalScreenName = getScreenName({ serviceKey: (current || incoming || serviceConfig.parameters).key, index });
|
|
280
|
+
let futureScreenName = getFutureScreenName(canonicalScreenName);
|
|
281
|
+
let folder = os.homedir() + "/" + SERVICE_FOLDER + canonicalScreenName + "/";
|
|
282
|
+
// Returned even when the work below throws, so a transient failure never makes the sweep kill a screen we still want
|
|
283
|
+
let usedScreenNames = [canonicalScreenName, futureScreenName];
|
|
305
284
|
|
|
306
285
|
let launchCount = launchesPerService.get(serviceId) || 0;
|
|
307
286
|
let lastLaunchedTime = lastLaunchedTimePerService.get(serviceId) || 0;
|
|
@@ -313,47 +292,70 @@ async function ensureScreen(config: {
|
|
|
313
292
|
};
|
|
314
293
|
|
|
315
294
|
try {
|
|
316
|
-
await withScreenLock(canonicalScreenName, async () => {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
return;
|
|
295
|
+
await watchSlowPromise(`ensureScreen|${canonicalScreenName}`, withScreenLock(canonicalScreenName, async () => {
|
|
296
|
+
/* Three facts, each moving ? -> known -> correct as we go:
|
|
297
|
+
EXISTS - which of the two screens are there
|
|
298
|
+
VERSION - which parameters they were started with
|
|
299
|
+
RUNNING - whether a process is actually alive in them
|
|
300
|
+
Every step below says where those three stand, so it is always clear what we are still assuming.
|
|
301
|
+
START: EXISTS ? VERSION ? RUNNING ?
|
|
302
|
+
*/
|
|
303
|
+
let parameters = incoming || current;
|
|
304
|
+
let screenName = incoming && futureScreenName || canonicalScreenName;
|
|
305
|
+
let takeoverAt = incoming && killTime || undefined;
|
|
306
|
+
|
|
307
|
+
// A screen we do not expect holds something we never asked for, so it goes at once - nothing here is worth winding down. Each name is judged on its own: a release that turns deploy on has an incoming version and nothing that should be running under the canonical name yet.
|
|
308
|
+
// AFTER: EXISTS correct in the "nothing extra" sense. A screen that SHOULD exist may still be missing, which is RUNNING's problem below.
|
|
309
|
+
if (!incoming) {
|
|
310
|
+
await killScreenNow(futureScreenName, "there is no release in flight, so nothing should be waiting to take over");
|
|
333
311
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
await fs.promises.mkdir(gitFolder, { recursive: true });
|
|
337
|
-
if (parameters.gitRef) {
|
|
338
|
-
let prevGitRef = "";
|
|
339
|
-
try {
|
|
340
|
-
prevGitRef = await getGitRefLive(gitFolder);
|
|
341
|
-
} catch { }
|
|
342
|
-
await ensureGitSynced({ gitFolder, repoUrl: parameters.repoUrl, gitRef: parameters.gitRef });
|
|
343
|
-
let afterGitRef = await getGitRefLive(gitFolder);
|
|
344
|
-
// Reinstall when the ref changed OR node_modules is missing. A recovery re-clone can land on the same commit, so a ref-only check would leave the service with no node_modules.
|
|
345
|
-
let nodeModulesMissing = !await fsExistsAsync(gitFolder + "node_modules");
|
|
346
|
-
if (afterGitRef !== prevGitRef || nodeModulesMissing) {
|
|
347
|
-
console.log(green(`Yarn installing for ${magenta(screenName)} (ref ${prevGitRef} -> ${afterGitRef}, nodeModulesMissing=${nodeModulesMissing})`));
|
|
348
|
-
await runPromise(`yarn install --mutex network`, { cwd: gitFolder });
|
|
349
|
-
}
|
|
312
|
+
if (!current) {
|
|
313
|
+
await killScreenNow(canonicalScreenName, "nothing is supposed to be serving this name yet");
|
|
350
314
|
}
|
|
315
|
+
if (!current && !incoming) return;
|
|
351
316
|
|
|
317
|
+
// Both screens run out of one folder, so its parameters.json is the ONE version they were last started from - it cannot tell the two screens apart.
|
|
318
|
+
// AFTER: VERSION known. Anything it does not match, including a version we no longer recognize, is handled by starting the right one further down - which keeps whatever is up serving until the replacement is verified, instead of killing it now and serving nothing while we sync and start.
|
|
352
319
|
let parameterPath = folder + "parameters.json";
|
|
353
320
|
let prevParameters = "";
|
|
354
321
|
if (await fsExistsAsync(parameterPath)) {
|
|
355
322
|
prevParameters = await fs.promises.readFile(parameterPath, "utf8");
|
|
356
323
|
}
|
|
324
|
+
|
|
325
|
+
// AFTER: RUNNING known for every name that can still have anything in it. A screen can exist with nothing alive in it, and one that should exist can be missing entirely - both read as not up, and both are fixed the same way. Without an incoming version the future name was just killed, so there is nothing left to ask about it.
|
|
326
|
+
let canonicalPid = await getScreenPanePid(canonicalScreenName);
|
|
327
|
+
let canonicalUp = !!canonicalPid && await isScreenRunningProcess(canonicalPid);
|
|
328
|
+
let futurePid = incoming && await getScreenPanePid(futureScreenName) || undefined;
|
|
329
|
+
let futureUp = !!futurePid && await isScreenRunningProcess(futurePid);
|
|
330
|
+
|
|
331
|
+
// SMOOTH PATH: this is what a deploy looks like from the canonical name's side. The version being replaced is already correct - it keeps running untouched until its overlap ends, so this name is settled and only the future one is left to converge. It needs its record kept straight, and to hear when it goes down.
|
|
332
|
+
// AFTER (canonical, during a release): EXISTS correct VERSION correct RUNNING correct
|
|
333
|
+
if (incoming && current && !canonicalUp) {
|
|
334
|
+
console.log(red(`Nothing is running as ${canonicalScreenName}, which should be serving until ${new Date(killTime).toISOString()} while its replacement starts`));
|
|
335
|
+
}
|
|
336
|
+
if (incoming && current && canonicalUp && canonicalPid) {
|
|
337
|
+
await ensureProcessRecord({
|
|
338
|
+
screenName: canonicalScreenName,
|
|
339
|
+
folder,
|
|
340
|
+
panePid: canonicalPid,
|
|
341
|
+
record: { serviceId, serviceKey: current.key, index, machineId, parameters: current },
|
|
342
|
+
});
|
|
343
|
+
void (async () => {
|
|
344
|
+
let nodeId = await readServiceNodeId(folder);
|
|
345
|
+
if (nodeId) {
|
|
346
|
+
await notifyNodeShutdown(canonicalScreenName, nodeId, killTime);
|
|
347
|
+
}
|
|
348
|
+
})();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// From here on this is about ONE screen - the future one during a release, the canonical one otherwise. Everything else is already correct.
|
|
352
|
+
// STATE: EXISTS correct VERSION known RUNNING known
|
|
353
|
+
if (!parameters) return;
|
|
354
|
+
let panePid = incoming && futurePid || canonicalPid;
|
|
355
|
+
let isUp = incoming && futureUp || canonicalUp;
|
|
356
|
+
let aliveWindow: [number, number] = [parameters.releaseTime || 0, ALIVE_WINDOW_FOREVER];
|
|
357
|
+
let record = { serviceId, serviceKey: parameters.key, index, machineId, parameters };
|
|
358
|
+
|
|
357
359
|
let newParametersString = JSON.stringify(parameters);
|
|
358
360
|
|
|
359
361
|
let nodePathId = folder + SERVICE_NODE_FILE_NAME;
|
|
@@ -361,28 +363,62 @@ async function ensureScreen(config: {
|
|
|
361
363
|
machineInfo.services[serviceId].nodeId = await fs.promises.readFile(nodePathId, "utf8");
|
|
362
364
|
}
|
|
363
365
|
|
|
366
|
+
// Already running this exact version, so nothing needs starting - this is the state we are trying to reach
|
|
367
|
+
// AFTER: EXISTS correct VERSION correct RUNNING correct, and the takeover below settles which name it answers to
|
|
364
368
|
if (isUp && panePid && sameRestartParameters(prevParameters, parameters)) {
|
|
365
369
|
if (prevParameters !== newParametersString) {
|
|
366
370
|
await fs.promises.writeFile(parameterPath, newParametersString);
|
|
367
371
|
}
|
|
368
|
-
// We may not have
|
|
369
|
-
await ensureProcessRecord({ screenName, folder, panePid, record
|
|
370
|
-
await syncParametersTimelineFiles(folder, [{ pid: parseInt(panePid) || undefined, aliveWindow
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
372
|
+
// We may not have started this one (we restarted, or it predates process records), so make sure it is recorded before leaving it alone
|
|
373
|
+
await ensureProcessRecord({ screenName, folder, panePid, record });
|
|
374
|
+
await syncParametersTimelineFiles(folder, [{ pid: parseInt(panePid) || undefined, aliveWindow, parameters }]);
|
|
375
|
+
if (takeoverAt !== undefined) {
|
|
376
|
+
if (!canonicalUp) {
|
|
377
|
+
// The version this one is waiting to replace is gone, so there is no overlap left to serve - take the name now instead of sitting under the future one
|
|
378
|
+
console.log(red(`Nothing is running as ${canonicalScreenName}, so ${screenName} takes it over now instead of waiting until ${new Date(takeoverAt).toISOString()}`));
|
|
379
|
+
await takeoverScreenLocked({ canonicalScreenName, folder });
|
|
380
|
+
} else {
|
|
381
|
+
// SMOOTH PATH: both versions are up, the old one serving and the new one waiting. A restart loses the timer that retires the old one, so it is put back every time we see it still waiting.
|
|
382
|
+
scheduleTakeover({ canonicalScreenName, folder, killTime: takeoverAt });
|
|
383
|
+
}
|
|
374
384
|
}
|
|
375
385
|
console.log(green(`Verified ${magenta(screenName)} is running`));
|
|
376
386
|
return;
|
|
377
387
|
}
|
|
378
388
|
|
|
379
|
-
|
|
389
|
+
// RUNNING is wrong - either nothing is alive, or what is alive is the wrong version. Starting is the only thing that fixes it.
|
|
390
|
+
if (!isUp) {
|
|
391
|
+
// Nothing should ever stop on its own: either it crashed, or it never came up
|
|
392
|
+
console.log(red(`Nothing is running as ${screenName}, starting it: ${newParametersString}`));
|
|
393
|
+
} else {
|
|
394
|
+
// SMOOTH PATH: the previous version is up and this replaces it with a new one, which is exactly what a deploy is
|
|
395
|
+
console.log(green(`Starting a new version as ${magenta(screenName)}, replacing the one running there: ${newParametersString}`));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Only now does the folder have to hold this version, which is why the git and yarn work sits behind the check above rather than running on every resync
|
|
399
|
+
let gitFolder = folder + "git/";
|
|
400
|
+
await fs.promises.mkdir(gitFolder, { recursive: true });
|
|
401
|
+
if (parameters.gitRef) {
|
|
402
|
+
let prevGitRef = "";
|
|
403
|
+
try {
|
|
404
|
+
prevGitRef = await getGitRefLive(gitFolder);
|
|
405
|
+
} catch { }
|
|
406
|
+
await ensureGitSynced({ gitFolder, repoUrl: parameters.repoUrl, gitRef: parameters.gitRef });
|
|
407
|
+
let afterGitRef = await getGitRefLive(gitFolder);
|
|
408
|
+
// Reinstall when the ref changed OR node_modules is missing. A recovery re-clone can land on the same commit, so a ref-only check would leave the service with no node_modules.
|
|
409
|
+
let nodeModulesMissing = !await fsExistsAsync(gitFolder + "node_modules");
|
|
410
|
+
if (afterGitRef !== prevGitRef || nodeModulesMissing) {
|
|
411
|
+
console.log(green(`Yarn installing for ${magenta(screenName)} (ref ${prevGitRef} -> ${afterGitRef}, nodeModulesMissing=${nodeModulesMissing})`));
|
|
412
|
+
await runPromise(`yarn install --mutex network`, { cwd: gitFolder });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
380
416
|
await fs.promises.writeFile(parameterPath, newParametersString);
|
|
381
417
|
void recordLaunch({
|
|
382
418
|
serviceId,
|
|
383
|
-
serviceKey:
|
|
419
|
+
serviceKey: parameters.key,
|
|
384
420
|
screenName: canonicalScreenName,
|
|
385
|
-
machineId
|
|
421
|
+
machineId,
|
|
386
422
|
reason: isUp && "update" || "crashed",
|
|
387
423
|
time: Date.now(),
|
|
388
424
|
});
|
|
@@ -390,17 +426,17 @@ async function ensureScreen(config: {
|
|
|
390
426
|
// The node the version being replaced registered as, read before the launch overwrites the file with the new process's
|
|
391
427
|
let replacedNodeId = await readServiceNodeId(folder);
|
|
392
428
|
|
|
393
|
-
//
|
|
394
|
-
let launchScreenName =
|
|
429
|
+
// Always started under the future name and renamed over the canonical one, releases and plain restarts alike: it is up before anything touches what it replaces, and it always gets a brand new pane - which is what makes it a distinct process with its own log.
|
|
430
|
+
let launchScreenName = futureScreenName;
|
|
395
431
|
let launchedPid = await runScreenCommand({
|
|
396
432
|
screenName: launchScreenName,
|
|
397
433
|
folder,
|
|
398
434
|
command: parameters.command,
|
|
399
|
-
record
|
|
435
|
+
record,
|
|
400
436
|
});
|
|
401
|
-
//
|
|
402
|
-
if (
|
|
403
|
-
scheduleTakeover({ canonicalScreenName, folder, killTime:
|
|
437
|
+
// SMOOTH PATH: the command is running as of the line above, so the overlap the version it replaces gets is measured from here
|
|
438
|
+
if (takeoverAt !== undefined) {
|
|
439
|
+
scheduleTakeover({ canonicalScreenName, folder, killTime: takeoverAt, replacedNodeId });
|
|
404
440
|
}
|
|
405
441
|
|
|
406
442
|
await delay(LAUNCH_VERIFY_DELAY);
|
|
@@ -411,26 +447,28 @@ async function ensureScreen(config: {
|
|
|
411
447
|
throw new Error(`${logs}\n\nService ${magenta(launchScreenName)} is not running after starting. Trying again in ${formatTime(MACHINE_RESYNC_INTERVAL)}, or on next change. Last logs above.`);
|
|
412
448
|
}
|
|
413
449
|
console.log(green(`${magenta(launchScreenName)} is verified to be running (pid ${launchedPid})`));
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
450
|
+
// Take the canonical name now unless something is still serving under it - waiting out an overlap that nothing is serving would leave the name empty for no reason. SMOOTH PATH during a release is neither: the version being replaced is still up, so this one waits under the future name until its timer fires.
|
|
451
|
+
// AFTER: EXISTS correct VERSION correct RUNNING correct, under the canonical name, or under the future one with the version it is replacing still serving
|
|
452
|
+
if (takeoverAt === undefined || !canonicalUp) {
|
|
453
|
+
await takeoverScreenLocked({ canonicalScreenName, folder, replacedNodeId });
|
|
417
454
|
}
|
|
418
|
-
await syncParametersTimelineFiles(folder, [{ pid: parseInt(launchedPanePid) || undefined, aliveWindow
|
|
455
|
+
await syncParametersTimelineFiles(folder, [{ pid: parseInt(launchedPanePid) || undefined, aliveWindow, parameters }]);
|
|
419
456
|
|
|
420
457
|
launchCount++;
|
|
421
458
|
lastLaunchedTime = Date.now();
|
|
422
|
-
});
|
|
459
|
+
}));
|
|
423
460
|
} catch (e: any) {
|
|
424
461
|
// Counted on failure too, as a service that keeps crashing is only visible as a launch count climbing faster than everyone else's
|
|
425
462
|
launchCount++;
|
|
426
463
|
lastLaunchedTime = Date.now();
|
|
427
|
-
console.error(`Error resyncing ${magenta(
|
|
464
|
+
console.error(`Error resyncing ${magenta(canonicalScreenName)} (${serviceId}): ${e.stack}`);
|
|
428
465
|
machineInfo.services[serviceId].errorFromLastRun = e.stack;
|
|
429
466
|
}
|
|
430
467
|
machineInfo.services[serviceId].totalTimesLaunched = launchCount;
|
|
431
468
|
machineInfo.services[serviceId].lastLaunchedTime = lastLaunchedTime;
|
|
432
469
|
lastLaunchedTimePerService.set(serviceId, lastLaunchedTime);
|
|
433
470
|
launchesPerService.set(serviceId, launchCount);
|
|
471
|
+
return usedScreenNames;
|
|
434
472
|
}
|
|
435
473
|
|
|
436
474
|
function sameRestartParameters(prevParametersJSON: string, next: ServiceParameters): boolean {
|
|
@@ -464,22 +502,17 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
|
|
|
464
502
|
now: Date.now(),
|
|
465
503
|
});
|
|
466
504
|
let root = os.homedir() + "/" + SERVICE_FOLDER;
|
|
467
|
-
let
|
|
505
|
+
let instances = getMachineInstances(relevantConfigs, machineId);
|
|
468
506
|
|
|
469
|
-
// The fast-path results are memoized per resync, so a folder
|
|
507
|
+
// The fast-path results are memoized per resync, so a folder shared by two instances is only traversed once
|
|
470
508
|
gitFastPathChecks.clear();
|
|
471
509
|
|
|
472
|
-
// Every
|
|
473
|
-
await Promise.all(
|
|
510
|
+
// Every instance converges concurrently. Each has its own folder, its own records and its own lock, and yarn install takes a network mutex, so there is nothing to serialize behind - one slow git sync or shutdown no longer delays everything after it.
|
|
511
|
+
let usedScreenNames = new Set((await Promise.all(instances.map(instance => ensureScreen({ ...instance, machineInfo })))).flat());
|
|
474
512
|
|
|
475
|
-
//
|
|
476
|
-
let wantedNames = new Set<string>();
|
|
477
|
-
for (let desired of desiredScreens) {
|
|
478
|
-
wantedNames.add(desired.screenName);
|
|
479
|
-
wantedNames.add(desired.canonicalScreenName);
|
|
480
|
-
}
|
|
513
|
+
// Anything no instance claimed is not supposed to exist
|
|
481
514
|
for (let { screenName } of screenState) {
|
|
482
|
-
if (
|
|
515
|
+
if (usedScreenNames.has(screenName)) continue;
|
|
483
516
|
// Already being taken down. One left over from a restart is not, so it still gets cleaned up here.
|
|
484
517
|
if (isRetiring(screenName)) continue;
|
|
485
518
|
// Detached, so a screen that takes its full grace period to go down doesn't hold up the end of the resync
|
|
@@ -494,9 +527,6 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
|
|
|
494
527
|
})();
|
|
495
528
|
}
|
|
496
529
|
|
|
497
|
-
// The takeover is scheduled the moment the replacement starts, so this only exists to catch up after a restart loses those timers
|
|
498
|
-
scheduleReleaseResync(desiredScreens);
|
|
499
|
-
|
|
500
530
|
await machineInfos.set(machineId, machineInfo);
|
|
501
531
|
console.log(`${magenta(`Resynced ${relevantConfigs.length} services`)}:\n${JSON.stringify(machineInfo, null, 2)}`);
|
|
502
532
|
|
|
@@ -511,34 +541,6 @@ async function resyncServices() {
|
|
|
511
541
|
}
|
|
512
542
|
}
|
|
513
543
|
|
|
514
|
-
// The regular resync poll is too coarse for a takeover boundary, so schedule an extra resync at the next one. It carries no logic of its own — the resync just re-evaluates the current state, which is what recovers a takeover whose timer we lost by restarting.
|
|
515
|
-
let cancelReleaseResync: (() => void) | undefined;
|
|
516
|
-
function scheduleReleaseResync(desiredScreens: DesiredScreen[]) {
|
|
517
|
-
let now = Date.now();
|
|
518
|
-
let next: number | undefined;
|
|
519
|
-
for (let desired of desiredScreens) {
|
|
520
|
-
let boundary = desired.takeoverAt;
|
|
521
|
-
if (boundary === undefined || boundary <= now) continue;
|
|
522
|
-
if (next === undefined || boundary < next) {
|
|
523
|
-
next = boundary;
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
if (cancelReleaseResync) {
|
|
527
|
-
cancelReleaseResync();
|
|
528
|
-
cancelReleaseResync = undefined;
|
|
529
|
-
}
|
|
530
|
-
if (next === undefined) return;
|
|
531
|
-
console.log(`Scheduling a resync for the next release boundary at ${new Date(next).toLocaleString()}`);
|
|
532
|
-
cancelReleaseResync = setPreciseTimeout({
|
|
533
|
-
time: next,
|
|
534
|
-
callback: () => {
|
|
535
|
-
cancelReleaseResync = undefined;
|
|
536
|
-
void resyncServices();
|
|
537
|
-
},
|
|
538
|
-
});
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
|
|
542
544
|
async function isPIDRunning(pid: string) {
|
|
543
545
|
try {
|
|
544
546
|
// Use process.kill with signal 0 to test if process exists without actually killing it
|
|
@@ -301,33 +301,41 @@ export async function takeoverScreen(config: {
|
|
|
301
301
|
folder: string;
|
|
302
302
|
/** The node the version being replaced registered as, read before the replacement overwrote the folder's file. */
|
|
303
303
|
replacedNodeId?: string;
|
|
304
|
+
}): Promise<boolean> {
|
|
305
|
+
return await withScreenLock(config.canonicalScreenName, () => takeoverScreenLocked(config));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The takeover itself. Callers already holding this screen's lock use it directly - taking the lock again from inside it would wait on themselves. */
|
|
309
|
+
export async function takeoverScreenLocked(config: {
|
|
310
|
+
canonicalScreenName: string;
|
|
311
|
+
folder: string;
|
|
312
|
+
replacedNodeId?: string;
|
|
304
313
|
}): Promise<boolean> {
|
|
305
314
|
let { canonicalScreenName, folder, replacedNodeId } = config;
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
}
|
|
321
|
-
retiringScreens.add(retiringScreenName);
|
|
322
|
-
void killScreen({ screenName: retiringScreenName }).finally(() => {
|
|
323
|
-
retiringScreens.delete(retiringScreenName);
|
|
324
|
-
});
|
|
315
|
+
let futureScreenName = getFutureScreenName(canonicalScreenName);
|
|
316
|
+
let futurePid = await getScreenPanePid(futureScreenName);
|
|
317
|
+
if (!futurePid) return false;
|
|
318
|
+
let prefix = getTmuxPrefix();
|
|
319
|
+
|
|
320
|
+
let outgoingPid = await getScreenPanePid(canonicalScreenName);
|
|
321
|
+
if (outgoingPid) {
|
|
322
|
+
// Renamed out of the way immediately, so the canonical name is free at once and the old process winds down on its own
|
|
323
|
+
let retiringScreenName = getRetiringScreenName(canonicalScreenName, Date.now());
|
|
324
|
+
await runPromise(`${prefix}tmux rename-session -t ${canonicalScreenName} ${retiringScreenName}`);
|
|
325
|
+
await renameProcessScreen({ folder, panePid: outgoingPid, screenName: retiringScreenName });
|
|
326
|
+
if (replacedNodeId) {
|
|
327
|
+
// The file in the folder belongs to the replacement now, so only the registration goes
|
|
328
|
+
void removeServiceNode({ folder, nodeId: replacedNodeId, ownsFile: false });
|
|
325
329
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
}
|
|
330
|
+
retiringScreens.add(retiringScreenName);
|
|
331
|
+
void killScreen({ screenName: retiringScreenName }).finally(() => {
|
|
332
|
+
retiringScreens.delete(retiringScreenName);
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
console.log(green(`Renaming ${futureScreenName} to ${canonicalScreenName} (takeover complete)`));
|
|
336
|
+
await runPromise(`${prefix}tmux rename-session -t ${futureScreenName} ${canonicalScreenName}`);
|
|
337
|
+
await renameProcessScreen({ folder, panePid: futurePid, screenName: canonicalScreenName });
|
|
338
|
+
return true;
|
|
331
339
|
}
|
|
332
340
|
|
|
333
341
|
/** Points a process's record at the session name it now runs under. The pid is the key, so this reads exactly one file. */
|
|
@@ -371,6 +379,13 @@ export function isRetiring(screenName: string): boolean {
|
|
|
371
379
|
return retiringScreens.has(screenName);
|
|
372
380
|
}
|
|
373
381
|
|
|
382
|
+
/** Ends a session at once, with no notice - for a screen that should not exist at all, so there is nothing to wind down gracefully. Finding one is always a surprise, so the reason it was not supposed to be there is logged. */
|
|
383
|
+
export async function killScreenNow(screenName: string, reason: string): Promise<void> {
|
|
384
|
+
if (!await getScreenPanePid(screenName)) return;
|
|
385
|
+
console.log(red(`Unexpected screen ${screenName}: ${reason}. Killing it immediately.`));
|
|
386
|
+
await runPromise(`${getTmuxPrefix()}tmux kill-session -t ${screenName}`);
|
|
387
|
+
}
|
|
388
|
+
|
|
374
389
|
// Kills a screen and nothing else. Which node the process registered as, and whether that registration should be dropped, is the deploy logic's business - it reads the node id off the process's record, which was taken before any replacement could overwrite the file.
|
|
375
390
|
export const killScreen = measureWrap(async function killScreen(config: {
|
|
376
391
|
screenName: string;
|