querysub 0.571.0 → 0.573.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
|
|
|
@@ -221,87 +221,68 @@ let launchesPerService = new Map<string, number>();
|
|
|
221
221
|
let lastLaunchedTimePerService = new Map<string, number>();
|
|
222
222
|
|
|
223
223
|
const LAUNCH_VERIFY_DELAY = 2000;
|
|
224
|
+
// How far a re-read start time may drift from the recorded one and still be the same process. Well under any interval a pid could be reused in, and wide enough for sources that only resolve to the second.
|
|
225
|
+
const START_TIME_TOLERANCE = timeInSecond * 5;
|
|
224
226
|
|
|
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();
|
|
227
|
+
/** 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. */
|
|
228
|
+
function getMachineInstances(configs: ServiceConfig[], machineId: string): { config: ServiceConfig; index: number }[] {
|
|
229
|
+
let instances: { config: ServiceConfig; index: number }[] = [];
|
|
250
230
|
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), {});
|
|
231
|
+
// A release can add or remove instances, so the count is whichever version asks for the most
|
|
232
|
+
let count = 0;
|
|
233
|
+
for (let parameters of [config.parameters, config.oldParameters]) {
|
|
234
|
+
if (!parameters) continue;
|
|
235
|
+
count = Math.max(count, getMachineTargets(parameters).filter(x => x.machineId === machineId).length);
|
|
236
|
+
}
|
|
237
|
+
for (let index = 0; index < count; index++) {
|
|
238
|
+
const resolve = (parameters: ServiceParameters) => {
|
|
239
|
+
let target = getMachineTargets(parameters).filter(x => x.machineId === machineId)[index];
|
|
240
|
+
return {
|
|
241
|
+
...parameters,
|
|
242
|
+
command: target && applyCommandTemplate(parameters.command, target.variables) || parameters.command,
|
|
243
|
+
deploy: !!parameters.deploy && !!target,
|
|
244
|
+
};
|
|
245
|
+
};
|
|
246
|
+
instances.push({
|
|
247
|
+
index,
|
|
248
|
+
config: {
|
|
249
|
+
...config,
|
|
250
|
+
parameters: resolve(config.parameters),
|
|
251
|
+
oldParameters: config.oldParameters && resolve(config.oldParameters),
|
|
252
|
+
},
|
|
253
|
+
});
|
|
288
254
|
}
|
|
289
255
|
}
|
|
290
|
-
|
|
291
|
-
for (let screen of desired) {
|
|
292
|
-
if (screen.takeoverAt === undefined) continue;
|
|
293
|
-
screen.screenName = getFutureScreenName(screen.canonicalScreenName);
|
|
294
|
-
}
|
|
295
|
-
return desired;
|
|
256
|
+
return instances;
|
|
296
257
|
}
|
|
297
258
|
|
|
298
|
-
/** Puts one
|
|
259
|
+
/** 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
260
|
async function ensureScreen(config: {
|
|
300
|
-
|
|
261
|
+
config: ServiceConfig;
|
|
262
|
+
index: number;
|
|
301
263
|
machineInfo: MachineInfo;
|
|
302
|
-
}): Promise<
|
|
303
|
-
let {
|
|
304
|
-
let
|
|
264
|
+
}): Promise<string[]> {
|
|
265
|
+
let { machineInfo, index } = config;
|
|
266
|
+
let machineId = machineInfo.machineId;
|
|
267
|
+
let serviceId = config.config.serviceId;
|
|
268
|
+
let serviceConfig = config.config;
|
|
269
|
+
|
|
270
|
+
let releaseTime = serviceConfig.parameters.releaseTime || 0;
|
|
271
|
+
// The overlap length always comes from the NEWEST parameters, even though it governs how long the OLD instances outlive the release
|
|
272
|
+
let killTime = releaseTime + (serviceConfig.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME);
|
|
273
|
+
// Only in flight while there is an old version to keep alive, and only until its overlap runs out
|
|
274
|
+
let releaseInFlight = !!releaseTime && !!serviceConfig.oldParameters && Date.now() < killTime;
|
|
275
|
+
|
|
276
|
+
// What should be running under the canonical name now, and the version waiting to take it over
|
|
277
|
+
let current: ServiceParameters | undefined = releaseInFlight && serviceConfig.oldParameters || getLiveServiceParameters(serviceConfig);
|
|
278
|
+
if (!current?.deploy) current = undefined;
|
|
279
|
+
let incoming: ServiceParameters | undefined = releaseInFlight && Date.now() >= releaseTime && serviceConfig.parameters || undefined;
|
|
280
|
+
if (!incoming?.deploy) incoming = undefined;
|
|
281
|
+
let canonicalScreenName = getScreenName({ serviceKey: (current || incoming || serviceConfig.parameters).key, index });
|
|
282
|
+
let futureScreenName = getFutureScreenName(canonicalScreenName);
|
|
283
|
+
let folder = os.homedir() + "/" + SERVICE_FOLDER + canonicalScreenName + "/";
|
|
284
|
+
// Returned even when the work below throws, so a transient failure never makes the sweep kill a screen we still want
|
|
285
|
+
let usedScreenNames = [canonicalScreenName, futureScreenName];
|
|
305
286
|
|
|
306
287
|
let launchCount = launchesPerService.get(serviceId) || 0;
|
|
307
288
|
let lastLaunchedTime = lastLaunchedTimePerService.get(serviceId) || 0;
|
|
@@ -313,47 +294,70 @@ async function ensureScreen(config: {
|
|
|
313
294
|
};
|
|
314
295
|
|
|
315
296
|
try {
|
|
316
|
-
await withScreenLock(canonicalScreenName, async () => {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
return;
|
|
297
|
+
await watchSlowPromise(`ensureScreen|${canonicalScreenName}`, withScreenLock(canonicalScreenName, async () => {
|
|
298
|
+
/* Three facts, each moving ? -> known -> correct as we go:
|
|
299
|
+
EXISTS - which of the two screens are there
|
|
300
|
+
VERSION - which parameters they were started with
|
|
301
|
+
RUNNING - whether a process is actually alive in them
|
|
302
|
+
Every step below says where those three stand, so it is always clear what we are still assuming.
|
|
303
|
+
START: EXISTS ? VERSION ? RUNNING ?
|
|
304
|
+
*/
|
|
305
|
+
let parameters = incoming || current;
|
|
306
|
+
let screenName = incoming && futureScreenName || canonicalScreenName;
|
|
307
|
+
let takeoverAt = incoming && killTime || undefined;
|
|
308
|
+
|
|
309
|
+
// 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.
|
|
310
|
+
// AFTER: EXISTS correct in the "nothing extra" sense. A screen that SHOULD exist may still be missing, which is RUNNING's problem below.
|
|
311
|
+
if (!incoming) {
|
|
312
|
+
await killScreenNow(futureScreenName, "there is no release in flight, so nothing should be waiting to take over");
|
|
333
313
|
}
|
|
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
|
-
}
|
|
314
|
+
if (!current) {
|
|
315
|
+
await killScreenNow(canonicalScreenName, "nothing is supposed to be serving this name yet");
|
|
350
316
|
}
|
|
317
|
+
if (!current && !incoming) return;
|
|
351
318
|
|
|
319
|
+
// 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.
|
|
320
|
+
// 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
321
|
let parameterPath = folder + "parameters.json";
|
|
353
322
|
let prevParameters = "";
|
|
354
323
|
if (await fsExistsAsync(parameterPath)) {
|
|
355
324
|
prevParameters = await fs.promises.readFile(parameterPath, "utf8");
|
|
356
325
|
}
|
|
326
|
+
|
|
327
|
+
// 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.
|
|
328
|
+
let canonicalPid = await getScreenPanePid(canonicalScreenName);
|
|
329
|
+
let canonicalUp = !!canonicalPid && await isScreenRunningProcess(canonicalPid);
|
|
330
|
+
let futurePid = incoming && await getScreenPanePid(futureScreenName) || undefined;
|
|
331
|
+
let futureUp = !!futurePid && await isScreenRunningProcess(futurePid);
|
|
332
|
+
|
|
333
|
+
// 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.
|
|
334
|
+
// AFTER (canonical, during a release): EXISTS correct VERSION correct RUNNING correct
|
|
335
|
+
if (incoming && current && !canonicalUp) {
|
|
336
|
+
console.log(red(`Nothing is running as ${canonicalScreenName}, which should be serving until ${new Date(killTime).toISOString()} while its replacement starts`));
|
|
337
|
+
}
|
|
338
|
+
if (incoming && current && canonicalUp && canonicalPid) {
|
|
339
|
+
await ensureProcessRecord({
|
|
340
|
+
screenName: canonicalScreenName,
|
|
341
|
+
folder,
|
|
342
|
+
panePid: canonicalPid,
|
|
343
|
+
record: { serviceId, serviceKey: current.key, index, machineId, parameters: current },
|
|
344
|
+
});
|
|
345
|
+
void (async () => {
|
|
346
|
+
let nodeId = await readServiceNodeId(folder);
|
|
347
|
+
if (nodeId) {
|
|
348
|
+
await notifyNodeShutdown(canonicalScreenName, nodeId, killTime);
|
|
349
|
+
}
|
|
350
|
+
})();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// From here on this is about ONE screen - the future one during a release, the canonical one otherwise. Everything else is already correct.
|
|
354
|
+
// STATE: EXISTS correct VERSION known RUNNING known
|
|
355
|
+
if (!parameters) return;
|
|
356
|
+
let panePid = incoming && futurePid || canonicalPid;
|
|
357
|
+
let isUp = incoming && futureUp || canonicalUp;
|
|
358
|
+
let aliveWindow: [number, number] = [parameters.releaseTime || 0, ALIVE_WINDOW_FOREVER];
|
|
359
|
+
let record = { serviceId, serviceKey: parameters.key, index, machineId, parameters };
|
|
360
|
+
|
|
357
361
|
let newParametersString = JSON.stringify(parameters);
|
|
358
362
|
|
|
359
363
|
let nodePathId = folder + SERVICE_NODE_FILE_NAME;
|
|
@@ -361,28 +365,62 @@ async function ensureScreen(config: {
|
|
|
361
365
|
machineInfo.services[serviceId].nodeId = await fs.promises.readFile(nodePathId, "utf8");
|
|
362
366
|
}
|
|
363
367
|
|
|
368
|
+
// Already running this exact version, so nothing needs starting - this is the state we are trying to reach
|
|
369
|
+
// AFTER: EXISTS correct VERSION correct RUNNING correct, and the takeover below settles which name it answers to
|
|
364
370
|
if (isUp && panePid && sameRestartParameters(prevParameters, parameters)) {
|
|
365
371
|
if (prevParameters !== newParametersString) {
|
|
366
372
|
await fs.promises.writeFile(parameterPath, newParametersString);
|
|
367
373
|
}
|
|
368
|
-
// We may not have
|
|
369
|
-
await ensureProcessRecord({ screenName, folder, panePid, record
|
|
370
|
-
await syncParametersTimelineFiles(folder, [{ pid: parseInt(panePid) || undefined, aliveWindow
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
+
// We may not have started this one (we restarted, or it predates process records), so make sure it is recorded before leaving it alone
|
|
375
|
+
await ensureProcessRecord({ screenName, folder, panePid, record });
|
|
376
|
+
await syncParametersTimelineFiles(folder, [{ pid: parseInt(panePid) || undefined, aliveWindow, parameters }]);
|
|
377
|
+
if (takeoverAt !== undefined) {
|
|
378
|
+
if (!canonicalUp) {
|
|
379
|
+
// 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
|
|
380
|
+
console.log(red(`Nothing is running as ${canonicalScreenName}, so ${screenName} takes it over now instead of waiting until ${new Date(takeoverAt).toISOString()}`));
|
|
381
|
+
await takeoverScreenLocked({ canonicalScreenName, folder });
|
|
382
|
+
} else {
|
|
383
|
+
// 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.
|
|
384
|
+
scheduleTakeover({ canonicalScreenName, folder, killTime: takeoverAt });
|
|
385
|
+
}
|
|
374
386
|
}
|
|
375
387
|
console.log(green(`Verified ${magenta(screenName)} is running`));
|
|
376
388
|
return;
|
|
377
389
|
}
|
|
378
390
|
|
|
379
|
-
|
|
391
|
+
// RUNNING is wrong - either nothing is alive, or what is alive is the wrong version. Starting is the only thing that fixes it.
|
|
392
|
+
if (!isUp) {
|
|
393
|
+
// Nothing should ever stop on its own: either it crashed, or it never came up
|
|
394
|
+
console.log(red(`Nothing is running as ${screenName}, starting it: ${newParametersString}`));
|
|
395
|
+
} else {
|
|
396
|
+
// SMOOTH PATH: the previous version is up and this replaces it with a new one, which is exactly what a deploy is
|
|
397
|
+
console.log(green(`Starting a new version as ${magenta(screenName)}, replacing the one running there: ${newParametersString}`));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// 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
|
|
401
|
+
let gitFolder = folder + "git/";
|
|
402
|
+
await fs.promises.mkdir(gitFolder, { recursive: true });
|
|
403
|
+
if (parameters.gitRef) {
|
|
404
|
+
let prevGitRef = "";
|
|
405
|
+
try {
|
|
406
|
+
prevGitRef = await getGitRefLive(gitFolder);
|
|
407
|
+
} catch { }
|
|
408
|
+
await ensureGitSynced({ gitFolder, repoUrl: parameters.repoUrl, gitRef: parameters.gitRef });
|
|
409
|
+
let afterGitRef = await getGitRefLive(gitFolder);
|
|
410
|
+
// 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.
|
|
411
|
+
let nodeModulesMissing = !await fsExistsAsync(gitFolder + "node_modules");
|
|
412
|
+
if (afterGitRef !== prevGitRef || nodeModulesMissing) {
|
|
413
|
+
console.log(green(`Yarn installing for ${magenta(screenName)} (ref ${prevGitRef} -> ${afterGitRef}, nodeModulesMissing=${nodeModulesMissing})`));
|
|
414
|
+
await runPromise(`yarn install --mutex network`, { cwd: gitFolder });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
380
418
|
await fs.promises.writeFile(parameterPath, newParametersString);
|
|
381
419
|
void recordLaunch({
|
|
382
420
|
serviceId,
|
|
383
|
-
serviceKey:
|
|
421
|
+
serviceKey: parameters.key,
|
|
384
422
|
screenName: canonicalScreenName,
|
|
385
|
-
machineId
|
|
423
|
+
machineId,
|
|
386
424
|
reason: isUp && "update" || "crashed",
|
|
387
425
|
time: Date.now(),
|
|
388
426
|
});
|
|
@@ -390,17 +428,17 @@ async function ensureScreen(config: {
|
|
|
390
428
|
// The node the version being replaced registered as, read before the launch overwrites the file with the new process's
|
|
391
429
|
let replacedNodeId = await readServiceNodeId(folder);
|
|
392
430
|
|
|
393
|
-
//
|
|
394
|
-
let launchScreenName =
|
|
431
|
+
// 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.
|
|
432
|
+
let launchScreenName = futureScreenName;
|
|
395
433
|
let launchedPid = await runScreenCommand({
|
|
396
434
|
screenName: launchScreenName,
|
|
397
435
|
folder,
|
|
398
436
|
command: parameters.command,
|
|
399
|
-
record
|
|
437
|
+
record,
|
|
400
438
|
});
|
|
401
|
-
//
|
|
402
|
-
if (
|
|
403
|
-
scheduleTakeover({ canonicalScreenName, folder, killTime:
|
|
439
|
+
// SMOOTH PATH: the command is running as of the line above, so the overlap the version it replaces gets is measured from here
|
|
440
|
+
if (takeoverAt !== undefined) {
|
|
441
|
+
scheduleTakeover({ canonicalScreenName, folder, killTime: takeoverAt, replacedNodeId });
|
|
404
442
|
}
|
|
405
443
|
|
|
406
444
|
await delay(LAUNCH_VERIFY_DELAY);
|
|
@@ -411,26 +449,28 @@ async function ensureScreen(config: {
|
|
|
411
449
|
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
450
|
}
|
|
413
451
|
console.log(green(`${magenta(launchScreenName)} is verified to be running (pid ${launchedPid})`));
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
452
|
+
// 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.
|
|
453
|
+
// AFTER: EXISTS correct VERSION correct RUNNING correct, under the canonical name, or under the future one with the version it is replacing still serving
|
|
454
|
+
if (takeoverAt === undefined || !canonicalUp) {
|
|
455
|
+
await takeoverScreenLocked({ canonicalScreenName, folder, replacedNodeId });
|
|
417
456
|
}
|
|
418
|
-
await syncParametersTimelineFiles(folder, [{ pid: parseInt(launchedPanePid) || undefined, aliveWindow
|
|
457
|
+
await syncParametersTimelineFiles(folder, [{ pid: parseInt(launchedPanePid) || undefined, aliveWindow, parameters }]);
|
|
419
458
|
|
|
420
459
|
launchCount++;
|
|
421
460
|
lastLaunchedTime = Date.now();
|
|
422
|
-
});
|
|
461
|
+
}));
|
|
423
462
|
} catch (e: any) {
|
|
424
463
|
// Counted on failure too, as a service that keeps crashing is only visible as a launch count climbing faster than everyone else's
|
|
425
464
|
launchCount++;
|
|
426
465
|
lastLaunchedTime = Date.now();
|
|
427
|
-
console.error(`Error resyncing ${magenta(
|
|
466
|
+
console.error(`Error resyncing ${magenta(canonicalScreenName)} (${serviceId}): ${e.stack}`);
|
|
428
467
|
machineInfo.services[serviceId].errorFromLastRun = e.stack;
|
|
429
468
|
}
|
|
430
469
|
machineInfo.services[serviceId].totalTimesLaunched = launchCount;
|
|
431
470
|
machineInfo.services[serviceId].lastLaunchedTime = lastLaunchedTime;
|
|
432
471
|
lastLaunchedTimePerService.set(serviceId, lastLaunchedTime);
|
|
433
472
|
launchesPerService.set(serviceId, launchCount);
|
|
473
|
+
return usedScreenNames;
|
|
434
474
|
}
|
|
435
475
|
|
|
436
476
|
function sameRestartParameters(prevParametersJSON: string, next: ServiceParameters): boolean {
|
|
@@ -459,27 +499,23 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
|
|
|
459
499
|
await syncProcessRecords({
|
|
460
500
|
isAlive: async record => {
|
|
461
501
|
if (!running.has(record.pid)) return false;
|
|
462
|
-
|
|
502
|
+
// Compared with a tolerance rather than exactly: the sources we read a start time from only resolve to the second, and they do not all round the same way, so an exact match would call a live process dead
|
|
503
|
+
return Math.abs(await getProcessStartTime(record.pid) - record.startTime) <= START_TIME_TOLERANCE;
|
|
463
504
|
},
|
|
464
505
|
now: Date.now(),
|
|
465
506
|
});
|
|
466
507
|
let root = os.homedir() + "/" + SERVICE_FOLDER;
|
|
467
|
-
let
|
|
508
|
+
let instances = getMachineInstances(relevantConfigs, machineId);
|
|
468
509
|
|
|
469
|
-
// The fast-path results are memoized per resync, so a folder
|
|
510
|
+
// The fast-path results are memoized per resync, so a folder shared by two instances is only traversed once
|
|
470
511
|
gitFastPathChecks.clear();
|
|
471
512
|
|
|
472
|
-
// Every
|
|
473
|
-
await Promise.all(
|
|
513
|
+
// 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.
|
|
514
|
+
let usedScreenNames = new Set((await Promise.all(instances.map(instance => ensureScreen({ ...instance, machineInfo })))).flat());
|
|
474
515
|
|
|
475
|
-
//
|
|
476
|
-
let wantedNames = new Set<string>();
|
|
477
|
-
for (let desired of desiredScreens) {
|
|
478
|
-
wantedNames.add(desired.screenName);
|
|
479
|
-
wantedNames.add(desired.canonicalScreenName);
|
|
480
|
-
}
|
|
516
|
+
// Anything no instance claimed is not supposed to exist
|
|
481
517
|
for (let { screenName } of screenState) {
|
|
482
|
-
if (
|
|
518
|
+
if (usedScreenNames.has(screenName)) continue;
|
|
483
519
|
// Already being taken down. One left over from a restart is not, so it still gets cleaned up here.
|
|
484
520
|
if (isRetiring(screenName)) continue;
|
|
485
521
|
// Detached, so a screen that takes its full grace period to go down doesn't hold up the end of the resync
|
|
@@ -494,9 +530,6 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
|
|
|
494
530
|
})();
|
|
495
531
|
}
|
|
496
532
|
|
|
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
533
|
await machineInfos.set(machineId, machineInfo);
|
|
501
534
|
console.log(`${magenta(`Resynced ${relevantConfigs.length} services`)}:\n${JSON.stringify(machineInfo, null, 2)}`);
|
|
502
535
|
|
|
@@ -511,34 +544,6 @@ async function resyncServices() {
|
|
|
511
544
|
}
|
|
512
545
|
}
|
|
513
546
|
|
|
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
547
|
async function isPIDRunning(pid: string) {
|
|
543
548
|
try {
|
|
544
549
|
// Use process.kill with signal 0 to test if process exists without actually killing it
|
|
@@ -99,6 +99,11 @@ export async function readProcessRecord(folder: string, pid: string, startTime:
|
|
|
99
99
|
return JSON.parse(await fs.promises.readFile(file, "utf8")) as ProcessRecord;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/** The live record for a pid in this folder. The pid is what identifies a process, so a start time that reads back a second off - the sources only resolve to the second - must not make us miss the record and write a duplicate. */
|
|
103
|
+
export async function findProcessRecordByPid(folder: string, pid: string): Promise<ProcessRecord | undefined> {
|
|
104
|
+
return (await readFolderRecords(folder)).find(x => x.pid === pid && x.deadTime === undefined);
|
|
105
|
+
}
|
|
106
|
+
|
|
102
107
|
export async function writeProcessRecord(record: ProcessRecord): Promise<void> {
|
|
103
108
|
await fs.promises.mkdir(path.join(record.folder, PROCESS_FOLDER), { recursive: true });
|
|
104
109
|
await fs.promises.writeFile(getRecordPath(record.folder, record.pid, record.startTime), JSON.stringify(record));
|
|
@@ -13,7 +13,7 @@ import { forceRemoveNode } from "../-f-node-discovery/NodeDiscovery";
|
|
|
13
13
|
import { fsExistsAsync } from "../fs";
|
|
14
14
|
import { PromiseObj } from "../promise";
|
|
15
15
|
import { SERVICE_FOLDER, SERVICE_NODE_FILE_NAME } from "./machineSchema";
|
|
16
|
-
import { ProcessRecord, getLogPipeScript, getLogTailScript, getProcessLogPath, getPipeScriptPath, getTailScriptPath,
|
|
16
|
+
import { ProcessRecord, getLogPipeScript, getLogTailScript, getProcessLogPath, getPipeScriptPath, getTailScriptPath, findProcessRecordByPid, writeProcessRecord } from "./processLogs";
|
|
17
17
|
import { setPreciseTimeout } from "../misc";
|
|
18
18
|
|
|
19
19
|
// Running, inspecting and killing the tmux screens services run in. This layer only knows "here is a configuration, run it" - which version should be running when is the deploy logic's problem.
|
|
@@ -163,11 +163,23 @@ const getAllParentPids = measureWrap(async function getAllParentPids(): Promise<
|
|
|
163
163
|
let obj = textTableLineToObj(table) as { PID: string; PPID: string }[];
|
|
164
164
|
return new Set(obj.map(x => x.PPID));
|
|
165
165
|
});
|
|
166
|
-
/** The start time the OS reports for a pid, as an epoch time in milliseconds like every other time we store. Asked of the machine rather than remembered, so it stays true across our restarts and catches a reused pid. Returns 0 when the process is gone. */
|
|
166
|
+
/** The start time the OS reports for a pid, as an epoch time in milliseconds like every other time we store. Asked of the machine rather than remembered, so it stays true across our restarts and catches a reused pid. Returns 0 when the process is gone, or when neither source can answer. */
|
|
167
167
|
export const getProcessStartTime = measureWrap(async function getProcessStartTime(pid: string): Promise<number> {
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
168
|
+
// /proc/<pid> is created when the process is, so its ctime IS the start time - no date parsing, and nothing to get wrong about locales or ps output formats
|
|
169
|
+
try {
|
|
170
|
+
let started = await runPromise(`stat -c %Y /proc/${pid}`, { quiet: true });
|
|
171
|
+
let seconds = parseInt(started.trim()) || 0;
|
|
172
|
+
if (seconds) return seconds * 1000;
|
|
173
|
+
} catch {
|
|
174
|
+
// No procfs (or the process is gone), so fall through to ps
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
// Second-accurate, which is plenty to tell two processes on the same pid apart
|
|
178
|
+
let started = await runPromise(`date -d "$(ps -o lstart= -p ${pid})" +%s`, { quiet: true });
|
|
179
|
+
return (parseInt(started.trim()) || 0) * 1000;
|
|
180
|
+
} catch {
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
171
183
|
});
|
|
172
184
|
|
|
173
185
|
export const isScreenRunningProcess = measureWrap(async function isScreenRunningProcess(pid: string): Promise<boolean> {
|
|
@@ -263,9 +275,9 @@ export async function ensureProcessRecord(config: {
|
|
|
263
275
|
record: Omit<ProcessRecord, "pid" | "startTime" | "folder" | "screenName">;
|
|
264
276
|
}): Promise<void> {
|
|
265
277
|
let pid = config.panePid;
|
|
278
|
+
// A start time we could not read is still recorded: a process with no record at all is invisible everywhere, which is worse than one whose identity is only its pid
|
|
266
279
|
let startTime = await getProcessStartTime(pid);
|
|
267
|
-
|
|
268
|
-
let existing = await readProcessRecord(config.folder, pid, startTime);
|
|
280
|
+
let existing = await findProcessRecordByPid(config.folder, pid);
|
|
269
281
|
if (existing) {
|
|
270
282
|
// A takeover renames the session, so the record follows the name it now runs under
|
|
271
283
|
if (existing.screenName === config.screenName) return;
|
|
@@ -301,40 +313,46 @@ export async function takeoverScreen(config: {
|
|
|
301
313
|
folder: string;
|
|
302
314
|
/** The node the version being replaced registered as, read before the replacement overwrote the folder's file. */
|
|
303
315
|
replacedNodeId?: string;
|
|
316
|
+
}): Promise<boolean> {
|
|
317
|
+
return await withScreenLock(config.canonicalScreenName, () => takeoverScreenLocked(config));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The takeover itself. Callers already holding this screen's lock use it directly - taking the lock again from inside it would wait on themselves. */
|
|
321
|
+
export async function takeoverScreenLocked(config: {
|
|
322
|
+
canonicalScreenName: string;
|
|
323
|
+
folder: string;
|
|
324
|
+
replacedNodeId?: string;
|
|
304
325
|
}): Promise<boolean> {
|
|
305
326
|
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
|
-
});
|
|
327
|
+
let futureScreenName = getFutureScreenName(canonicalScreenName);
|
|
328
|
+
let futurePid = await getScreenPanePid(futureScreenName);
|
|
329
|
+
if (!futurePid) return false;
|
|
330
|
+
let prefix = getTmuxPrefix();
|
|
331
|
+
|
|
332
|
+
let outgoingPid = await getScreenPanePid(canonicalScreenName);
|
|
333
|
+
if (outgoingPid) {
|
|
334
|
+
// Renamed out of the way immediately, so the canonical name is free at once and the old process winds down on its own
|
|
335
|
+
let retiringScreenName = getRetiringScreenName(canonicalScreenName, Date.now());
|
|
336
|
+
await runPromise(`${prefix}tmux rename-session -t ${canonicalScreenName} ${retiringScreenName}`);
|
|
337
|
+
await renameProcessScreen({ folder, panePid: outgoingPid, screenName: retiringScreenName });
|
|
338
|
+
if (replacedNodeId) {
|
|
339
|
+
// The file in the folder belongs to the replacement now, so only the registration goes
|
|
340
|
+
void removeServiceNode({ folder, nodeId: replacedNodeId, ownsFile: false });
|
|
325
341
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
}
|
|
342
|
+
retiringScreens.add(retiringScreenName);
|
|
343
|
+
void killScreen({ screenName: retiringScreenName }).finally(() => {
|
|
344
|
+
retiringScreens.delete(retiringScreenName);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
console.log(green(`Renaming ${futureScreenName} to ${canonicalScreenName} (takeover complete)`));
|
|
348
|
+
await runPromise(`${prefix}tmux rename-session -t ${futureScreenName} ${canonicalScreenName}`);
|
|
349
|
+
await renameProcessScreen({ folder, panePid: futurePid, screenName: canonicalScreenName });
|
|
350
|
+
return true;
|
|
331
351
|
}
|
|
332
352
|
|
|
333
353
|
/** Points a process's record at the session name it now runs under. The pid is the key, so this reads exactly one file. */
|
|
334
354
|
async function renameProcessScreen(config: { folder: string; panePid: string; screenName: string }): Promise<void> {
|
|
335
|
-
let
|
|
336
|
-
if (!startTime) return;
|
|
337
|
-
let record = await readProcessRecord(config.folder, config.panePid, startTime);
|
|
355
|
+
let record = await findProcessRecordByPid(config.folder, config.panePid);
|
|
338
356
|
if (!record || record.screenName === config.screenName) return;
|
|
339
357
|
await writeProcessRecord({ ...record, screenName: config.screenName });
|
|
340
358
|
}
|
|
@@ -371,6 +389,13 @@ export function isRetiring(screenName: string): boolean {
|
|
|
371
389
|
return retiringScreens.has(screenName);
|
|
372
390
|
}
|
|
373
391
|
|
|
392
|
+
/** 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. */
|
|
393
|
+
export async function killScreenNow(screenName: string, reason: string): Promise<void> {
|
|
394
|
+
if (!await getScreenPanePid(screenName)) return;
|
|
395
|
+
console.log(red(`Unexpected screen ${screenName}: ${reason}. Killing it immediately.`));
|
|
396
|
+
await runPromise(`${getTmuxPrefix()}tmux kill-session -t ${screenName}`);
|
|
397
|
+
}
|
|
398
|
+
|
|
374
399
|
// 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
400
|
export const killScreen = measureWrap(async function killScreen(config: {
|
|
376
401
|
screenName: string;
|