@platformatic/runtime 3.72.0 → 4.0.0-alpha.1
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/config.d.ts +602 -673
- package/index.d.ts +22 -27
- package/index.js +290 -30
- package/lib/config.js +270 -367
- package/lib/errors.js +27 -13
- package/lib/generator.js +249 -127
- package/lib/logger.js +3 -3
- package/lib/management-api.js +22 -65
- package/lib/management-handlers.js +3 -5
- package/lib/memory.js +29 -0
- package/lib/policies.js +27 -0
- package/lib/predictive-scaling.js +910 -0
- package/lib/predictive-worker-scaler.js +378 -0
- package/lib/runtime.js +1081 -572
- package/lib/scheduler.js +6 -41
- package/lib/schema.js +77 -15
- package/lib/upgrade.js +16 -1
- package/lib/versions/v4.0.0.js +31 -0
- package/lib/worker/controller.js +89 -109
- package/lib/worker/interceptors.js +34 -34
- package/lib/worker/itc.js +248 -66
- package/lib/worker/main.js +50 -98
- package/lib/worker/messaging.js +42 -1
- package/lib/worker/round-robin-map.js +10 -3
- package/lib/worker/symbols.js +6 -2
- package/package.json +33 -24
- package/schema.json +252 -433
- package/.gitignore +0 -3
- package/.npmignore +0 -2
- package/lib/scaling-algorithm.js +0 -256
- package/lib/worker-scaler.js +0 -249
package/lib/runtime.js
CHANGED
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
executeInParallel,
|
|
6
6
|
executeWithTimeout,
|
|
7
7
|
features,
|
|
8
|
-
kEnvFileFallbackKeys,
|
|
9
8
|
kMetadata,
|
|
10
9
|
kTimeout,
|
|
11
10
|
parseMemorySize,
|
|
@@ -26,6 +25,7 @@ import { STATUS_CODES } from 'node:http'
|
|
|
26
25
|
import { createRequire } from 'node:module'
|
|
27
26
|
import { availableParallelism } from 'node:os'
|
|
28
27
|
import { basename, dirname, isAbsolute, join } from 'node:path'
|
|
28
|
+
import { randomUUID } from 'node:crypto'
|
|
29
29
|
import { Readable } from 'node:stream'
|
|
30
30
|
import { finished } from 'node:stream/promises'
|
|
31
31
|
import { setImmediate as immediate, setTimeout as sleep } from 'node:timers/promises'
|
|
@@ -33,14 +33,15 @@ import { pathToFileURL } from 'node:url'
|
|
|
33
33
|
import { Worker } from 'node:worker_threads'
|
|
34
34
|
import SonicBoom from 'sonic-boom'
|
|
35
35
|
import { Agent, request, interceptors as undiciInterceptors } from 'undici'
|
|
36
|
-
import {
|
|
36
|
+
import { createCoordinator, createInterceptor } from 'undici-thread-interceptor'
|
|
37
37
|
import { pprofCapturePreloadPath } from './config.js'
|
|
38
38
|
import {
|
|
39
|
+
AddressInUseError,
|
|
40
|
+
WorkerAddressInUseError,
|
|
39
41
|
ApplicationAlreadyStartedError,
|
|
40
42
|
ApplicationNotFoundError,
|
|
41
43
|
ApplicationNotStartedError,
|
|
42
44
|
ApplicationStartTimeoutError,
|
|
43
|
-
CannotRemoveEntrypointError,
|
|
44
45
|
DuplicateExtensionHealthCheckError,
|
|
45
46
|
DuplicateITCHandlerNameError,
|
|
46
47
|
ExtensionHealthRoutesUnavailableError,
|
|
@@ -53,23 +54,23 @@ import {
|
|
|
53
54
|
MessagingError,
|
|
54
55
|
MetricFamilyCollisionError,
|
|
55
56
|
MissingPprofCapture,
|
|
57
|
+
MixedServingStateError,
|
|
56
58
|
ReservedITCHandlerNameError,
|
|
57
59
|
RuntimeAbortedError,
|
|
58
60
|
RuntimeExtensionBuildAlreadyCalledError,
|
|
59
|
-
WorkerInterceptorJoinTimeoutError,
|
|
60
61
|
WorkerNotFoundError
|
|
61
62
|
} from './errors.js'
|
|
62
63
|
import { abstractLogger, createLogger } from './logger.js'
|
|
63
64
|
import { startManagementApi } from './management-api.js'
|
|
64
65
|
import { createManagementHandlers } from './management-handlers.js'
|
|
65
66
|
import { OpenTelemetryMetricsForwarder } from './opentelemetry-metrics.js'
|
|
66
|
-
import { createChannelCreationHook } from './policies.js'
|
|
67
|
+
import { createChannelCreationHook, createTargetPermissionHook } from './policies.js'
|
|
67
68
|
import { startHealthProbesServer, startPrometheusServer } from './prom-server.js'
|
|
68
69
|
import { startScheduler } from './scheduler.js'
|
|
69
70
|
import { createSharedStore } from './shared-http-cache.js'
|
|
70
71
|
import { topologicalLevels, topologicalSort } from './utils.js'
|
|
71
72
|
import { version } from './version.js'
|
|
72
|
-
import {
|
|
73
|
+
import { PredictiveWorkersScaler } from './predictive-worker-scaler.js'
|
|
73
74
|
import { HealthSignalsQueue } from './worker/health-signals.js'
|
|
74
75
|
import { sendMultipleViaITC, sendViaITC, waitEventFromITC } from './worker/itc.js'
|
|
75
76
|
import { RoundRobinMap } from './worker/round-robin-map.js'
|
|
@@ -79,14 +80,19 @@ import {
|
|
|
79
80
|
kFullId,
|
|
80
81
|
kHealthCheckTimer,
|
|
81
82
|
kId,
|
|
82
|
-
kInterceptorReadyPromise,
|
|
83
83
|
kIsSubprocessHost,
|
|
84
|
+
kSubprocessPid,
|
|
84
85
|
kITC,
|
|
85
86
|
kLastHealthCheckELU,
|
|
86
87
|
kStderrMarker,
|
|
88
|
+
kWorkerExitCode,
|
|
87
89
|
kWorkerHealthSignals,
|
|
88
90
|
kWorkerId,
|
|
91
|
+
kWorkerPortOffset,
|
|
92
|
+
kWorkerServerOptions,
|
|
93
|
+
kWorkerUrl,
|
|
89
94
|
kWorkersBroadcast,
|
|
95
|
+
kWorkerStartAborted,
|
|
90
96
|
kWorkerStartTime,
|
|
91
97
|
kWorkerStatus
|
|
92
98
|
} from './worker/symbols.js'
|
|
@@ -118,6 +124,10 @@ const kLastProfileFallbackCodes = new Set([
|
|
|
118
124
|
const kApplicationRestartsMetricName = 'platformatic_application_restarts_total'
|
|
119
125
|
const kApplicationRestartsMetricHelp = 'Total number of restarts triggered by the runtime for an application.'
|
|
120
126
|
|
|
127
|
+
function normalizeCompileCache (value) {
|
|
128
|
+
return typeof value === 'boolean' ? { enabled: value } : value ?? {}
|
|
129
|
+
}
|
|
130
|
+
|
|
121
131
|
const MAX_LISTENERS_COUNT = 100
|
|
122
132
|
|
|
123
133
|
function hasWorkerIndex (applicationId) {
|
|
@@ -174,6 +184,56 @@ const IMMEDIATE_RESTART_MAX_THRESHOLD = 10
|
|
|
174
184
|
const MAX_WORKERS = 100
|
|
175
185
|
const DEFAULT_RESTART_ON_ERROR_DELAY = 5000
|
|
176
186
|
|
|
187
|
+
/*
|
|
188
|
+
Both public payloads are built from a snapshot and frozen through. What a consumer could observe
|
|
189
|
+
could once observe was scalars and a file path, so handing out interior state was harmless in
|
|
190
|
+
practice; the payload now nests resolvedConfig -- an entire capability payload -- inside every
|
|
191
|
+
entry, and the getters read
|
|
192
|
+
straight off live state. A consumer mutating what it received would be editing the configuration
|
|
193
|
+
that later restarts and scale-up workers read, silently, and would make worker generations
|
|
194
|
+
disagree about what they are running. setApplicationConfigPatch exists precisely so that changing
|
|
195
|
+
a running application's configuration is explicit, patch-shaped and visible.
|
|
196
|
+
|
|
197
|
+
Only plain objects and arrays are copied. Anything carrying its own prototype -- a class
|
|
198
|
+
instance, a stream, a buffer -- is handed back as it is, because a copy of it would not be the
|
|
199
|
+
thing; the hazard this closes is a consumer editing configuration, not one editing a socket.
|
|
200
|
+
*/
|
|
201
|
+
function frozenSnapshot (value, seen = new Map()) {
|
|
202
|
+
if (value === null || typeof value !== 'object') {
|
|
203
|
+
return value
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (seen.has(value)) {
|
|
207
|
+
return seen.get(value)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (Array.isArray(value)) {
|
|
211
|
+
const copy = []
|
|
212
|
+
seen.set(value, copy)
|
|
213
|
+
|
|
214
|
+
for (const entry of value) {
|
|
215
|
+
copy.push(frozenSnapshot(entry, seen))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return Object.freeze(copy)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const prototype = Object.getPrototypeOf(value)
|
|
222
|
+
|
|
223
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
224
|
+
return value
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const copy = {}
|
|
228
|
+
seen.set(value, copy)
|
|
229
|
+
|
|
230
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
231
|
+
copy[key] = frozenSnapshot(entry, seen)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return Object.freeze(copy)
|
|
235
|
+
}
|
|
236
|
+
|
|
177
237
|
export class Runtime extends EventEmitter {
|
|
178
238
|
logger
|
|
179
239
|
error
|
|
@@ -194,19 +254,20 @@ export class Runtime extends EventEmitter {
|
|
|
194
254
|
#sharedContext
|
|
195
255
|
#isProduction
|
|
196
256
|
#concurrency
|
|
197
|
-
#entrypointId
|
|
198
|
-
#url
|
|
199
|
-
#entrypointPort
|
|
200
257
|
|
|
201
258
|
#healthMetricsTimer
|
|
202
259
|
#healthMetricsCollectionActive
|
|
203
260
|
|
|
204
261
|
#meshInterceptor
|
|
262
|
+
#meshCoordinator
|
|
263
|
+
#meshId
|
|
205
264
|
#dispatcher
|
|
206
265
|
|
|
207
266
|
#managementApi
|
|
208
267
|
#prometheusServer
|
|
209
268
|
#healthProbesServer
|
|
269
|
+
#metricsServersInitialized
|
|
270
|
+
#metricsServersStartPromise
|
|
210
271
|
#opentelemetryMetricsForwarder
|
|
211
272
|
#inspectorServer
|
|
212
273
|
#metricsLabelName
|
|
@@ -224,11 +285,11 @@ export class Runtime extends EventEmitter {
|
|
|
224
285
|
#extensionLivenessChecks
|
|
225
286
|
#extensionHealthRoutes
|
|
226
287
|
#lastOverloadProfiles
|
|
288
|
+
#servingStates
|
|
227
289
|
#restartingApplications
|
|
228
|
-
#
|
|
290
|
+
#replicas
|
|
229
291
|
#dynamicWorkersScaler
|
|
230
292
|
#nextWorkerIndex
|
|
231
|
-
#workerPortOffsets
|
|
232
293
|
|
|
233
294
|
#sharedHttpCache
|
|
234
295
|
#scheduler
|
|
@@ -255,22 +316,13 @@ export class Runtime extends EventEmitter {
|
|
|
255
316
|
this.#applications = new Map()
|
|
256
317
|
this.#applicationRestartCounts = new Map()
|
|
257
318
|
this.#workers = new RoundRobinMap()
|
|
258
|
-
this.#url = undefined
|
|
259
|
-
this.#entrypointPort = undefined
|
|
260
319
|
this.#channelCreationHook = createChannelCreationHook(this.#config)
|
|
261
|
-
this.#
|
|
262
|
-
domain: '.plt.local',
|
|
263
|
-
timeout: this.#config.applicationTimeout,
|
|
264
|
-
meshTimeout: this.#context.meshTimeout ?? true,
|
|
265
|
-
onChannelCreation: this.#channelCreationHook,
|
|
266
|
-
onError: this.#onMeshInterceptorError.bind(this)
|
|
267
|
-
})
|
|
320
|
+
this.#meshId = `runtime-${randomUUID()}`
|
|
268
321
|
this.logger = abstractLogger // This is replaced by the real logger in init() and eventually removed in close()
|
|
269
322
|
this.#status = undefined
|
|
270
323
|
this.#restartingApplications = new Set()
|
|
271
|
-
this.#
|
|
324
|
+
this.#replicas = new Map()
|
|
272
325
|
this.#nextWorkerIndex = new Map()
|
|
273
|
-
this.#workerPortOffsets = new Map() // fullWorkerId => portOffset
|
|
274
326
|
this.#sharedHttpCache = null
|
|
275
327
|
this.#applicationsConfigsPatches = new Map()
|
|
276
328
|
|
|
@@ -299,20 +351,26 @@ export class Runtime extends EventEmitter {
|
|
|
299
351
|
// Registered per-worker in #setupWorker, reserved so extensions cannot clobber it
|
|
300
352
|
this.#reservedITCHandlerNames.add('profiling:started')
|
|
301
353
|
this.#extensions = []
|
|
354
|
+
this.#metricsServersInitialized = false
|
|
355
|
+
this.#metricsServersStartPromise = null
|
|
302
356
|
this.#extensionsWantHealthMetrics = false
|
|
303
357
|
this.#extensionReadinessChecks = new Map()
|
|
304
358
|
this.#extensionLivenessChecks = new Map()
|
|
305
359
|
this.#extensionHealthRoutes = []
|
|
306
360
|
this.#lastOverloadProfiles = new Map()
|
|
361
|
+
this.#servingStates = new Map()
|
|
307
362
|
this.#sharedContext = {}
|
|
308
363
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
364
|
+
/*
|
|
365
|
+
`PLT_DEV` and `PLT_ENVIRONMENT` are no longer injected here: an application branches on
|
|
366
|
+
its own variables, or the decision moves into the configuration, where the context carries
|
|
367
|
+
`production` and `mode` with types. `NODE_ENV` is the one the runtime still defaults, at the
|
|
368
|
+
bottom of the env ladder, so anything the project sets outranks it.
|
|
369
|
+
|
|
370
|
+
They were worse than redundant by the end -- the worker environment is the one the loader
|
|
371
|
+
resolved per application, and these were written onto the runtime's own copy, so under
|
|
372
|
+
`wattpm start` an application still read `PLT_ENVIRONMENT=development`.
|
|
373
|
+
*/
|
|
316
374
|
}
|
|
317
375
|
|
|
318
376
|
async init () {
|
|
@@ -330,10 +388,9 @@ export class Runtime extends EventEmitter {
|
|
|
330
388
|
this.#metricsLabelName = 'applicationId'
|
|
331
389
|
}
|
|
332
390
|
|
|
333
|
-
// Initialize process-level metrics registry
|
|
391
|
+
// Initialize process-level metrics registry only when metrics are enabled.
|
|
334
392
|
// These metrics are the same across all workers and only need to be collected once
|
|
335
|
-
|
|
336
|
-
if (config.metrics || config.managementApi) {
|
|
393
|
+
if (config.metrics && config.metrics.enabled !== false) {
|
|
337
394
|
this.#processMetricsRegistry = new metricsClient.Registry()
|
|
338
395
|
collectProcessMetrics(this.#processMetricsRegistry)
|
|
339
396
|
}
|
|
@@ -354,13 +411,7 @@ export class Runtime extends EventEmitter {
|
|
|
354
411
|
this.#createWorkersBroadcastChannel()
|
|
355
412
|
|
|
356
413
|
if (this.#config.workers.dynamic) {
|
|
357
|
-
|
|
358
|
-
this.logger.warn(
|
|
359
|
-
`Worker scaler disabled because the "workers" configuration is set to ${this.#config.workers.static}.`
|
|
360
|
-
)
|
|
361
|
-
} else {
|
|
362
|
-
this.#dynamicWorkersScaler = new DynamicWorkersScaler(this, this.#config.workers)
|
|
363
|
-
}
|
|
414
|
+
this.#dynamicWorkersScaler = new PredictiveWorkersScaler(this, this.#config.workers)
|
|
364
415
|
}
|
|
365
416
|
|
|
366
417
|
// Load extensions before creating any worker so that custom ITC handlers
|
|
@@ -369,18 +420,28 @@ export class Runtime extends EventEmitter {
|
|
|
369
420
|
// readiness/liveness checks and probe routes before Fastify starts listening.
|
|
370
421
|
await this.#loadExtensions()
|
|
371
422
|
|
|
372
|
-
|
|
373
|
-
|
|
423
|
+
// A runtime without applications exits during start and must not briefly
|
|
424
|
+
// claim the default health-probe port. Applications added later, including
|
|
425
|
+
// by extension start hooks, initialize these servers in addApplications().
|
|
426
|
+
if (config.applications.length > 0) {
|
|
427
|
+
await this.#ensureMetricsServersStarted()
|
|
374
428
|
}
|
|
375
429
|
|
|
376
|
-
this.#
|
|
377
|
-
this.#
|
|
430
|
+
this.#meshCoordinator = createCoordinator({ meshId: this.#meshId })
|
|
431
|
+
this.#meshInterceptor = createInterceptor({
|
|
432
|
+
meshId: this.#meshId,
|
|
433
|
+
domain: '.plt.local',
|
|
434
|
+
connectTimeout: this.#config.applicationTimeout,
|
|
435
|
+
bootstrapTimeout: this.#config.applicationTimeout,
|
|
436
|
+
allowTarget: createTargetPermissionHook(this.#config)
|
|
437
|
+
})
|
|
438
|
+
await this.#meshInterceptor.ready
|
|
378
439
|
|
|
379
440
|
await this.addApplications(this.#config.applications)
|
|
380
441
|
await this.#setDispatcher(config.undici)
|
|
381
442
|
|
|
382
443
|
if (!this.#context.build) {
|
|
383
|
-
this.#scheduler = startScheduler(
|
|
444
|
+
this.#scheduler = startScheduler(logger)
|
|
384
445
|
}
|
|
385
446
|
|
|
386
447
|
this.#updateStatus('init')
|
|
@@ -407,7 +468,7 @@ export class Runtime extends EventEmitter {
|
|
|
407
468
|
if (this.getApplicationsIds().length === 0) {
|
|
408
469
|
this.#updateStatus('started')
|
|
409
470
|
await this.close(silent)
|
|
410
|
-
return
|
|
471
|
+
return {}
|
|
411
472
|
}
|
|
412
473
|
|
|
413
474
|
if (this.#config.inspectorOptions) {
|
|
@@ -450,19 +511,13 @@ export class Runtime extends EventEmitter {
|
|
|
450
511
|
|
|
451
512
|
this.#updateStatus('started')
|
|
452
513
|
|
|
453
|
-
// The CLI enables the module compile cache for this process. Node.js would only write it when
|
|
454
|
-
// the process terminates, so flush it now that the boot is complete to not lose it when the
|
|
455
|
-
// process is killed abruptly. This is a no-op when the compile cache is not enabled.
|
|
456
|
-
scheduleCompileCacheFlush()
|
|
457
|
-
|
|
458
514
|
// Start the global health metrics timer for all workers if needed
|
|
459
515
|
this.#startHealthMetricsCollectionIfNeeded()
|
|
460
516
|
|
|
461
517
|
await this.#dynamicWorkersScaler?.start()
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
return this.#url
|
|
518
|
+
this.#showUrls()
|
|
519
|
+
scheduleCompileCacheFlush(this.logger)
|
|
520
|
+
return this.getUrls()
|
|
466
521
|
}
|
|
467
522
|
|
|
468
523
|
async stop (silent = false) {
|
|
@@ -491,18 +546,14 @@ export class Runtime extends EventEmitter {
|
|
|
491
546
|
|
|
492
547
|
await this.#dynamicWorkersScaler?.stop()
|
|
493
548
|
|
|
494
|
-
// Stop the entrypoint first so that no new requests are accepted
|
|
495
|
-
if (this.#entrypointId) {
|
|
496
|
-
await this.stopApplication(this.#entrypointId, silent)
|
|
497
|
-
}
|
|
498
|
-
|
|
499
549
|
// Await extension stop hooks before stopping remaining applications so that
|
|
500
550
|
// control-plane extensions can settle work and hand off state first.
|
|
501
551
|
await this.#stopExtensions()
|
|
502
552
|
|
|
503
553
|
await this.stopApplications(this.getApplicationsIds(), silent)
|
|
504
554
|
|
|
505
|
-
await this.#meshInterceptor
|
|
555
|
+
await this.#meshInterceptor?.close()
|
|
556
|
+
this.#meshCoordinator?.destroy()
|
|
506
557
|
this.#workersBroadcastChannel?.close()
|
|
507
558
|
|
|
508
559
|
this.#updateStatus('stopped')
|
|
@@ -520,8 +571,6 @@ export class Runtime extends EventEmitter {
|
|
|
520
571
|
await this.restartApplications(toRestart)
|
|
521
572
|
|
|
522
573
|
this.emitAndNotify('restarted')
|
|
523
|
-
|
|
524
|
-
return this.#url
|
|
525
574
|
}
|
|
526
575
|
|
|
527
576
|
async close (silent = false) {
|
|
@@ -537,6 +586,7 @@ export class Runtime extends EventEmitter {
|
|
|
537
586
|
clearTimeout(this.#healthMetricsTimer)
|
|
538
587
|
this.#healthMetricsCollectionActive = false
|
|
539
588
|
this.#lastOverloadProfiles.clear()
|
|
589
|
+
this.#servingStates.clear()
|
|
540
590
|
|
|
541
591
|
await this.stop(silent)
|
|
542
592
|
this.#updateStatus('closing')
|
|
@@ -673,25 +723,8 @@ export class Runtime extends EventEmitter {
|
|
|
673
723
|
|
|
674
724
|
async addApplications (applications, start = false) {
|
|
675
725
|
const setupInvocations = []
|
|
676
|
-
// Per-worker ports do not need SO_REUSEPORT because each worker binds a different port.
|
|
677
|
-
const usesPerWorkerPorts = this.#config.server?.portAssignment === 'perWorkerIncrement'
|
|
678
|
-
|
|
679
726
|
const toStart = []
|
|
680
727
|
for (const application of applications) {
|
|
681
|
-
const workers = application.workers
|
|
682
|
-
|
|
683
|
-
if (
|
|
684
|
-
(workers.static > 1 || workers.minimum > 1) &&
|
|
685
|
-
application.entrypoint &&
|
|
686
|
-
!features.node.reusePort &&
|
|
687
|
-
!usesPerWorkerPorts
|
|
688
|
-
) {
|
|
689
|
-
this.logger.warn(
|
|
690
|
-
`"${application.id}" is set as the entrypoint, but reusePort is not available in your OS; setting workers to 1 instead of ${workers.static}`
|
|
691
|
-
)
|
|
692
|
-
application.workers = { dynamic: false, static: 1 }
|
|
693
|
-
}
|
|
694
|
-
|
|
695
728
|
this.#applications.set(application.id, application)
|
|
696
729
|
this.#applicationRestartCounts.set(application.id, this.#applicationRestartCounts.get(application.id) ?? 0)
|
|
697
730
|
setupInvocations.push([application])
|
|
@@ -700,8 +733,12 @@ export class Runtime extends EventEmitter {
|
|
|
700
733
|
|
|
701
734
|
await executeInParallel(this.#setupApplication.bind(this), setupInvocations, this.#concurrency)
|
|
702
735
|
|
|
736
|
+
if (applications.length > 0) {
|
|
737
|
+
await this.#ensureMetricsServersStarted()
|
|
738
|
+
}
|
|
739
|
+
|
|
703
740
|
for (const application of applications) {
|
|
704
|
-
this.logger.debug(`Added application "${application.id}"
|
|
741
|
+
this.logger.debug(`Added application "${application.id}".`)
|
|
705
742
|
this.emitAndNotify('application:added', application)
|
|
706
743
|
}
|
|
707
744
|
|
|
@@ -711,7 +748,7 @@ export class Runtime extends EventEmitter {
|
|
|
711
748
|
|
|
712
749
|
const created = []
|
|
713
750
|
for (const { id } of applications) {
|
|
714
|
-
created.push(await this
|
|
751
|
+
created.push(await this.#buildApplicationDetails(id))
|
|
715
752
|
}
|
|
716
753
|
|
|
717
754
|
this.#updateLoggingPrefixes()
|
|
@@ -719,10 +756,6 @@ export class Runtime extends EventEmitter {
|
|
|
719
756
|
}
|
|
720
757
|
|
|
721
758
|
async removeApplications (applications, silent = false) {
|
|
722
|
-
if (applications.includes(this.#entrypointId)) {
|
|
723
|
-
throw new CannotRemoveEntrypointError()
|
|
724
|
-
}
|
|
725
|
-
|
|
726
759
|
const removed = []
|
|
727
760
|
for (const application of applications) {
|
|
728
761
|
if (!this.#applications.has(application)) {
|
|
@@ -731,18 +764,23 @@ export class Runtime extends EventEmitter {
|
|
|
731
764
|
|
|
732
765
|
// Use allowUnloaded so that applications without a live worker
|
|
733
766
|
// (stopped or crashed with restartOnError: 0) can still be removed.
|
|
734
|
-
const details = await this
|
|
767
|
+
const details = await this.#buildApplicationDetails(application, true)
|
|
735
768
|
details.status = 'removed'
|
|
769
|
+
|
|
770
|
+
// The snapshot is taken while the application is still up, but what it reports is an
|
|
771
|
+
// application about to be gone: it is not making a claim about how it would serve.
|
|
772
|
+
delete details.servingState
|
|
773
|
+
|
|
736
774
|
removed.push(details)
|
|
737
775
|
}
|
|
738
776
|
|
|
739
777
|
await this.stopApplications(applications, silent, true)
|
|
740
778
|
|
|
741
779
|
for (const application of applications) {
|
|
742
|
-
this.#dynamicWorkersScaler?.remove(application)
|
|
743
780
|
await this.#scheduler?.removeApplicationJobs(application)
|
|
744
781
|
this.#applications.delete(application)
|
|
745
782
|
this.#applicationRestartCounts.delete(application)
|
|
783
|
+
this.#replicas.delete(application)
|
|
746
784
|
}
|
|
747
785
|
|
|
748
786
|
for (const application of applications) {
|
|
@@ -773,8 +811,24 @@ export class Runtime extends EventEmitter {
|
|
|
773
811
|
const levels = topologicalLevels(applications, dependencies)
|
|
774
812
|
|
|
775
813
|
for (const level of levels) {
|
|
776
|
-
const
|
|
777
|
-
|
|
814
|
+
const applicationsWithPort = await Promise.all(
|
|
815
|
+
level.map(async applicationId => {
|
|
816
|
+
const worker = await this.#getWorkerByIdOrNext(applicationId)
|
|
817
|
+
const applicationConfig = await sendViaITC(worker, 'getApplicationConfig')
|
|
818
|
+
const port = Number(applicationConfig?.server?.port)
|
|
819
|
+
return { applicationId, hasPort: Number.isInteger(port) && port > 0 }
|
|
820
|
+
})
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
for (const hasPort of [true, false]) {
|
|
824
|
+
const startInvocations = applicationsWithPort
|
|
825
|
+
.filter(application => application.hasPort === hasPort)
|
|
826
|
+
.map(({ applicationId }) => [applicationId, silent])
|
|
827
|
+
|
|
828
|
+
if (startInvocations.length > 0) {
|
|
829
|
+
await executeInParallel(this.startApplication.bind(this), startInvocations, this.#concurrency)
|
|
830
|
+
}
|
|
831
|
+
}
|
|
778
832
|
}
|
|
779
833
|
}
|
|
780
834
|
|
|
@@ -786,16 +840,21 @@ export class Runtime extends EventEmitter {
|
|
|
786
840
|
|
|
787
841
|
if (!skipDependencies) {
|
|
788
842
|
try {
|
|
789
|
-
const
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
843
|
+
const details = await executeWithTimeout(
|
|
844
|
+
this.getApplications(true),
|
|
845
|
+
this.#config.gracefulShutdown.application
|
|
846
|
+
)
|
|
847
|
+
if (details !== kTimeout) {
|
|
848
|
+
for (const application of details.applications) {
|
|
849
|
+
for (const dependency of application.dependencies ?? []) {
|
|
850
|
+
let applicationDependents = dependents[dependency]
|
|
851
|
+
if (!applicationDependents) {
|
|
852
|
+
applicationDependents = new Set()
|
|
853
|
+
dependents[dependency] = applicationDependents
|
|
854
|
+
}
|
|
797
855
|
|
|
798
|
-
|
|
856
|
+
applicationDependents.add(application.id)
|
|
857
|
+
}
|
|
799
858
|
}
|
|
800
859
|
}
|
|
801
860
|
} catch (e) {
|
|
@@ -804,11 +863,6 @@ export class Runtime extends EventEmitter {
|
|
|
804
863
|
}
|
|
805
864
|
|
|
806
865
|
for (const application of applicationsToStop) {
|
|
807
|
-
// The entrypoint has been stopped above
|
|
808
|
-
if (application === this.#entrypointId) {
|
|
809
|
-
continue
|
|
810
|
-
}
|
|
811
|
-
|
|
812
866
|
stopInvocations.push([application, silent, Array.from(dependents[application] ?? [])])
|
|
813
867
|
}
|
|
814
868
|
|
|
@@ -816,13 +870,39 @@ export class Runtime extends EventEmitter {
|
|
|
816
870
|
}
|
|
817
871
|
|
|
818
872
|
async restartApplications (applicationsToRestart) {
|
|
819
|
-
const restartInvocations = []
|
|
873
|
+
const restartInvocations = applicationsToRestart.map(application => [application, true])
|
|
874
|
+
const restarts = await executeInParallel(
|
|
875
|
+
this.restartApplication.bind(this),
|
|
876
|
+
restartInvocations,
|
|
877
|
+
this.#concurrency,
|
|
878
|
+
false
|
|
879
|
+
)
|
|
880
|
+
const failed = restarts.filter(result => result instanceof Error)
|
|
881
|
+
const succeeded = restarts.filter(result => result && !(result instanceof Error))
|
|
820
882
|
|
|
821
|
-
|
|
822
|
-
|
|
883
|
+
if (failed.length > 0) {
|
|
884
|
+
await Promise.allSettled(succeeded.map(restart => restart.discard()))
|
|
885
|
+
throw failed[0]
|
|
823
886
|
}
|
|
824
887
|
|
|
825
|
-
|
|
888
|
+
const results = await executeInParallel(
|
|
889
|
+
retirement => retirement.retire(),
|
|
890
|
+
succeeded.flatMap(({ retirements }) => retirements.map(retirement => [retirement])),
|
|
891
|
+
this.#concurrency,
|
|
892
|
+
false
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
const error = results.find(result => result instanceof Error)
|
|
896
|
+
if (error) {
|
|
897
|
+
for (const restart of succeeded) {
|
|
898
|
+
restart.release()
|
|
899
|
+
}
|
|
900
|
+
throw error
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
for (const restart of succeeded) {
|
|
904
|
+
await restart.complete()
|
|
905
|
+
}
|
|
826
906
|
}
|
|
827
907
|
|
|
828
908
|
async startApplication (id, silent = false) {
|
|
@@ -833,26 +913,33 @@ export class Runtime extends EventEmitter {
|
|
|
833
913
|
throw new ApplicationNotFoundError(id, this.getApplicationsIds().join(', '))
|
|
834
914
|
}
|
|
835
915
|
|
|
916
|
+
if (this.#isApplicationStarted(id)) {
|
|
917
|
+
throw new ApplicationAlreadyStartedError()
|
|
918
|
+
}
|
|
919
|
+
|
|
836
920
|
const workers = applicationConfig.workers.static
|
|
837
|
-
|
|
838
|
-
const worker = this.#workers.get(`${id}:${i}`)
|
|
839
|
-
const status = worker?.[kWorkerStatus]
|
|
921
|
+
this.emitAndNotify('application:starting', id)
|
|
840
922
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
923
|
+
const compileCache = {
|
|
924
|
+
...normalizeCompileCache(config.compileCache),
|
|
925
|
+
...normalizeCompileCache(applicationConfig.compileCache)
|
|
844
926
|
}
|
|
927
|
+
const awaitFirstWorker =
|
|
928
|
+
typeof compileCache === 'object' && compileCache.enabled !== false && compileCache.awaitFirstWorker === true
|
|
845
929
|
|
|
846
|
-
|
|
930
|
+
if (awaitFirstWorker && workers > 0) {
|
|
931
|
+
await this.#startFirstWorkerWithCompileCache(config, applicationConfig, workers, id, silent)
|
|
932
|
+
}
|
|
847
933
|
|
|
848
|
-
|
|
934
|
+
// Keep the first worker separate so compile-cache startup can be made a barrier without changing this loop later.
|
|
935
|
+
for (let i = awaitFirstWorker ? 1 : 0; i < workers; i++) {
|
|
849
936
|
await this.#startWorker(config, applicationConfig, workers, id, i, silent)
|
|
850
937
|
}
|
|
851
938
|
|
|
852
939
|
await this.#registerApplicationSchedulerJobs(id)
|
|
940
|
+
await this.#collectServingState(id)
|
|
853
941
|
|
|
854
942
|
this.emitAndNotify('application:started', id)
|
|
855
|
-
await this.#dynamicWorkersScaler?.applyPendingUpdate(id)
|
|
856
943
|
}
|
|
857
944
|
|
|
858
945
|
async stopApplication (id, silent = false, dependents = []) {
|
|
@@ -860,7 +947,17 @@ export class Runtime extends EventEmitter {
|
|
|
860
947
|
throw new ApplicationNotFoundError(id, this.getApplicationsIds().join(', '))
|
|
861
948
|
}
|
|
862
949
|
|
|
950
|
+
this.#dynamicWorkersScaler?.remove(id)
|
|
951
|
+
|
|
952
|
+
// Removing the replicas cancels the work in progress for them, i.e. refills of crashed workers and replacements
|
|
953
|
+
const replicas = this.#replicas.get(id) ?? []
|
|
954
|
+
this.#replicas.set(id, [])
|
|
955
|
+
const detached = Promise.all(replicas.map(replica => this.#detachReplica(id, replica)))
|
|
956
|
+
|
|
957
|
+
// Workers being discarded by the detach above are already marked as exited
|
|
863
958
|
const workersIds = this.#workers.getKeys(id)
|
|
959
|
+
.filter(workerId => this.#workers.get(workerId)[kWorkerStatus] !== 'exited')
|
|
960
|
+
|
|
864
961
|
const workersCount = workersIds.length
|
|
865
962
|
|
|
866
963
|
this.emitAndNotify('application:stopping', id)
|
|
@@ -876,10 +973,17 @@ export class Runtime extends EventEmitter {
|
|
|
876
973
|
await executeInParallel(this.#stopWorker.bind(this), stopInvocations, this.#concurrency)
|
|
877
974
|
}
|
|
878
975
|
|
|
976
|
+
await detached
|
|
977
|
+
|
|
978
|
+
// Absent rather than 'inactive': a stopped application is not making a claim about how it
|
|
979
|
+
// would serve, and conflating "not started" with "started and serving nothing" is the exact
|
|
980
|
+
// distinction this field exists to draw.
|
|
981
|
+
this.#servingStates.delete(id)
|
|
982
|
+
|
|
879
983
|
this.emitAndNotify('application:stopped', id)
|
|
880
984
|
}
|
|
881
985
|
|
|
882
|
-
async restartApplication (id) {
|
|
986
|
+
async restartApplication (id, deferOldWorkerRetirement = false) {
|
|
883
987
|
const applicationConfig = this.#applications.get(id)
|
|
884
988
|
|
|
885
989
|
if (!applicationConfig) {
|
|
@@ -898,31 +1002,75 @@ export class Runtime extends EventEmitter {
|
|
|
898
1002
|
}
|
|
899
1003
|
|
|
900
1004
|
this.#restartingApplications.add(id)
|
|
1005
|
+
const retirements = []
|
|
1006
|
+
let deferred = false
|
|
901
1007
|
|
|
902
1008
|
try {
|
|
903
1009
|
const config = this.#config
|
|
904
|
-
|
|
905
|
-
|
|
1010
|
+
// A copy: while the loop awaits, scale-downs remove replicas from the list, which would shift the remaining ones
|
|
1011
|
+
// and skip some. Replicas added in the meanwhile (scale-up) already run fresh workers.
|
|
1012
|
+
const replicas = [...(this.#replicas.get(id) ?? [])]
|
|
1013
|
+
const replicasCount = replicas.length
|
|
906
1014
|
|
|
907
1015
|
this.emitAndNotify('application:restarting', id)
|
|
908
1016
|
|
|
909
|
-
for (let i = 0; i <
|
|
910
|
-
const
|
|
911
|
-
const worker = this.#workers.get(workerId)
|
|
912
|
-
const workerIndex = parseInt(workerId.split(':')[1], 10)
|
|
1017
|
+
for (let i = 0; i < replicasCount; i++) {
|
|
1018
|
+
const replica = replicas[i]
|
|
913
1019
|
|
|
914
1020
|
if (i > 0 && config.workersRestartDelay > 0) {
|
|
915
1021
|
await sleep(config.workersRestartDelay)
|
|
916
1022
|
}
|
|
917
1023
|
|
|
918
|
-
|
|
1024
|
+
// The replica might have been removed while the previous ones were being replaced,
|
|
1025
|
+
// or its worker crashed and the replica is being refilled with a fresh worker.
|
|
1026
|
+
const worker = replica.worker
|
|
1027
|
+
if (replica.abortController.signal.aborted || !worker) {
|
|
1028
|
+
continue
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
const retirement = await this.#replaceWorker(
|
|
1032
|
+
config,
|
|
1033
|
+
applicationConfig,
|
|
1034
|
+
replicasCount,
|
|
1035
|
+
id,
|
|
1036
|
+
worker[kWorkerId],
|
|
1037
|
+
worker,
|
|
1038
|
+
true,
|
|
1039
|
+
deferOldWorkerRetirement
|
|
1040
|
+
)
|
|
1041
|
+
if (retirement) {
|
|
1042
|
+
retirements.push(retirement)
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
if (deferOldWorkerRetirement) {
|
|
1047
|
+
deferred = true
|
|
1048
|
+
return {
|
|
1049
|
+
retirements,
|
|
1050
|
+
release: () => this.#restartingApplications.delete(id),
|
|
1051
|
+
discard: async () => {
|
|
1052
|
+
await Promise.allSettled(retirements.map(retirement => retirement.discard()))
|
|
1053
|
+
this.#restartingApplications.delete(id)
|
|
1054
|
+
},
|
|
1055
|
+
complete: async () => {
|
|
1056
|
+
try {
|
|
1057
|
+
await this.#registerApplicationSchedulerJobs(id)
|
|
1058
|
+
this.#incrementApplicationRestartCount(id)
|
|
1059
|
+
this.emitAndNotify('application:restarted', id)
|
|
1060
|
+
} finally {
|
|
1061
|
+
this.#restartingApplications.delete(id)
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
919
1065
|
}
|
|
920
1066
|
|
|
921
1067
|
await this.#registerApplicationSchedulerJobs(id)
|
|
922
1068
|
this.#incrementApplicationRestartCount(id)
|
|
923
1069
|
this.emitAndNotify('application:restarted', id)
|
|
924
1070
|
} finally {
|
|
925
|
-
|
|
1071
|
+
if (!deferred) {
|
|
1072
|
+
this.#restartingApplications.delete(id)
|
|
1073
|
+
}
|
|
926
1074
|
}
|
|
927
1075
|
}
|
|
928
1076
|
|
|
@@ -994,13 +1142,7 @@ export class Runtime extends EventEmitter {
|
|
|
994
1142
|
|
|
995
1143
|
if (!allWorkers || hasWorkerIndex(id)) {
|
|
996
1144
|
const service = await this.#getApplicationWorkerForProfiling(id, ensureStarted)
|
|
997
|
-
|
|
998
|
-
service[kConfig].nodeModulesSourceMaps ?? this.#config.nodeModulesSourceMaps
|
|
999
|
-
|
|
1000
|
-
return sendViaITC(service, 'startProfiling', {
|
|
1001
|
-
...profilingOptions,
|
|
1002
|
-
nodeModulesSourceMaps
|
|
1003
|
-
})
|
|
1145
|
+
return sendViaITC(service, 'startProfiling', profilingOptions)
|
|
1004
1146
|
}
|
|
1005
1147
|
|
|
1006
1148
|
const started = []
|
|
@@ -1009,14 +1151,7 @@ export class Runtime extends EventEmitter {
|
|
|
1009
1151
|
|
|
1010
1152
|
for (const { workerIndex, worker } of await this.#getApplicationWorkersForProfiling(id, ensureStarted)) {
|
|
1011
1153
|
try {
|
|
1012
|
-
|
|
1013
|
-
worker[kConfig].nodeModulesSourceMaps ??
|
|
1014
|
-
this.#config.nodeModulesSourceMaps
|
|
1015
|
-
|
|
1016
|
-
await sendViaITC(worker, 'startProfiling', {
|
|
1017
|
-
...profilingOptions,
|
|
1018
|
-
nodeModulesSourceMaps
|
|
1019
|
-
})
|
|
1154
|
+
await sendViaITC(worker, 'startProfiling', profilingOptions)
|
|
1020
1155
|
started.push(workerIndex)
|
|
1021
1156
|
} catch (error) {
|
|
1022
1157
|
// A worker which is already being profiled is considered covered, but
|
|
@@ -1300,6 +1435,8 @@ export class Runtime extends EventEmitter {
|
|
|
1300
1435
|
this.#healthProbesServer = null
|
|
1301
1436
|
}
|
|
1302
1437
|
|
|
1438
|
+
this.#metricsServersInitialized = false
|
|
1439
|
+
|
|
1303
1440
|
if (this.#opentelemetryMetricsForwarder) {
|
|
1304
1441
|
await this.#opentelemetryMetricsForwarder.close()
|
|
1305
1442
|
this.#opentelemetryMetricsForwarder = null
|
|
@@ -1313,10 +1450,7 @@ export class Runtime extends EventEmitter {
|
|
|
1313
1450
|
entry.applied = false
|
|
1314
1451
|
}
|
|
1315
1452
|
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
this.#healthProbesServer = await startHealthProbesServer(this, metricsConfig, this.#config.healthProbes)
|
|
1319
|
-
this.#assertExtensionHealthRoutesApplied()
|
|
1453
|
+
await this.#ensureMetricsServersStarted()
|
|
1320
1454
|
|
|
1321
1455
|
await this.#startOpenTelemetryMetricsForwarder(metricsConfig?.opentelemetry)
|
|
1322
1456
|
|
|
@@ -1338,6 +1472,41 @@ export class Runtime extends EventEmitter {
|
|
|
1338
1472
|
return { success: true, config: metricsConfig }
|
|
1339
1473
|
}
|
|
1340
1474
|
|
|
1475
|
+
async #ensureMetricsServersStarted () {
|
|
1476
|
+
if (this.#metricsServersInitialized) {
|
|
1477
|
+
return
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
if (this.#metricsServersStartPromise) {
|
|
1481
|
+
return this.#metricsServersStartPromise
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
this.#metricsServersStartPromise = (async () => {
|
|
1485
|
+
const config = this.#config
|
|
1486
|
+
|
|
1487
|
+
try {
|
|
1488
|
+
if (config.metrics || (typeof config.healthProbes === 'object' && config.healthProbes !== null)) {
|
|
1489
|
+
this.#prometheusServer = await startPrometheusServer(this, config.metrics ?? false, config.healthProbes)
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
this.#healthProbesServer = await startHealthProbesServer(this, config.metrics, config.healthProbes)
|
|
1493
|
+
this.#assertExtensionHealthRoutesApplied()
|
|
1494
|
+
this.#metricsServersInitialized = true
|
|
1495
|
+
} catch (err) {
|
|
1496
|
+
await Promise.allSettled([this.#prometheusServer?.close(), this.#healthProbesServer?.close()])
|
|
1497
|
+
this.#prometheusServer = null
|
|
1498
|
+
this.#healthProbesServer = null
|
|
1499
|
+
throw err
|
|
1500
|
+
}
|
|
1501
|
+
})()
|
|
1502
|
+
|
|
1503
|
+
try {
|
|
1504
|
+
await this.#metricsServersStartPromise
|
|
1505
|
+
} finally {
|
|
1506
|
+
this.#metricsServersStartPromise = null
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1341
1510
|
async #startOpenTelemetryMetricsForwarder (config) {
|
|
1342
1511
|
if (!config?.endpoint || config.enabled === false || config.enabled === 'false') {
|
|
1343
1512
|
return
|
|
@@ -1490,7 +1659,7 @@ export class Runtime extends EventEmitter {
|
|
|
1490
1659
|
const { applicationId, config: applicationConfig, workers, health, currentWorkers, currentHealth } = update
|
|
1491
1660
|
|
|
1492
1661
|
if (workers && health) {
|
|
1493
|
-
const
|
|
1662
|
+
const resourceReport = await this.#updateApplicationWorkersAndHealth(
|
|
1494
1663
|
applicationId,
|
|
1495
1664
|
config,
|
|
1496
1665
|
applicationConfig,
|
|
@@ -1501,11 +1670,11 @@ export class Runtime extends EventEmitter {
|
|
|
1501
1670
|
)
|
|
1502
1671
|
report.push({
|
|
1503
1672
|
application: applicationId,
|
|
1504
|
-
workers:
|
|
1505
|
-
health:
|
|
1673
|
+
workers: resourceReport.workers,
|
|
1674
|
+
health: resourceReport.health
|
|
1506
1675
|
})
|
|
1507
1676
|
} else if (health) {
|
|
1508
|
-
const
|
|
1677
|
+
const healthReport = await this.#updateApplicationHealth(
|
|
1509
1678
|
applicationId,
|
|
1510
1679
|
config,
|
|
1511
1680
|
applicationConfig,
|
|
@@ -1515,10 +1684,10 @@ export class Runtime extends EventEmitter {
|
|
|
1515
1684
|
)
|
|
1516
1685
|
report.push({
|
|
1517
1686
|
application: applicationId,
|
|
1518
|
-
health:
|
|
1687
|
+
health: healthReport
|
|
1519
1688
|
})
|
|
1520
1689
|
} else if (workers) {
|
|
1521
|
-
const
|
|
1690
|
+
const workersReport = await this.#updateApplicationWorkers(
|
|
1522
1691
|
applicationId,
|
|
1523
1692
|
config,
|
|
1524
1693
|
applicationConfig,
|
|
@@ -1527,7 +1696,7 @@ export class Runtime extends EventEmitter {
|
|
|
1527
1696
|
)
|
|
1528
1697
|
report.push({
|
|
1529
1698
|
application: applicationId,
|
|
1530
|
-
workers:
|
|
1699
|
+
workers: workersReport
|
|
1531
1700
|
})
|
|
1532
1701
|
}
|
|
1533
1702
|
}
|
|
@@ -1543,8 +1712,20 @@ export class Runtime extends EventEmitter {
|
|
|
1543
1712
|
return this.#root
|
|
1544
1713
|
}
|
|
1545
1714
|
|
|
1546
|
-
|
|
1547
|
-
|
|
1715
|
+
getUrls (applicationId) {
|
|
1716
|
+
const urls = {}
|
|
1717
|
+
const applicationIds = applicationId ? [applicationId] : this.#applications.keys()
|
|
1718
|
+
|
|
1719
|
+
for (const id of applicationIds) {
|
|
1720
|
+
for (const workerId of this.#workers.getKeys(id)) {
|
|
1721
|
+
const url = this.#workers.get(workerId)?.[kWorkerUrl]
|
|
1722
|
+
if (url) {
|
|
1723
|
+
urls[workerId] = url
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
return urls
|
|
1548
1729
|
}
|
|
1549
1730
|
|
|
1550
1731
|
getRuntimeStatus () {
|
|
@@ -1553,8 +1734,6 @@ export class Runtime extends EventEmitter {
|
|
|
1553
1734
|
|
|
1554
1735
|
async getRuntimeMetadata () {
|
|
1555
1736
|
const packageJson = await this.#getRuntimePackageJson()
|
|
1556
|
-
const entrypointDetails = await this.getEntrypointDetails()
|
|
1557
|
-
|
|
1558
1737
|
return {
|
|
1559
1738
|
pid: process.pid,
|
|
1560
1739
|
cwd: process.cwd(),
|
|
@@ -1563,10 +1742,18 @@ export class Runtime extends EventEmitter {
|
|
|
1563
1742
|
execPath: process.execPath,
|
|
1564
1743
|
nodeVersion: process.version,
|
|
1565
1744
|
projectDir: this.#root,
|
|
1745
|
+
/*
|
|
1746
|
+
What `applications:add`/`remove --save` actually consume, so they can stop reading the whole
|
|
1747
|
+
runtime configuration over HTTP to get at three values. `autoload` is the declaration as
|
|
1748
|
+
authored, not the expansion: it is expanded in the eval worker, and --save has to edit what
|
|
1749
|
+
the file says rather than what it produced.
|
|
1750
|
+
*/
|
|
1751
|
+
configPath: this.#config[kMetadata]?.path ?? null,
|
|
1752
|
+
autoload: this.#config.autoload ?? null,
|
|
1566
1753
|
packageName: packageJson.name ?? null,
|
|
1567
1754
|
packageVersion: packageJson.version ?? null,
|
|
1568
|
-
|
|
1569
|
-
|
|
1755
|
+
platformaticVersion: version,
|
|
1756
|
+
urls: this.getUrls()
|
|
1570
1757
|
}
|
|
1571
1758
|
}
|
|
1572
1759
|
|
|
@@ -1574,13 +1761,31 @@ export class Runtime extends EventEmitter {
|
|
|
1574
1761
|
return this.#env
|
|
1575
1762
|
}
|
|
1576
1763
|
|
|
1764
|
+
/*
|
|
1765
|
+
What a watcher has to follow to know this configuration changed. The whole set the evaluation
|
|
1766
|
+
read is reported; resolving per worker had no such set, so it was the deciding file alone --
|
|
1767
|
+
which is what dev watched before either way.
|
|
1768
|
+
*/
|
|
1769
|
+
getConfigurationWatchTargets () {
|
|
1770
|
+
const metadata = this.#config[kMetadata]
|
|
1771
|
+
const targets = metadata?.loader?.watchTargets
|
|
1772
|
+
|
|
1773
|
+
if (targets) {
|
|
1774
|
+
return { files: [...targets.files], directories: [...targets.directories] }
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
return { files: metadata?.path ? [metadata.path] : [], directories: [] }
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1577
1780
|
getRuntimeConfig (includeMeta = false) {
|
|
1781
|
+
// includeMeta is an internal contract and hands back live state, symbol key and all. It leaves
|
|
1782
|
+
// the public surface with the DTO change; until then, its in-tree callers depend on identity.
|
|
1578
1783
|
if (includeMeta) {
|
|
1579
1784
|
return this.#config
|
|
1580
1785
|
}
|
|
1581
1786
|
|
|
1582
1787
|
const { [kMetadata]: _, ...config } = this.#config
|
|
1583
|
-
return config
|
|
1788
|
+
return frozenSnapshot(config)
|
|
1584
1789
|
}
|
|
1585
1790
|
|
|
1586
1791
|
getInterceptor () {
|
|
@@ -1599,12 +1804,39 @@ export class Runtime extends EventEmitter {
|
|
|
1599
1804
|
return this.#managementApi?.server.address() ?? null
|
|
1600
1805
|
}
|
|
1601
1806
|
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1807
|
+
/*
|
|
1808
|
+
servingState is computed per worker: for a worker-classified capability it depends on what the
|
|
1809
|
+
application's factory returned in *that* worker, and nothing stops arbitrary code from returning
|
|
1810
|
+
a server from worker 0 and a background result from worker 1. Sampling one worker -- which is
|
|
1811
|
+
what getApplicationDetails does for every other field -- would make the reported value depend on
|
|
1812
|
+
which worker the selector picked, and, worse, would leave mesh dispatch routing a share of
|
|
1813
|
+
requests to a worker that destroys them. So every worker answers and a mixed answer is refused,
|
|
1814
|
+
naming each worker and the state it reported.
|
|
1815
|
+
*/
|
|
1816
|
+
async #collectServingState (id) {
|
|
1817
|
+
const invocations = this.#workers.getKeys(id).map(workerId => [workerId, this.#workers.get(workerId)])
|
|
1818
|
+
|
|
1819
|
+
if (invocations.length === 0) {
|
|
1820
|
+
this.#servingStates.delete(id)
|
|
1821
|
+
return
|
|
1605
1822
|
}
|
|
1606
1823
|
|
|
1607
|
-
|
|
1824
|
+
const states = await sendMultipleViaITC(invocations, 'getServingState', undefined, [], this.#concurrency)
|
|
1825
|
+
const reported = Object.entries(states).filter(([, state]) => typeof state === 'string')
|
|
1826
|
+
|
|
1827
|
+
if (reported.length === 0) {
|
|
1828
|
+
this.#servingStates.delete(id)
|
|
1829
|
+
return
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
const distinct = new Set(reported.map(([, state]) => state))
|
|
1833
|
+
|
|
1834
|
+
if (distinct.size > 1) {
|
|
1835
|
+
this.#servingStates.delete(id)
|
|
1836
|
+
throw new MixedServingStateError(id, reported.map(([worker, state]) => `${worker} reported ${state}`).join(', '))
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
this.#servingStates.set(id, reported[0][1])
|
|
1608
1840
|
}
|
|
1609
1841
|
|
|
1610
1842
|
async getCustomHealthChecks () {
|
|
@@ -1758,7 +1990,7 @@ export class Runtime extends EventEmitter {
|
|
|
1758
1990
|
// Drop any configured custom label that shares the name of the application
|
|
1759
1991
|
// label (a config can set both `applicationLabel: 'serviceId'` and a static
|
|
1760
1992
|
// `serviceId` label): keeping it would make these runtime-wide metrics look
|
|
1761
|
-
// like they belong to an application
|
|
1993
|
+
// like they belong to an application during metrics aggregation.
|
|
1762
1994
|
const processLabels = { ...this.#config.metrics?.labels }
|
|
1763
1995
|
delete processLabels[this.#metricsLabelName]
|
|
1764
1996
|
this.#applyLabelsToMetrics(processMetricsJson, processLabels, processMetrics)
|
|
@@ -1923,180 +2155,41 @@ export class Runtime extends EventEmitter {
|
|
|
1923
2155
|
return output
|
|
1924
2156
|
}
|
|
1925
2157
|
|
|
1926
|
-
async getFormattedMetrics () {
|
|
1927
|
-
try {
|
|
1928
|
-
const { metrics } = await this.getMetrics()
|
|
1929
|
-
|
|
1930
|
-
if (metrics === null || metrics.length === 0) {
|
|
1931
|
-
return null
|
|
1932
|
-
}
|
|
1933
|
-
|
|
1934
|
-
const metricsNames = [
|
|
1935
|
-
'process_cpu_percent_usage',
|
|
1936
|
-
'process_resident_memory_bytes',
|
|
1937
|
-
'nodejs_heap_size_total_bytes',
|
|
1938
|
-
'nodejs_heap_size_used_bytes',
|
|
1939
|
-
'nodejs_heap_space_size_total_bytes',
|
|
1940
|
-
'nodejs_eventloop_utilization',
|
|
1941
|
-
'http_request_all_summary_seconds'
|
|
1942
|
-
]
|
|
1943
|
-
|
|
1944
|
-
const applicationsMetrics = {}
|
|
1945
|
-
|
|
1946
|
-
// Process-level metrics are reported only once for the whole runtime, without
|
|
1947
|
-
// an application label, since they are shared by all the applications running
|
|
1948
|
-
// in worker threads (see issue #3332). Applications running as separate OS
|
|
1949
|
-
// processes report their own labeled values, which take precedence below.
|
|
1950
|
-
const runtimeProcessMetrics = {}
|
|
1951
|
-
const applicationProcessMetrics = new Set()
|
|
1952
|
-
|
|
1953
|
-
for (const metric of metrics) {
|
|
1954
|
-
const { name, values } = metric
|
|
1955
|
-
|
|
1956
|
-
if (!metricsNames.includes(name)) continue
|
|
1957
|
-
if (!values || values.length === 0) continue
|
|
1958
|
-
|
|
1959
|
-
const labels = values[0].labels
|
|
1960
|
-
// Use the configured label name (serviceId for v2 compatibility, applicationId for v3+)
|
|
1961
|
-
const applicationId = labels?.[this.#metricsLabelName]
|
|
1962
|
-
|
|
1963
|
-
if (!applicationId) {
|
|
1964
|
-
if (name === 'process_cpu_percent_usage') {
|
|
1965
|
-
runtimeProcessMetrics.cpu = values[0].value
|
|
1966
|
-
continue
|
|
1967
|
-
}
|
|
1968
|
-
if (name === 'process_resident_memory_bytes') {
|
|
1969
|
-
runtimeProcessMetrics.rss = values[0].value
|
|
1970
|
-
continue
|
|
1971
|
-
}
|
|
1972
|
-
|
|
1973
|
-
throw new Error(`Missing ${this.#metricsLabelName} label in metrics`)
|
|
1974
|
-
}
|
|
1975
|
-
|
|
1976
|
-
if (name === 'process_cpu_percent_usage' || name === 'process_resident_memory_bytes') {
|
|
1977
|
-
applicationProcessMetrics.add(`${applicationId}:${name}`)
|
|
1978
|
-
}
|
|
1979
|
-
|
|
1980
|
-
let applicationMetrics = applicationsMetrics[applicationId]
|
|
1981
|
-
if (!applicationMetrics) {
|
|
1982
|
-
applicationMetrics = {
|
|
1983
|
-
cpu: 0,
|
|
1984
|
-
rss: 0,
|
|
1985
|
-
totalHeapSize: 0,
|
|
1986
|
-
usedHeapSize: 0,
|
|
1987
|
-
newSpaceSize: 0,
|
|
1988
|
-
oldSpaceSize: 0,
|
|
1989
|
-
elu: 0,
|
|
1990
|
-
latency: {
|
|
1991
|
-
p50: 0,
|
|
1992
|
-
p90: 0,
|
|
1993
|
-
p95: 0,
|
|
1994
|
-
p99: 0
|
|
1995
|
-
}
|
|
1996
|
-
}
|
|
1997
|
-
applicationsMetrics[applicationId] = applicationMetrics
|
|
1998
|
-
}
|
|
1999
|
-
|
|
2000
|
-
parsePromMetric(applicationMetrics, metric)
|
|
2001
|
-
}
|
|
2002
|
-
|
|
2003
|
-
// Apply the runtime-wide process-level values to every application that did
|
|
2004
|
-
// not report its own (i.e. every application running in a worker thread).
|
|
2005
|
-
for (const [applicationId, applicationMetrics] of Object.entries(applicationsMetrics)) {
|
|
2006
|
-
if (
|
|
2007
|
-
runtimeProcessMetrics.cpu !== undefined &&
|
|
2008
|
-
!applicationProcessMetrics.has(`${applicationId}:process_cpu_percent_usage`)
|
|
2009
|
-
) {
|
|
2010
|
-
applicationMetrics.cpu = runtimeProcessMetrics.cpu
|
|
2011
|
-
}
|
|
2012
|
-
|
|
2013
|
-
if (
|
|
2014
|
-
runtimeProcessMetrics.rss !== undefined &&
|
|
2015
|
-
!applicationProcessMetrics.has(`${applicationId}:process_resident_memory_bytes`)
|
|
2016
|
-
) {
|
|
2017
|
-
applicationMetrics.rss = runtimeProcessMetrics.rss
|
|
2018
|
-
}
|
|
2019
|
-
}
|
|
2020
|
-
|
|
2021
|
-
function parsePromMetric (applicationMetrics, promMetric) {
|
|
2022
|
-
const { name } = promMetric
|
|
2023
|
-
|
|
2024
|
-
if (name === 'process_cpu_percent_usage') {
|
|
2025
|
-
applicationMetrics.cpu = promMetric.values[0].value
|
|
2026
|
-
return
|
|
2027
|
-
}
|
|
2028
|
-
if (name === 'process_resident_memory_bytes') {
|
|
2029
|
-
applicationMetrics.rss = promMetric.values[0].value
|
|
2030
|
-
return
|
|
2031
|
-
}
|
|
2032
|
-
if (name === 'nodejs_heap_size_total_bytes') {
|
|
2033
|
-
applicationMetrics.totalHeapSize = promMetric.values[0].value
|
|
2034
|
-
return
|
|
2035
|
-
}
|
|
2036
|
-
if (name === 'nodejs_heap_size_used_bytes') {
|
|
2037
|
-
applicationMetrics.usedHeapSize = promMetric.values[0].value
|
|
2038
|
-
return
|
|
2039
|
-
}
|
|
2040
|
-
if (name === 'nodejs_heap_space_size_total_bytes') {
|
|
2041
|
-
const newSpaceSize = promMetric.values.find(value => value.labels.space === 'new')
|
|
2042
|
-
const oldSpaceSize = promMetric.values.find(value => value.labels.space === 'old')
|
|
2043
|
-
|
|
2044
|
-
applicationMetrics.newSpaceSize = newSpaceSize.value
|
|
2045
|
-
applicationMetrics.oldSpaceSize = oldSpaceSize.value
|
|
2046
|
-
return
|
|
2047
|
-
}
|
|
2048
|
-
if (name === 'nodejs_eventloop_utilization') {
|
|
2049
|
-
applicationMetrics.elu = promMetric.values[0].value
|
|
2050
|
-
return
|
|
2051
|
-
}
|
|
2052
|
-
if (name === 'http_request_all_summary_seconds') {
|
|
2053
|
-
applicationMetrics.latency = {
|
|
2054
|
-
p50: promMetric.values.find(value => value.labels.quantile === 0.5)?.value || 0,
|
|
2055
|
-
p90: promMetric.values.find(value => value.labels.quantile === 0.9)?.value || 0,
|
|
2056
|
-
p95: promMetric.values.find(value => value.labels.quantile === 0.95)?.value || 0,
|
|
2057
|
-
p99: promMetric.values.find(value => value.labels.quantile === 0.99)?.value || 0
|
|
2058
|
-
}
|
|
2059
|
-
}
|
|
2060
|
-
}
|
|
2061
|
-
|
|
2062
|
-
return {
|
|
2063
|
-
version: 1,
|
|
2064
|
-
date: new Date().toISOString(),
|
|
2065
|
-
applications: applicationsMetrics
|
|
2066
|
-
}
|
|
2067
|
-
} catch (err) {
|
|
2068
|
-
// If any metric is missing, return nothing
|
|
2069
|
-
this.logger.warn({ err }, 'Cannot fetch metrics')
|
|
2070
|
-
|
|
2071
|
-
return null
|
|
2072
|
-
}
|
|
2073
|
-
}
|
|
2074
|
-
|
|
2075
2158
|
getSharedContext () {
|
|
2076
2159
|
return this.#sharedContext
|
|
2077
2160
|
}
|
|
2078
2161
|
|
|
2079
2162
|
async getApplicationResourcesInfo (id) {
|
|
2080
|
-
const
|
|
2163
|
+
const workers = this.#replicas.get(id)?.length ?? 0
|
|
2081
2164
|
// Use round-robin to get any available worker instead of assuming index 0 exists
|
|
2082
2165
|
const worker = await this.#getWorkerByIdOrNext(id, null, false, false)
|
|
2083
|
-
const health = worker[kConfig].health
|
|
2084
2166
|
|
|
2085
|
-
|
|
2167
|
+
if (worker) {
|
|
2168
|
+
return { workers, health: worker[kConfig].health }
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
// No worker can answer (the application is stopped or its only worker is being restarted), use the configuration
|
|
2172
|
+
const applicationConfig = this.#applications.get(id)
|
|
2173
|
+
const health = deepmerge(this.#config.health ?? {}, applicationConfig.health ?? {})
|
|
2174
|
+
|
|
2175
|
+
return { workers, health }
|
|
2086
2176
|
}
|
|
2087
2177
|
|
|
2088
2178
|
getApplicationsIds () {
|
|
2089
|
-
return Array.from(this.#applications.keys())
|
|
2179
|
+
return Array.from(this.#applications.keys()).sort()
|
|
2090
2180
|
}
|
|
2091
2181
|
|
|
2092
2182
|
async getApplications (allowUnloaded = false) {
|
|
2093
|
-
return {
|
|
2094
|
-
entrypoint: this.#entrypointId,
|
|
2183
|
+
return frozenSnapshot({
|
|
2095
2184
|
production: this.#isProduction,
|
|
2096
2185
|
applications: await Promise.all(
|
|
2097
|
-
this.getApplicationsIds().map(id => this
|
|
2186
|
+
this.getApplicationsIds().map(id => this.#buildApplicationDetails(id, allowUnloaded))
|
|
2098
2187
|
)
|
|
2099
|
-
}
|
|
2188
|
+
})
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
async getApplicationDetails (id, allowUnloaded = false) {
|
|
2192
|
+
return frozenSnapshot(await this.#buildApplicationDetails(id, allowUnloaded))
|
|
2100
2193
|
}
|
|
2101
2194
|
|
|
2102
2195
|
async getApplicationMeta (id) {
|
|
@@ -2124,7 +2217,7 @@ export class Runtime extends EventEmitter {
|
|
|
2124
2217
|
}
|
|
2125
2218
|
}
|
|
2126
2219
|
|
|
2127
|
-
async
|
|
2220
|
+
async #buildApplicationDetails (id, allowUnloaded = false) {
|
|
2128
2221
|
let application
|
|
2129
2222
|
|
|
2130
2223
|
try {
|
|
@@ -2137,7 +2230,7 @@ export class Runtime extends EventEmitter {
|
|
|
2137
2230
|
throw e
|
|
2138
2231
|
}
|
|
2139
2232
|
|
|
2140
|
-
const {
|
|
2233
|
+
const { localUrl, config, configPath, path } = application[kConfig]
|
|
2141
2234
|
|
|
2142
2235
|
const sourceMaps = application[kConfig].sourceMaps ?? this.#config.sourceMaps
|
|
2143
2236
|
const status = await sendViaITC(application, 'getStatus')
|
|
@@ -2146,24 +2239,43 @@ export class Runtime extends EventEmitter {
|
|
|
2146
2239
|
const applicationDetails = {
|
|
2147
2240
|
id,
|
|
2148
2241
|
type,
|
|
2149
|
-
config,
|
|
2150
2242
|
path,
|
|
2151
2243
|
status,
|
|
2152
2244
|
dependencies,
|
|
2153
2245
|
version,
|
|
2154
2246
|
localUrl,
|
|
2155
|
-
entrypoint,
|
|
2156
2247
|
sourceMaps
|
|
2157
2248
|
}
|
|
2158
2249
|
|
|
2159
|
-
|
|
2160
|
-
|
|
2250
|
+
/*
|
|
2251
|
+
The `config` key once held the application's configuration file path. That file is now
|
|
2252
|
+
evaluated main-side and the worker receives the payload, so the path becomes `configPath` and the entry's own
|
|
2253
|
+
`config` is no longer a path to report. Emitting the key that matches the dialect keeps a
|
|
2254
|
+
consumer from reading one and silently getting the other.
|
|
2255
|
+
*/
|
|
2256
|
+
if (typeof config === 'string') {
|
|
2257
|
+
applicationDetails.config = config
|
|
2258
|
+
} else if (configPath) {
|
|
2259
|
+
applicationDetails.configPath = configPath
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
// status is worker lifecycle; servingState is how the thing serves. They are different
|
|
2263
|
+
// questions, and the runtime gates URL emission on status === 'started', so they cannot share
|
|
2264
|
+
// a field.
|
|
2265
|
+
const servingState = this.#servingStates.get(id)
|
|
2266
|
+
|
|
2267
|
+
if (servingState) {
|
|
2268
|
+
applicationDetails.servingState = servingState
|
|
2161
2269
|
}
|
|
2162
2270
|
|
|
2163
|
-
if (
|
|
2164
|
-
applicationDetails.
|
|
2271
|
+
if (this.#isProduction) {
|
|
2272
|
+
applicationDetails.workers = this.#replicas.get(id)?.length ?? 0
|
|
2165
2273
|
}
|
|
2166
2274
|
|
|
2275
|
+
const urls = status === 'started' ? Object.values(this.getUrls(id)) : []
|
|
2276
|
+
applicationDetails.urls = urls
|
|
2277
|
+
applicationDetails.url = urls[0] ?? null
|
|
2278
|
+
|
|
2167
2279
|
return applicationDetails
|
|
2168
2280
|
}
|
|
2169
2281
|
|
|
@@ -2211,10 +2323,6 @@ export class Runtime extends EventEmitter {
|
|
|
2211
2323
|
return this.#scheduler?.getJobs() ?? []
|
|
2212
2324
|
}
|
|
2213
2325
|
|
|
2214
|
-
getScheduler () {
|
|
2215
|
-
return this.getSchedulerJobs()
|
|
2216
|
-
}
|
|
2217
|
-
|
|
2218
2326
|
pauseSchedulerJob (name) {
|
|
2219
2327
|
return this.#schedulerOrThrow().pauseJob(name)
|
|
2220
2328
|
}
|
|
@@ -2283,6 +2391,7 @@ export class Runtime extends EventEmitter {
|
|
|
2283
2391
|
worker: index,
|
|
2284
2392
|
status: worker[kWorkerStatus],
|
|
2285
2393
|
thread: worker.threadId,
|
|
2394
|
+
url: worker[kWorkerUrl] ?? null,
|
|
2286
2395
|
raw: includeRaw ? worker : undefined
|
|
2287
2396
|
}
|
|
2288
2397
|
}
|
|
@@ -2309,10 +2418,6 @@ export class Runtime extends EventEmitter {
|
|
|
2309
2418
|
elu = worker.performance.eventLoopUtilization(elu, previousELU)
|
|
2310
2419
|
}
|
|
2311
2420
|
|
|
2312
|
-
if (!features.node.worker.getHeapStatistics) {
|
|
2313
|
-
return { elu: elu.utilization, currentELU }
|
|
2314
|
-
}
|
|
2315
|
-
|
|
2316
2421
|
// Only refresh heap statistics every 60 health checks (once per minute).
|
|
2317
2422
|
// This keeps the common path fully synchronous — no promise allocation.
|
|
2318
2423
|
const counter = (worker[kHeapCheckCounter] ?? 0) + 1
|
|
@@ -2404,12 +2509,37 @@ export class Runtime extends EventEmitter {
|
|
|
2404
2509
|
this.emitAndNotify(status, args)
|
|
2405
2510
|
}
|
|
2406
2511
|
|
|
2407
|
-
#
|
|
2408
|
-
|
|
2409
|
-
|
|
2512
|
+
#showUrls (workerIds) {
|
|
2513
|
+
const whitelist = workerIds ? new Set(workerIds) : null
|
|
2514
|
+
|
|
2515
|
+
for (const applicationId of this.#applications.keys()) {
|
|
2516
|
+
for (const workerId of this.#workers.getKeys(applicationId)) {
|
|
2517
|
+
if (whitelist && !whitelist.has(workerId)) {
|
|
2518
|
+
continue
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
const worker = this.#workers.get(workerId)
|
|
2522
|
+
const url = worker?.[kWorkerUrl]
|
|
2523
|
+
if (!url) {
|
|
2524
|
+
continue
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
this.logger.info(
|
|
2528
|
+
`Platformatic is now listening at ${url} for ${this.#workerExtendedLabel(applicationId, worker[kWorkerId])}`
|
|
2529
|
+
)
|
|
2530
|
+
}
|
|
2410
2531
|
}
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
async #refuseUnresolvedApplication (id) {
|
|
2535
|
+
const executable = getExecutable() ?? 'wattpm'
|
|
2536
|
+
|
|
2537
|
+
this.logger.error(
|
|
2538
|
+
`The path for application "%s" does not exist. Please run "${executable} resolve" and try again.`,
|
|
2539
|
+
id
|
|
2540
|
+
)
|
|
2411
2541
|
|
|
2412
|
-
this.
|
|
2542
|
+
await this.closeAndThrow(new RuntimeAbortedError())
|
|
2413
2543
|
}
|
|
2414
2544
|
|
|
2415
2545
|
async #setupApplication (applicationConfig) {
|
|
@@ -2427,13 +2557,7 @@ export class Runtime extends EventEmitter {
|
|
|
2427
2557
|
applicationConfig.path = join(this.#root, config.resolvedApplicationsBasePath, id)
|
|
2428
2558
|
|
|
2429
2559
|
if (!existsSync(applicationConfig.path)) {
|
|
2430
|
-
|
|
2431
|
-
this.logger.error(
|
|
2432
|
-
`The path for application "%s" does not exist. Please run "${executable} resolve" and try again.`,
|
|
2433
|
-
id
|
|
2434
|
-
)
|
|
2435
|
-
|
|
2436
|
-
await this.closeAndThrow(new RuntimeAbortedError())
|
|
2560
|
+
await this.#refuseUnresolvedApplication(id)
|
|
2437
2561
|
}
|
|
2438
2562
|
} else {
|
|
2439
2563
|
this.logger.error(
|
|
@@ -2443,17 +2567,47 @@ export class Runtime extends EventEmitter {
|
|
|
2443
2567
|
|
|
2444
2568
|
await this.closeAndThrow(new RuntimeAbortedError())
|
|
2445
2569
|
}
|
|
2570
|
+
} else if (applicationConfig.unresolved) {
|
|
2571
|
+
/*
|
|
2572
|
+
A remote application that declares where its clone belongs, and whose clone is not there --
|
|
2573
|
+
either the directory is missing or it holds no application at all. The pathless case above
|
|
2574
|
+
is the same state said differently, and both are what `resolve` exists to fix, so both get
|
|
2575
|
+
the message that names it rather than the capability detector's report that the directory
|
|
2576
|
+
holds nothing.
|
|
2577
|
+
*/
|
|
2578
|
+
await this.#refuseUnresolvedApplication(id)
|
|
2446
2579
|
}
|
|
2447
2580
|
|
|
2448
|
-
|
|
2449
|
-
|
|
2581
|
+
let workers = applicationConfig.workers.static
|
|
2582
|
+
|
|
2583
|
+
this.#replicas.set(id, [])
|
|
2584
|
+
|
|
2585
|
+
let firstIndex = 0
|
|
2450
2586
|
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2587
|
+
// On platforms where reusePort is not available, multiple workers cannot listen on the same fixed port.
|
|
2588
|
+
// The listener configuration is owned by the capability, so it can only be inspected after setting up the first
|
|
2589
|
+
// worker: if the application would try to share a fixed port between workers, clamp it to a single worker.
|
|
2590
|
+
if (!features.node.reusePort && (workers > 1 || applicationConfig.workers.dynamic)) {
|
|
2591
|
+
const { worker } = await this.#setupReplica(config, applicationConfig, workers, id, 0, 0)
|
|
2592
|
+
firstIndex = 1
|
|
2593
|
+
|
|
2594
|
+
if (await this.#usesSharedFixedPort(worker)) {
|
|
2595
|
+
this.logger.warn(
|
|
2596
|
+
`The application "${id}" is configured to listen on a fixed port with multiple workers, but reusePort is not available in your OS. ${workers > 1 ? `Setting workers to 1 instead of ${workers}` : 'Disabling dynamic workers scaling'}. To run multiple workers, set "server.portAssignment" to "perWorkerIncrement" in the application configuration.`
|
|
2597
|
+
)
|
|
2598
|
+
|
|
2599
|
+
applicationConfig.workers = { dynamic: false, static: 1 }
|
|
2600
|
+
workers = 1
|
|
2601
|
+
await sendViaITC(worker, 'updateWorkersCount', { applicationId: id, workers })
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
const setupInvocations = []
|
|
2606
|
+
for (let i = firstIndex; i < workers; i++) {
|
|
2607
|
+
setupInvocations.push([config, applicationConfig, workers, id, i, i])
|
|
2454
2608
|
}
|
|
2455
2609
|
|
|
2456
|
-
await executeInParallel(this.#
|
|
2610
|
+
await executeInParallel(this.#setupReplica.bind(this), setupInvocations, this.#concurrency)
|
|
2457
2611
|
|
|
2458
2612
|
// Initialize the next worker index counter (next index starts after initial workers)
|
|
2459
2613
|
this.#nextWorkerIndex.set(id, workers)
|
|
@@ -2462,7 +2616,51 @@ export class Runtime extends EventEmitter {
|
|
|
2462
2616
|
this.emitAndNotify('application:init', id)
|
|
2463
2617
|
}
|
|
2464
2618
|
|
|
2465
|
-
|
|
2619
|
+
// Adds a replica to the application (see the #replicas field) and sets up its worker.
|
|
2620
|
+
// Returns null if the replica was removed in the meanwhile.
|
|
2621
|
+
async #setupReplica (config, applicationConfig, workersCount, applicationId, index, portOffset) {
|
|
2622
|
+
const replica = {
|
|
2623
|
+
portOffset,
|
|
2624
|
+
// The worker serving the replica, null while the replica is being refilled
|
|
2625
|
+
worker: null,
|
|
2626
|
+
// The worker being started to refill the replica or to replace its worker
|
|
2627
|
+
replacement: null,
|
|
2628
|
+
// The operation in progress: 'refill' or 'replace'
|
|
2629
|
+
pending: null,
|
|
2630
|
+
abortController: new AbortController()
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
// Added right away, so that concurrent operations see the requested workers
|
|
2634
|
+
this.#replicas.get(applicationId).push(replica)
|
|
2635
|
+
|
|
2636
|
+
let worker
|
|
2637
|
+
try {
|
|
2638
|
+
worker = await this.#setupWorker(config, applicationConfig, workersCount, applicationId, index, portOffset)
|
|
2639
|
+
} catch (err) {
|
|
2640
|
+
await this.#detachReplica(applicationId, replica)
|
|
2641
|
+
throw err
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
// The replica was removed in the meanwhile (i.e. scale-down or application stopped)
|
|
2645
|
+
if (replica.abortController.signal.aborted) {
|
|
2646
|
+
await this.#discardWorker(worker)
|
|
2647
|
+
return null
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
replica.worker = worker
|
|
2651
|
+
return replica
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
async #setupWorker (
|
|
2655
|
+
config,
|
|
2656
|
+
applicationConfig,
|
|
2657
|
+
workersCount,
|
|
2658
|
+
applicationId,
|
|
2659
|
+
index,
|
|
2660
|
+
portOffset,
|
|
2661
|
+
enabled = true,
|
|
2662
|
+
attempt = 0
|
|
2663
|
+
) {
|
|
2466
2664
|
const restartOnError = this.#getApplicationRestartOnError(config, applicationConfig)
|
|
2467
2665
|
const workerId = `${applicationId}:${index}`
|
|
2468
2666
|
|
|
@@ -2477,17 +2675,11 @@ export class Runtime extends EventEmitter {
|
|
|
2477
2675
|
inspectorOptions.port = inspectorOptions.port + this.#workers.size + 1
|
|
2478
2676
|
}
|
|
2479
2677
|
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
if (config.telemetry) {
|
|
2487
|
-
applicationConfig.telemetry = {
|
|
2488
|
-
...config.telemetry,
|
|
2489
|
-
...applicationConfig.telemetry,
|
|
2490
|
-
applicationName: `${config.telemetry.applicationName}-${applicationConfig.id}`
|
|
2678
|
+
if (config.tracing) {
|
|
2679
|
+
applicationConfig.tracing = {
|
|
2680
|
+
...config.tracing,
|
|
2681
|
+
...applicationConfig.tracing,
|
|
2682
|
+
applicationName: `${config.tracing.applicationName}-${applicationConfig.id}`
|
|
2491
2683
|
}
|
|
2492
2684
|
}
|
|
2493
2685
|
|
|
@@ -2496,9 +2688,9 @@ export class Runtime extends EventEmitter {
|
|
|
2496
2688
|
|
|
2497
2689
|
const execArgv = applicationConfig.execArgv ?? []
|
|
2498
2690
|
|
|
2499
|
-
if (!applicationConfig.
|
|
2691
|
+
if (!applicationConfig.skipTracingHooks && config.tracing && config.tracing.enabled !== false) {
|
|
2500
2692
|
const require = createRequire(import.meta.url)
|
|
2501
|
-
const telemetryPath = require.resolve('@platformatic/
|
|
2693
|
+
const telemetryPath = require.resolve('@platformatic/tracing')
|
|
2502
2694
|
const openTelemetrySetupPath = join(telemetryPath, '..', 'lib', 'node-telemetry.js')
|
|
2503
2695
|
const hookUrl = pathToFileURL(require.resolve('@opentelemetry/instrumentation/hook.mjs'))
|
|
2504
2696
|
|
|
@@ -2523,7 +2715,10 @@ export class Runtime extends EventEmitter {
|
|
|
2523
2715
|
preload = preload.filter(p => p !== pprofCapturePath)
|
|
2524
2716
|
}
|
|
2525
2717
|
|
|
2526
|
-
|
|
2718
|
+
// Each application's worker environment is resolved main-side, with its own env-file chain, the
|
|
2719
|
+
// two env blocks and the injected topology URLs. Every worker used to be seeded from one loadEnv
|
|
2720
|
+
// at the runtime root, which is what #env still holds.
|
|
2721
|
+
const workerEnv = structuredClone(applicationConfig.workerEnv ?? this.#env)
|
|
2527
2722
|
|
|
2528
2723
|
if (applicationConfig.nodeOptions?.trim().length > 0) {
|
|
2529
2724
|
const originalNodeOptions = workerEnv.NODE_OPTIONS ?? ''
|
|
@@ -2546,21 +2741,12 @@ export class Runtime extends EventEmitter {
|
|
|
2546
2741
|
const maxYoungGenerationSizeMb = maxYoungGeneration ? Math.floor(maxYoungGeneration / (1024 * 1024)) : undefined
|
|
2547
2742
|
const codeRangeSizeMb = codeRangeSize ? Math.floor(codeRangeSize / (1024 * 1024)) : undefined
|
|
2548
2743
|
|
|
2549
|
-
const workerConfig = {
|
|
2550
|
-
...config,
|
|
2551
|
-
preload
|
|
2552
|
-
}
|
|
2553
|
-
|
|
2554
|
-
if (config.server && serverConfigOverride) {
|
|
2555
|
-
workerConfig.server = {
|
|
2556
|
-
...config.server,
|
|
2557
|
-
...serverConfigOverride
|
|
2558
|
-
}
|
|
2559
|
-
}
|
|
2744
|
+
const workerConfig = { ...config, preload }
|
|
2560
2745
|
|
|
2561
2746
|
const worker = new Worker(kWorkerFile, {
|
|
2562
2747
|
workerData: {
|
|
2563
2748
|
config: workerConfig,
|
|
2749
|
+
meshId: this.#meshId,
|
|
2564
2750
|
applicationConfig: {
|
|
2565
2751
|
...applicationConfig,
|
|
2566
2752
|
isProduction: this.#isProduction,
|
|
@@ -2569,7 +2755,8 @@ export class Runtime extends EventEmitter {
|
|
|
2569
2755
|
worker: {
|
|
2570
2756
|
id: workerId,
|
|
2571
2757
|
index,
|
|
2572
|
-
count: workersCount
|
|
2758
|
+
count: workersCount,
|
|
2759
|
+
portOffset
|
|
2573
2760
|
},
|
|
2574
2761
|
resourceLimits: {
|
|
2575
2762
|
maxOldGenerationSizeMb,
|
|
@@ -2577,10 +2764,7 @@ export class Runtime extends EventEmitter {
|
|
|
2577
2764
|
codeRangeSizeMb
|
|
2578
2765
|
},
|
|
2579
2766
|
inspectorOptions,
|
|
2580
|
-
dirname: this.#root
|
|
2581
|
-
// Keys of the worker environment which only come from an env file of the runtime:
|
|
2582
|
-
// the env file of the application is allowed to override those.
|
|
2583
|
-
envFileFallbackKeys: this.#env[kEnvFileFallbackKeys] ?? []
|
|
2767
|
+
dirname: this.#root
|
|
2584
2768
|
},
|
|
2585
2769
|
argv: applicationConfig.arguments,
|
|
2586
2770
|
execArgv,
|
|
@@ -2604,15 +2788,24 @@ export class Runtime extends EventEmitter {
|
|
|
2604
2788
|
const eventPayload = { application: applicationId, worker: index, workersCount }
|
|
2605
2789
|
|
|
2606
2790
|
worker.once('exit', code => {
|
|
2791
|
+
worker[kWorkerExitCode] = code
|
|
2792
|
+
|
|
2607
2793
|
if (worker[kWorkerStatus] === 'exited') {
|
|
2608
2794
|
return
|
|
2609
2795
|
}
|
|
2610
2796
|
|
|
2611
2797
|
const started = worker[kWorkerStatus] === 'started'
|
|
2798
|
+
|
|
2799
|
+
// A crashed worker leaves its replica empty until the replica is refilled below.
|
|
2800
|
+
// Workers which crash while starting are handled by #startWorker.
|
|
2801
|
+
const replica = started ? this.#getWorkerReplica(applicationId, worker) : undefined
|
|
2802
|
+
if (replica) {
|
|
2803
|
+
replica.worker = null
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2612
2806
|
worker[kWorkerStatus] = 'exited'
|
|
2613
2807
|
this.emitAndNotify('application:worker:exited', eventPayload)
|
|
2614
2808
|
|
|
2615
|
-
const portOffset = this.#workerPortOffsets.get(worker[kFullId]) ?? index
|
|
2616
2809
|
this.#cleanupWorker(worker)
|
|
2617
2810
|
|
|
2618
2811
|
if (this.#status === 'stopping') {
|
|
@@ -2628,8 +2821,9 @@ export class Runtime extends EventEmitter {
|
|
|
2628
2821
|
this.logger.warn(`The ${errorLabel} unexpectedly exited with code ${code}.`)
|
|
2629
2822
|
}
|
|
2630
2823
|
|
|
2631
|
-
//
|
|
2632
|
-
|
|
2824
|
+
// Refill the replica of the worker if it was started. Workers which were not serving a replica anymore
|
|
2825
|
+
// (i.e. being retired after a restart) are not replaced, and a replica being replaced gets the replacement.
|
|
2826
|
+
if (started && this.#status === 'started' && replica && replica.pending !== 'replace') {
|
|
2633
2827
|
if (restartOnError > 0) {
|
|
2634
2828
|
if (restartOnError < IMMEDIATE_RESTART_MAX_THRESHOLD) {
|
|
2635
2829
|
this.logger.warn(`The ${errorLabel} is being restarted ...`)
|
|
@@ -2637,14 +2831,16 @@ export class Runtime extends EventEmitter {
|
|
|
2637
2831
|
this.logger.warn(`The ${errorLabel} will be restarted in ${restartOnError}ms ...`)
|
|
2638
2832
|
}
|
|
2639
2833
|
|
|
2640
|
-
this.#
|
|
2834
|
+
this.#refillReplica(config, applicationConfig, workersCount, applicationId, replica, false, 0).catch(
|
|
2641
2835
|
err => {
|
|
2642
2836
|
this.logger.error({ err: ensureLoggableError(err) }, `${errorLabel} could not be restarted.`)
|
|
2837
|
+
this.#detachReplica(applicationId, replica)
|
|
2643
2838
|
}
|
|
2644
2839
|
)
|
|
2645
2840
|
} else {
|
|
2646
2841
|
this.emitAndNotify('application:worker:unvailable', eventPayload)
|
|
2647
2842
|
this.logger.warn(`The ${errorLabel} is no longer available.`)
|
|
2843
|
+
this.#detachReplica(applicationId, replica)
|
|
2648
2844
|
}
|
|
2649
2845
|
}
|
|
2650
2846
|
})
|
|
@@ -2654,6 +2850,7 @@ export class Runtime extends EventEmitter {
|
|
|
2654
2850
|
worker[kFullId] = workerId
|
|
2655
2851
|
worker[kApplicationId] = applicationId
|
|
2656
2852
|
worker[kWorkerId] = index
|
|
2853
|
+
worker[kWorkerPortOffset] = portOffset
|
|
2657
2854
|
worker[kWorkerStatus] = 'boot'
|
|
2658
2855
|
|
|
2659
2856
|
if (inspectorOptions) {
|
|
@@ -2694,18 +2891,47 @@ export class Runtime extends EventEmitter {
|
|
|
2694
2891
|
// Forward events from the worker
|
|
2695
2892
|
// Do not use emitAndNotify here since we don't want to forward unknown events
|
|
2696
2893
|
worker[kITC].on('event', ({ event, payload }) => {
|
|
2894
|
+
if (event === 'serverOptions') {
|
|
2895
|
+
worker[kWorkerServerOptions] = payload[0]
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2697
2898
|
event = `application:worker:event:${event}`
|
|
2698
2899
|
|
|
2699
2900
|
this.emit(event, ...payload, workerId, applicationId, index)
|
|
2700
2901
|
this.logger.trace({ event, payload, id: workerId, application: applicationId, worker: index }, 'Runtime event')
|
|
2701
2902
|
})
|
|
2702
2903
|
|
|
2904
|
+
worker[kITC].on('compile-cache:flushed', ({ flushed, source }) => {
|
|
2905
|
+
this.emit('application:worker:compile-cache:flushed', {
|
|
2906
|
+
application: applicationId,
|
|
2907
|
+
worker: index,
|
|
2908
|
+
workersCount,
|
|
2909
|
+
flushed,
|
|
2910
|
+
source
|
|
2911
|
+
})
|
|
2912
|
+
})
|
|
2913
|
+
|
|
2914
|
+
worker[kITC].on('compile-cache:unavailable', ({ source }) => {
|
|
2915
|
+
this.emit('application:worker:compile-cache:unavailable', {
|
|
2916
|
+
application: applicationId,
|
|
2917
|
+
worker: index,
|
|
2918
|
+
workersCount,
|
|
2919
|
+
source
|
|
2920
|
+
})
|
|
2921
|
+
})
|
|
2922
|
+
|
|
2703
2923
|
// The worker notifies us when its capability has spawned a child process
|
|
2704
2924
|
// (e.g. Next.js in dev mode). From that point on health metrics must come
|
|
2705
2925
|
// from the child via ITC; for thread-only workers we keep reading the
|
|
2706
2926
|
// handle directly in getWorkerHealth().
|
|
2707
|
-
worker[kITC].on('subprocess:started', () => {
|
|
2927
|
+
worker[kITC].on('subprocess:started', ({ pid } = {}) => {
|
|
2708
2928
|
worker[kIsSubprocessHost] = true
|
|
2929
|
+
worker[kSubprocessPid] = pid
|
|
2930
|
+
})
|
|
2931
|
+
worker[kITC].on('subprocess:exited', ({ pid }) => {
|
|
2932
|
+
if (worker[kSubprocessPid] === pid) {
|
|
2933
|
+
worker[kSubprocessPid] = undefined
|
|
2934
|
+
}
|
|
2709
2935
|
})
|
|
2710
2936
|
|
|
2711
2937
|
worker[kITC].on(openTelemetryITCMessage, resourceMetrics => {
|
|
@@ -2867,9 +3093,7 @@ export class Runtime extends EventEmitter {
|
|
|
2867
3093
|
this.logger.info(`The application "${applicationId}" has been successfully reloaded.`)
|
|
2868
3094
|
this.emitAndNotify('application:worker:reloaded', eventPayload)
|
|
2869
3095
|
|
|
2870
|
-
|
|
2871
|
-
this.#showUrl()
|
|
2872
|
-
}
|
|
3096
|
+
this.#showUrls(this.#workers.getKeys(applicationId))
|
|
2873
3097
|
} catch (e) {
|
|
2874
3098
|
this.logger.error(e)
|
|
2875
3099
|
}
|
|
@@ -2881,25 +3105,6 @@ export class Runtime extends EventEmitter {
|
|
|
2881
3105
|
this.#workers.set(workerId, worker)
|
|
2882
3106
|
}
|
|
2883
3107
|
|
|
2884
|
-
// Setup the interceptor
|
|
2885
|
-
// kInterceptorReadyPromise resolves when the worker
|
|
2886
|
-
// is ready to receive requests: after calling the replaceServer method
|
|
2887
|
-
//
|
|
2888
|
-
// It is stored, not awaited: #startWorker awaits it later, and only if the
|
|
2889
|
-
// worker is actually started. A worker that is discarded or torn down
|
|
2890
|
-
// before that — a replacement abandoned because the runtime stopped
|
|
2891
|
-
// mid-restart, for instance — leaves the promise with nobody to observe
|
|
2892
|
-
// it. Closing the mesh interceptor rejects any routing still in flight
|
|
2893
|
-
// ('The dispatcher has been closed.'), so that is a routine shutdown
|
|
2894
|
-
// outcome, not an exotic one, and it surfaced as an unhandledRejection
|
|
2895
|
-
// that could take the process down before graceful shutdown finished.
|
|
2896
|
-
//
|
|
2897
|
-
// Attaching a no-op handler marks the rejection observed WITHOUT
|
|
2898
|
-
// swallowing it: the await in #startWorker still sees and reports it.
|
|
2899
|
-
const interceptorReady = this.#meshInterceptor.route(applicationId, worker)
|
|
2900
|
-
interceptorReady.catch(() => {})
|
|
2901
|
-
worker[kInterceptorReadyPromise] = interceptorReady
|
|
2902
|
-
|
|
2903
3108
|
// Wait for initialization
|
|
2904
3109
|
try {
|
|
2905
3110
|
await waitEventFromITC(worker, 'init')
|
|
@@ -2918,11 +3123,16 @@ export class Runtime extends EventEmitter {
|
|
|
2918
3123
|
}
|
|
2919
3124
|
|
|
2920
3125
|
this.#workers.delete(workerId)
|
|
2921
|
-
return this.#setupWorker(
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
3126
|
+
return this.#setupWorker(
|
|
3127
|
+
config,
|
|
3128
|
+
applicationConfig,
|
|
3129
|
+
workersCount,
|
|
3130
|
+
applicationId,
|
|
3131
|
+
index,
|
|
3132
|
+
portOffset,
|
|
3133
|
+
enabled,
|
|
3134
|
+
attempt + 1
|
|
3135
|
+
)
|
|
2926
3136
|
}
|
|
2927
3137
|
|
|
2928
3138
|
worker[kConfig] = { ...applicationConfig, health, workers: workersCount }
|
|
@@ -3339,29 +3549,8 @@ export class Runtime extends EventEmitter {
|
|
|
3339
3549
|
|
|
3340
3550
|
const { url: workerUrl, scheduledTasks } = workerStartResult
|
|
3341
3551
|
worker[kWorkerScheduledTasks] = scheduledTasks
|
|
3342
|
-
if (workerUrl) {
|
|
3343
|
-
this.#url = workerUrl
|
|
3344
|
-
|
|
3345
|
-
// Pin the entrypoint port so that subsequent restarts (especially with
|
|
3346
|
-
// stopBeforeStart when reuseTcpPorts is false) bind to the same port
|
|
3347
|
-
// instead of getting a new random one.
|
|
3348
|
-
if (applicationConfig.entrypoint) {
|
|
3349
|
-
try {
|
|
3350
|
-
const boundPort = Number(new URL(workerUrl).port)
|
|
3351
|
-
if (boundPort) {
|
|
3352
|
-
this.#entrypointPort = boundPort
|
|
3353
|
-
}
|
|
3354
|
-
} catch {
|
|
3355
|
-
// URL parsing failed, leave unchanged
|
|
3356
|
-
}
|
|
3357
|
-
}
|
|
3358
|
-
}
|
|
3359
3552
|
|
|
3360
|
-
|
|
3361
|
-
const interceptorResult = await executeWithTimeout(worker[kInterceptorReadyPromise], config.startTimeout)
|
|
3362
|
-
if (interceptorResult === kTimeout) {
|
|
3363
|
-
throw new WorkerInterceptorJoinTimeoutError(label, config.startTimeout)
|
|
3364
|
-
}
|
|
3553
|
+
this.#recordWorkerUrl(worker, id, workerUrl)
|
|
3365
3554
|
|
|
3366
3555
|
worker[kWorkerStatus] = 'started'
|
|
3367
3556
|
worker[kWorkerStartTime] = Date.now()
|
|
@@ -3385,12 +3574,28 @@ export class Runtime extends EventEmitter {
|
|
|
3385
3574
|
).unref()
|
|
3386
3575
|
}
|
|
3387
3576
|
} catch (err) {
|
|
3388
|
-
|
|
3577
|
+
let error = ensureError(err)
|
|
3578
|
+
// Forced shutdown may terminate the thread before its start handler can report the abort.
|
|
3579
|
+
if (
|
|
3580
|
+
worker[kWorkerStartAborted] &&
|
|
3581
|
+
error.code === 'PLT_RUNTIME_APPLICATION_WORKER_EXIT'
|
|
3582
|
+
) {
|
|
3583
|
+
error = new RuntimeAbortedError({ cause: error })
|
|
3584
|
+
}
|
|
3389
3585
|
worker[kITC].notify('application:worker:start:processed')
|
|
3390
|
-
const portOffset = this.#workerPortOffsets.get(worker[kFullId]) ?? index
|
|
3391
3586
|
|
|
3392
|
-
|
|
3393
|
-
|
|
3587
|
+
if (error.code === 'EADDRINUSE' && Number.isInteger(Number(error.port))) {
|
|
3588
|
+
const port = Number(error.port)
|
|
3589
|
+
const owner = this.#getPortOwner(port, id)
|
|
3590
|
+
|
|
3591
|
+
if (owner) {
|
|
3592
|
+
error = new AddressInUseError(port, owner, id)
|
|
3593
|
+
} else if (this.#getPortOwner(port, id, undefined, true)) {
|
|
3594
|
+
error = new WorkerAddressInUseError(port, id)
|
|
3595
|
+
}
|
|
3596
|
+
// Otherwise the port is used by an external process: keep the original error, which already describes it
|
|
3597
|
+
}
|
|
3598
|
+
if (error.code === 'EACCES') throw error
|
|
3394
3599
|
|
|
3395
3600
|
this.#cleanupWorker(worker)
|
|
3396
3601
|
|
|
@@ -3414,7 +3619,16 @@ export class Runtime extends EventEmitter {
|
|
|
3414
3619
|
|
|
3415
3620
|
const restartOnError = this.#getApplicationRestartOnError(config, applicationConfig)
|
|
3416
3621
|
|
|
3417
|
-
if (
|
|
3622
|
+
if (
|
|
3623
|
+
disableRestartAttempts ||
|
|
3624
|
+
!restartOnError ||
|
|
3625
|
+
error.code === 'EACCES' ||
|
|
3626
|
+
error.code === 'EADDRINUSE' ||
|
|
3627
|
+
error.code === 'EADDRNOTAVAIL' ||
|
|
3628
|
+
error.code === 'PLT_RUNTIME_EADDR_IN_USE' ||
|
|
3629
|
+
error.code === 'PLT_RUNTIME_RUNTIME_ABORT' ||
|
|
3630
|
+
error.code === 'PLT_RUNTIME_WORKER_EADDR_IN_USE'
|
|
3631
|
+
) {
|
|
3418
3632
|
throw error
|
|
3419
3633
|
}
|
|
3420
3634
|
|
|
@@ -3434,7 +3648,64 @@ export class Runtime extends EventEmitter {
|
|
|
3434
3648
|
)
|
|
3435
3649
|
}
|
|
3436
3650
|
|
|
3437
|
-
|
|
3651
|
+
// The replica was removed while the worker was starting, there is nothing to refill
|
|
3652
|
+
const replica = this.#getWorkerReplica(id, worker)
|
|
3653
|
+
if (!replica) {
|
|
3654
|
+
throw error
|
|
3655
|
+
}
|
|
3656
|
+
replica.worker = null
|
|
3657
|
+
await this.#refillReplica(config, applicationConfig, workersCount, id, replica, silent, bootstrapAttempt)
|
|
3658
|
+
}
|
|
3659
|
+
}
|
|
3660
|
+
|
|
3661
|
+
async #startFirstWorkerWithCompileCache (config, applicationConfig, workers, id, silent) {
|
|
3662
|
+
const cacheFlushEvent = 'application:worker:compile-cache:flushed'
|
|
3663
|
+
const cacheUnavailableEvent = 'application:worker:compile-cache:unavailable'
|
|
3664
|
+
const { promise, resolve } = Promise.withResolvers()
|
|
3665
|
+
const cacheEvents = []
|
|
3666
|
+
const runtime = this
|
|
3667
|
+
let expectedSource
|
|
3668
|
+
|
|
3669
|
+
function removeCacheFlushListener () {
|
|
3670
|
+
runtime.off(cacheFlushEvent, cacheFlushListener)
|
|
3671
|
+
runtime.off(cacheUnavailableEvent, cacheUnavailableListener)
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3674
|
+
function resolveIfReady () {
|
|
3675
|
+
if (expectedSource && cacheEvents.some(function (event) {
|
|
3676
|
+
return event.source === expectedSource
|
|
3677
|
+
})) {
|
|
3678
|
+
removeCacheFlushListener()
|
|
3679
|
+
resolve()
|
|
3680
|
+
}
|
|
3681
|
+
}
|
|
3682
|
+
|
|
3683
|
+
function cacheFlushListener (event) {
|
|
3684
|
+
if (event.application === id && event.worker === 0) {
|
|
3685
|
+
cacheEvents.push(event)
|
|
3686
|
+
resolveIfReady()
|
|
3687
|
+
}
|
|
3688
|
+
}
|
|
3689
|
+
|
|
3690
|
+
function cacheUnavailableListener (event) {
|
|
3691
|
+
if (event.application === id && event.worker === 0) {
|
|
3692
|
+
cacheEvents.push(event)
|
|
3693
|
+
resolveIfReady()
|
|
3694
|
+
}
|
|
3695
|
+
}
|
|
3696
|
+
|
|
3697
|
+
runtime.on(cacheFlushEvent, cacheFlushListener)
|
|
3698
|
+
runtime.on(cacheUnavailableEvent, cacheUnavailableListener)
|
|
3699
|
+
|
|
3700
|
+
try {
|
|
3701
|
+
await this.#startWorker(config, applicationConfig, workers, id, 0, silent)
|
|
3702
|
+
const firstWorker = this.#workers.get(`${id}:0`)
|
|
3703
|
+
expectedSource = firstWorker?.[kIsSubprocessHost] === true ? 'child-process' : 'worker'
|
|
3704
|
+
resolveIfReady()
|
|
3705
|
+
await promise
|
|
3706
|
+
} catch (err) {
|
|
3707
|
+
removeCacheFlushListener()
|
|
3708
|
+
throw err
|
|
3438
3709
|
}
|
|
3439
3710
|
}
|
|
3440
3711
|
|
|
@@ -3454,6 +3725,9 @@ export class Runtime extends EventEmitter {
|
|
|
3454
3725
|
|
|
3455
3726
|
const eventPayload = { application: id, worker: index, workersCount }
|
|
3456
3727
|
|
|
3728
|
+
if (worker[kWorkerStatus] === 'starting' && this.error) {
|
|
3729
|
+
worker[kWorkerStartAborted] = true
|
|
3730
|
+
}
|
|
3457
3731
|
worker[kWorkerStatus] = 'stopping'
|
|
3458
3732
|
worker[kITC].removeAllListeners('changed')
|
|
3459
3733
|
this.emitAndNotify('application:worker:stopping', eventPayload)
|
|
@@ -3469,13 +3743,18 @@ export class Runtime extends EventEmitter {
|
|
|
3469
3743
|
|
|
3470
3744
|
// Always send the stop message, it will shut down workers that only had ITC and interceptors setup
|
|
3471
3745
|
try {
|
|
3472
|
-
const res = await executeWithTimeout(
|
|
3746
|
+
const res = await executeWithTimeout(
|
|
3747
|
+
sendViaITC(worker, 'stop', { force: !!this.error, dependents, shutdownTimeout: exitTimeout }),
|
|
3748
|
+
exitTimeout
|
|
3749
|
+
)
|
|
3473
3750
|
|
|
3474
3751
|
if (res === kTimeout) {
|
|
3752
|
+
this.#terminateSubprocess(worker)
|
|
3475
3753
|
this.emitAndNotify('application:worker:stop:timeout', eventPayload)
|
|
3476
3754
|
this.logger.error(`Timeout while stopping ${label}. Killing a worker thread.`)
|
|
3477
3755
|
}
|
|
3478
3756
|
} catch (error) {
|
|
3757
|
+
this.#terminateSubprocess(worker)
|
|
3479
3758
|
this.emitAndNotify('application:worker:stop:error', eventPayload)
|
|
3480
3759
|
this.logger.error({ err: ensureLoggableError(error) }, `Failed to stop ${label}. Killing a worker thread.`)
|
|
3481
3760
|
} finally {
|
|
@@ -3503,13 +3782,32 @@ export class Runtime extends EventEmitter {
|
|
|
3503
3782
|
await this.#avoidOutOfOrderThreadLogs()
|
|
3504
3783
|
|
|
3505
3784
|
worker[kWorkerStatus] = 'stopped'
|
|
3785
|
+
worker[kWorkerUrl] = undefined
|
|
3506
3786
|
this.emitAndNotify('application:worker:stopped', eventPayload)
|
|
3507
3787
|
this.#broadcastWorkers()
|
|
3508
3788
|
}
|
|
3509
3789
|
|
|
3790
|
+
#terminateSubprocess (worker) {
|
|
3791
|
+
const pid = worker[kSubprocessPid]
|
|
3792
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
3793
|
+
return
|
|
3794
|
+
}
|
|
3795
|
+
try {
|
|
3796
|
+
// The main thread owns the fallback when a worker cannot supervise its child.
|
|
3797
|
+
this.logger.debug({ pid }, 'Terminating application child process from the runtime.')
|
|
3798
|
+
process.kill(pid, 'SIGKILL')
|
|
3799
|
+
} catch (error) {
|
|
3800
|
+
if (error.code === 'ESRCH') {
|
|
3801
|
+
// ESRCH means no such process: the child exited before the fallback signal arrived.
|
|
3802
|
+
this.logger.debug({ pid }, 'Application child process already exited before termination.')
|
|
3803
|
+
} else {
|
|
3804
|
+
this.logger.error({ err: ensureLoggableError(error) }, 'Failed to terminate application child process.')
|
|
3805
|
+
}
|
|
3806
|
+
}
|
|
3807
|
+
}
|
|
3808
|
+
|
|
3510
3809
|
#cleanupWorker (worker) {
|
|
3511
3810
|
clearTimeout(worker[kHealthCheckTimer])
|
|
3512
|
-
this.#workerPortOffsets.delete(worker[kFullId])
|
|
3513
3811
|
|
|
3514
3812
|
const currentWorker = this.#workers.get(worker[kFullId])
|
|
3515
3813
|
|
|
@@ -3521,8 +3819,8 @@ export class Runtime extends EventEmitter {
|
|
|
3521
3819
|
}
|
|
3522
3820
|
|
|
3523
3821
|
async #discardWorker (worker) {
|
|
3524
|
-
|
|
3525
|
-
worker
|
|
3822
|
+
// Marked synchronously, so that the exit handler ignores the worker and nobody else tries to stop it
|
|
3823
|
+
worker[kWorkerStatus] = 'exited'
|
|
3526
3824
|
await worker.terminate()
|
|
3527
3825
|
|
|
3528
3826
|
return this.#cleanupWorker(worker)
|
|
@@ -3538,6 +3836,57 @@ export class Runtime extends EventEmitter {
|
|
|
3538
3836
|
return index
|
|
3539
3837
|
}
|
|
3540
3838
|
|
|
3839
|
+
#getWorkerReplica (applicationId, worker) {
|
|
3840
|
+
return this.#replicas.get(applicationId)?.find(replica => replica.worker === worker)
|
|
3841
|
+
}
|
|
3842
|
+
|
|
3843
|
+
// Removes a replica and cancels the work in progress for it. Stopping its worker is up to the caller.
|
|
3844
|
+
async #detachReplica (applicationId, replica) {
|
|
3845
|
+
const replicas = this.#replicas.get(applicationId)
|
|
3846
|
+
const position = replicas?.indexOf(replica) ?? -1
|
|
3847
|
+
|
|
3848
|
+
if (position !== -1) {
|
|
3849
|
+
replicas.splice(position, 1)
|
|
3850
|
+
}
|
|
3851
|
+
|
|
3852
|
+
replica.abortController.abort()
|
|
3853
|
+
|
|
3854
|
+
if (replica.replacement) {
|
|
3855
|
+
await this.#discardWorker(replica.replacement)
|
|
3856
|
+
}
|
|
3857
|
+
}
|
|
3858
|
+
|
|
3859
|
+
// Returns the lowest port offset which is not used by any replica of the application.
|
|
3860
|
+
// Empty replicas keep their port offset, as they will be refilled.
|
|
3861
|
+
#getNextReplicaPortOffset (applicationId) {
|
|
3862
|
+
const used = new Set(this.#replicas.get(applicationId)?.map(replica => replica.portOffset))
|
|
3863
|
+
|
|
3864
|
+
let offset = 0
|
|
3865
|
+
while (used.has(offset)) {
|
|
3866
|
+
offset++
|
|
3867
|
+
}
|
|
3868
|
+
|
|
3869
|
+
return offset
|
|
3870
|
+
}
|
|
3871
|
+
|
|
3872
|
+
// Returns true if the application of the worker is configured to listen on a fixed port shared by all its workers
|
|
3873
|
+
async #usesSharedFixedPort (worker) {
|
|
3874
|
+
let server
|
|
3875
|
+
|
|
3876
|
+
try {
|
|
3877
|
+
server = (await sendViaITC(worker, 'getApplicationConfig'))?.server
|
|
3878
|
+
} catch {
|
|
3879
|
+
return false
|
|
3880
|
+
}
|
|
3881
|
+
|
|
3882
|
+
if (!server || server.portAssignment === 'perWorkerIncrement') {
|
|
3883
|
+
return false
|
|
3884
|
+
}
|
|
3885
|
+
|
|
3886
|
+
const port = Number(server.port)
|
|
3887
|
+
return Number.isInteger(port) && port > 0
|
|
3888
|
+
}
|
|
3889
|
+
|
|
3541
3890
|
// Returns the effective restartOnError value for an application: the application-level
|
|
3542
3891
|
// value, when defined, takes precedence over the runtime-level one.
|
|
3543
3892
|
// The value is normalized to a number: false becomes 0 (never restart),
|
|
@@ -3554,89 +3903,101 @@ export class Runtime extends EventEmitter {
|
|
|
3554
3903
|
return restartOnError
|
|
3555
3904
|
}
|
|
3556
3905
|
|
|
3557
|
-
|
|
3906
|
+
// Starts a new worker for a replica whose worker crashed (or failed to start), after the restartOnError delay
|
|
3907
|
+
async #refillReplica (config, applicationConfig, workersCount, id, replica, silent, bootstrapAttempt) {
|
|
3558
3908
|
const restartOnError = this.#getApplicationRestartOnError(config, applicationConfig)
|
|
3909
|
+
const { signal } = replica.abortController
|
|
3910
|
+
replica.pending = 'refill'
|
|
3559
3911
|
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
return
|
|
3567
|
-
}
|
|
3568
|
-
|
|
3569
|
-
restartPromise = new Promise((resolve, reject) => {
|
|
3570
|
-
async function restart () {
|
|
3571
|
-
this.#restartingWorkers.delete(restartKey)
|
|
3572
|
-
|
|
3573
|
-
// If some processes were scheduled to restart
|
|
3574
|
-
// but the runtime is stopped, ignore it
|
|
3575
|
-
if (!this.#status.startsWith('start')) {
|
|
3576
|
-
return
|
|
3912
|
+
try {
|
|
3913
|
+
try {
|
|
3914
|
+
if (restartOnError < IMMEDIATE_RESTART_MAX_THRESHOLD) {
|
|
3915
|
+
await new Promise(resolve => process.nextTick(resolve))
|
|
3916
|
+
} else {
|
|
3917
|
+
await sleep(restartOnError, undefined, { signal })
|
|
3577
3918
|
}
|
|
3919
|
+
} catch {
|
|
3920
|
+
// The replica was removed in the meanwhile
|
|
3921
|
+
return
|
|
3922
|
+
}
|
|
3578
3923
|
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3924
|
+
// If the replica was removed or the runtime was stopped in the meanwhile, ignore it
|
|
3925
|
+
if (signal.aborted || !this.#status.startsWith('start')) {
|
|
3926
|
+
return
|
|
3927
|
+
}
|
|
3583
3928
|
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
await this.#startWorker(config, applicationConfig, workersCount, id, newIndex, silent, bootstrapAttempt)
|
|
3587
|
-
this.#incrementApplicationRestartCount(id)
|
|
3929
|
+
// Get a new unique index for the worker, which inherits the port offset of the replica
|
|
3930
|
+
const newIndex = this.#getNextWorkerIndex(id)
|
|
3588
3931
|
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3932
|
+
try {
|
|
3933
|
+
const worker = await this.#setupWorker(
|
|
3934
|
+
config,
|
|
3935
|
+
applicationConfig,
|
|
3936
|
+
workersCount,
|
|
3937
|
+
id,
|
|
3938
|
+
newIndex,
|
|
3939
|
+
replica.portOffset
|
|
3940
|
+
)
|
|
3595
3941
|
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3942
|
+
if (signal.aborted) {
|
|
3943
|
+
await this.#discardWorker(worker)
|
|
3944
|
+
return
|
|
3945
|
+
}
|
|
3600
3946
|
|
|
3601
|
-
|
|
3947
|
+
replica.worker = worker
|
|
3948
|
+
await this.#startWorker(config, applicationConfig, workersCount, id, newIndex, silent, bootstrapAttempt, worker)
|
|
3949
|
+
} catch (err) {
|
|
3950
|
+
// The replica was removed or the runtime was stopped while the worker was starting, ignore any error.
|
|
3951
|
+
if (signal.aborted || !this.#status.startsWith('start')) {
|
|
3952
|
+
return
|
|
3602
3953
|
}
|
|
3603
|
-
}
|
|
3604
3954
|
|
|
3605
|
-
|
|
3606
|
-
process.nextTick(restart.bind(this))
|
|
3607
|
-
} else {
|
|
3608
|
-
setTimeout(restart.bind(this), restartOnError)
|
|
3955
|
+
throw err
|
|
3609
3956
|
}
|
|
3610
|
-
})
|
|
3611
3957
|
|
|
3612
|
-
|
|
3613
|
-
|
|
3958
|
+
this.#incrementApplicationRestartCount(id)
|
|
3959
|
+
this.logger.info(`The ${this.#workerExtendedLabel(id, newIndex, workersCount)} has been successfully restarted ...`)
|
|
3960
|
+
} finally {
|
|
3961
|
+
if (replica.pending === 'refill') {
|
|
3962
|
+
replica.pending = null
|
|
3963
|
+
}
|
|
3964
|
+
}
|
|
3614
3965
|
}
|
|
3615
3966
|
|
|
3616
|
-
|
|
3967
|
+
// Replaces the worker of a replica with a new one. Only one operation at a time is performed on a replica.
|
|
3968
|
+
async #replaceWorker (
|
|
3969
|
+
config,
|
|
3970
|
+
applicationConfig,
|
|
3971
|
+
workersCount,
|
|
3972
|
+
applicationId,
|
|
3973
|
+
oldIndex,
|
|
3974
|
+
worker,
|
|
3975
|
+
silent,
|
|
3976
|
+
deferOldWorkerRetirement = false
|
|
3977
|
+
) {
|
|
3978
|
+
const replica = this.#getWorkerReplica(applicationId, worker)
|
|
3979
|
+
|
|
3980
|
+
// The worker is not serving a replica anymore, or the replica is already being refilled or replaced
|
|
3981
|
+
if (!replica || replica.pending) {
|
|
3982
|
+
return
|
|
3983
|
+
}
|
|
3984
|
+
|
|
3985
|
+
const { signal } = replica.abortController
|
|
3617
3986
|
const oldLabel = this.#workerExtendedLabel(applicationId, oldIndex, workersCount)
|
|
3618
3987
|
let newWorker
|
|
3988
|
+
let error
|
|
3619
3989
|
|
|
3620
|
-
// Get a new unique index for the replacement worker
|
|
3990
|
+
// Get a new unique index for the replacement worker, which inherits the port offset of the replica
|
|
3621
3991
|
const newIndex = this.#getNextWorkerIndex(applicationId)
|
|
3622
3992
|
const newWorkerId = `${applicationId}:${newIndex}`
|
|
3623
3993
|
const newLabel = this.#workerExtendedLabel(applicationId, newIndex, workersCount)
|
|
3624
|
-
const portOffset = this.#workerPortOffsets.get(`${applicationId}:${oldIndex}`)
|
|
3625
|
-
this.#workerPortOffsets.set(newWorkerId, portOffset)
|
|
3626
3994
|
|
|
3627
3995
|
const stopBeforeStart =
|
|
3628
|
-
|
|
3996
|
+
Boolean(worker[kWorkerUrl]) &&
|
|
3997
|
+
worker[kWorkerServerOptions]?.port !== 0 &&
|
|
3629
3998
|
(config.reuseTcpPorts === false || applicationConfig.reuseTcpPorts === false || !features.node.reusePort)
|
|
3630
3999
|
|
|
3631
|
-
|
|
3632
|
-
// port in the config so the replacement worker binds to the same port.
|
|
3633
|
-
// We only do this for stopBeforeStart because when reusePort is available
|
|
3634
|
-
// the new worker starts alongside the old one and must use port 0 to avoid
|
|
3635
|
-
// SO_REUSEPORT routing requests to the stale old worker.
|
|
3636
|
-
let configForNewWorker = config
|
|
3637
|
-
if (stopBeforeStart && this.#entrypointPort && config.server) {
|
|
3638
|
-
configForNewWorker = { ...config, server: { ...config.server, port: this.#entrypointPort } }
|
|
3639
|
-
}
|
|
4000
|
+
replica.pending = 'replace'
|
|
3640
4001
|
|
|
3641
4002
|
try {
|
|
3642
4003
|
if (!silent) {
|
|
@@ -3645,13 +4006,22 @@ export class Runtime extends EventEmitter {
|
|
|
3645
4006
|
|
|
3646
4007
|
if (stopBeforeStart) {
|
|
3647
4008
|
await this.#removeWorker(workersCount, applicationId, oldIndex, worker, silent, oldLabel)
|
|
4009
|
+
replica.worker = null
|
|
3648
4010
|
}
|
|
3649
4011
|
|
|
3650
|
-
|
|
3651
|
-
|
|
4012
|
+
newWorker = await this.#setupWorker(
|
|
4013
|
+
config,
|
|
4014
|
+
applicationConfig,
|
|
4015
|
+
workersCount,
|
|
4016
|
+
applicationId,
|
|
4017
|
+
newIndex,
|
|
4018
|
+
replica.portOffset,
|
|
4019
|
+
false
|
|
4020
|
+
)
|
|
4021
|
+
replica.replacement = newWorker
|
|
3652
4022
|
|
|
3653
|
-
// Make sure the runtime hasn't been stopped in the meanwhile
|
|
3654
|
-
if (this.#status !== 'started') {
|
|
4023
|
+
// Make sure the runtime hasn't been stopped and the replica hasn't been removed in the meanwhile
|
|
4024
|
+
if (this.#status !== 'started' || signal.aborted) {
|
|
3655
4025
|
return this.#discardWorker(newWorker)
|
|
3656
4026
|
}
|
|
3657
4027
|
|
|
@@ -3662,7 +4032,7 @@ export class Runtime extends EventEmitter {
|
|
|
3662
4032
|
|
|
3663
4033
|
// Add the worker to the mesh
|
|
3664
4034
|
await this.#startWorker(
|
|
3665
|
-
|
|
4035
|
+
config,
|
|
3666
4036
|
applicationConfig,
|
|
3667
4037
|
workersCount,
|
|
3668
4038
|
applicationId,
|
|
@@ -3673,27 +4043,68 @@ export class Runtime extends EventEmitter {
|
|
|
3673
4043
|
true
|
|
3674
4044
|
)
|
|
3675
4045
|
|
|
3676
|
-
// Make sure the runtime hasn't been stopped in the meanwhile
|
|
3677
|
-
if (this.#status !== 'started') {
|
|
4046
|
+
// Make sure the runtime hasn't been stopped and the replica hasn't been removed in the meanwhile
|
|
4047
|
+
if (this.#status !== 'started' || signal.aborted) {
|
|
3678
4048
|
return this.#discardWorker(newWorker)
|
|
3679
4049
|
}
|
|
3680
4050
|
} catch (e) {
|
|
3681
|
-
|
|
4051
|
+
error = e
|
|
4052
|
+
} finally {
|
|
4053
|
+
replica.pending = null
|
|
4054
|
+
replica.replacement = null
|
|
4055
|
+
}
|
|
3682
4056
|
|
|
4057
|
+
if (error) {
|
|
3683
4058
|
if (this.#workers.get(newWorkerId) === newWorker) {
|
|
3684
4059
|
this.#workers.delete(newWorkerId)
|
|
3685
4060
|
}
|
|
3686
4061
|
|
|
3687
4062
|
newWorker?.terminate?.()
|
|
3688
|
-
|
|
4063
|
+
|
|
4064
|
+
// The replica was removed in the meanwhile, the new worker was meant to be discarded anyway
|
|
4065
|
+
if (signal.aborted) {
|
|
4066
|
+
return
|
|
4067
|
+
}
|
|
4068
|
+
|
|
4069
|
+
// The replaced worker crashed (or was stopped before starting the new one): the replica must be refilled
|
|
4070
|
+
if (!replica.worker && this.#getApplicationRestartOnError(config, applicationConfig) > 0) {
|
|
4071
|
+
this.#refillReplica(config, applicationConfig, workersCount, applicationId, replica, true, 0).catch(err => {
|
|
4072
|
+
this.logger.error({ err: ensureLoggableError(err) }, `${oldLabel} could not be restarted.`)
|
|
4073
|
+
this.#detachReplica(applicationId, replica)
|
|
4074
|
+
})
|
|
4075
|
+
}
|
|
4076
|
+
|
|
4077
|
+
throw error
|
|
3689
4078
|
}
|
|
3690
4079
|
|
|
4080
|
+
// The replaced worker might have crashed in the meanwhile, in which case it is not in the replica anymore
|
|
4081
|
+
replica.worker = newWorker
|
|
4082
|
+
|
|
3691
4083
|
if (!stopBeforeStart) {
|
|
4084
|
+
if (deferOldWorkerRetirement) {
|
|
4085
|
+
return {
|
|
4086
|
+
discard: async () => {
|
|
4087
|
+
// Roll back: the replaced worker serves the replica again
|
|
4088
|
+
if (replica.worker === newWorker) {
|
|
4089
|
+
replica.worker = typeof worker[kWorkerExitCode] === 'number' ? null : worker
|
|
4090
|
+
}
|
|
4091
|
+
|
|
4092
|
+
await this.#discardWorker(newWorker)
|
|
4093
|
+
},
|
|
4094
|
+
retire: () => this.#removeWorker(workersCount, applicationId, oldIndex, worker, silent, oldLabel)
|
|
4095
|
+
}
|
|
4096
|
+
}
|
|
4097
|
+
|
|
3692
4098
|
await this.#removeWorker(workersCount, applicationId, oldIndex, worker, silent, oldLabel)
|
|
3693
4099
|
}
|
|
3694
4100
|
}
|
|
3695
4101
|
|
|
3696
4102
|
async #removeWorker (workersCount, applicationId, index, worker, silent, label) {
|
|
4103
|
+
// The worker crashed in the meanwhile, there is nothing left to remove
|
|
4104
|
+
if (typeof worker[kWorkerExitCode] === 'number') {
|
|
4105
|
+
return
|
|
4106
|
+
}
|
|
4107
|
+
|
|
3697
4108
|
if (!silent) {
|
|
3698
4109
|
this.logger.debug(`Preparing to stop the old version of ${label} ...`)
|
|
3699
4110
|
}
|
|
@@ -3759,7 +4170,13 @@ export class Runtime extends EventEmitter {
|
|
|
3759
4170
|
|
|
3760
4171
|
// Note that in this class "== null" is purposely used instead of "===" to check for both null and undefined
|
|
3761
4172
|
if (workerId == null) {
|
|
3762
|
-
|
|
4173
|
+
// During restarts and scaling the application also has workers which are still booting or being stopped:
|
|
4174
|
+
// only pick a worker which can serve the call. Booting workers cannot handle ITC calls yet,
|
|
4175
|
+
// stopping ones might exit before replying.
|
|
4176
|
+
worker = this.#workers.next(applicationId, worker => {
|
|
4177
|
+
const status = worker[kWorkerStatus]
|
|
4178
|
+
return status === 'started' || (!ensureStarted && (status === 'init' || status === 'starting'))
|
|
4179
|
+
})
|
|
3763
4180
|
} else {
|
|
3764
4181
|
worker = this.#workers.get(`${applicationId}:${workerId}`)
|
|
3765
4182
|
}
|
|
@@ -3771,6 +4188,11 @@ export class Runtime extends EventEmitter {
|
|
|
3771
4188
|
return null
|
|
3772
4189
|
}
|
|
3773
4190
|
|
|
4191
|
+
// The application has workers, but they are all booting or being stopped
|
|
4192
|
+
if (workerId == null && this.#workers.getKeys(applicationId).length > 0) {
|
|
4193
|
+
throw new ApplicationNotStartedError(applicationId)
|
|
4194
|
+
}
|
|
4195
|
+
|
|
3774
4196
|
if (applicationsIds.includes(applicationId)) {
|
|
3775
4197
|
const availableWorkers = this.#workers
|
|
3776
4198
|
.getKeys(applicationId)
|
|
@@ -3864,11 +4286,20 @@ export class Runtime extends EventEmitter {
|
|
|
3864
4286
|
}
|
|
3865
4287
|
|
|
3866
4288
|
async #getRuntimePackageJson () {
|
|
3867
|
-
const
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
4289
|
+
const packageJsonPath = join(this.#root, 'package.json')
|
|
4290
|
+
|
|
4291
|
+
/*
|
|
4292
|
+
A project need not have one, and a runtime that cannot report its metadata without one is a
|
|
4293
|
+
runtime whose `applications:add --save` stops working -- that command reads `projectDir`,
|
|
4294
|
+
`configPath` and `autoload` from this, and has done since `GET /config` was removed. The
|
|
4295
|
+
metadata already declares `packageName` and `packageVersion` as nullable, so absence is a
|
|
4296
|
+
state its shape allows.
|
|
4297
|
+
*/
|
|
4298
|
+
try {
|
|
4299
|
+
return JSON.parse(await readFile(packageJsonPath, 'utf8'))
|
|
4300
|
+
} catch {
|
|
4301
|
+
return {}
|
|
4302
|
+
}
|
|
3872
4303
|
}
|
|
3873
4304
|
|
|
3874
4305
|
#handleWorkerStandardStreams (worker, applicationId, workerId) {
|
|
@@ -4217,16 +4648,21 @@ export class Runtime extends EventEmitter {
|
|
|
4217
4648
|
await this.#updateApplicationConfigHealth(applicationId, health)
|
|
4218
4649
|
}
|
|
4219
4650
|
|
|
4220
|
-
//
|
|
4221
|
-
|
|
4222
|
-
for (const
|
|
4223
|
-
|
|
4651
|
+
// A copy: while the loop awaits, scale-downs remove replicas from the list, which would shift the remaining ones
|
|
4652
|
+
// and skip some. Replicas added in the meanwhile (scale-up) already use the new health config.
|
|
4653
|
+
for (const replica of [...(this.#replicas.get(applicationId) ?? [])]) {
|
|
4654
|
+
// The worker crashed, the replica is refilled with a fresh worker which uses the new health config
|
|
4655
|
+
const worker = replica.worker
|
|
4656
|
+
if (!worker) {
|
|
4657
|
+
continue
|
|
4658
|
+
}
|
|
4659
|
+
|
|
4660
|
+
const workerIndex = worker[kWorkerId]
|
|
4224
4661
|
this.logger.info(
|
|
4225
4662
|
{ health: { current: currentHealth, new: health } },
|
|
4226
4663
|
`Restarting application "${applicationId}" worker ${workerIndex} to update config health heap...`
|
|
4227
4664
|
)
|
|
4228
4665
|
|
|
4229
|
-
const worker = this.#workers.get(workerKey)
|
|
4230
4666
|
if (health.maxHeapTotal) {
|
|
4231
4667
|
worker[kConfig].health.maxHeapTotal = health.maxHeapTotal
|
|
4232
4668
|
}
|
|
@@ -4269,24 +4705,51 @@ export class Runtime extends EventEmitter {
|
|
|
4269
4705
|
|
|
4270
4706
|
if (currentWorkers < workers) {
|
|
4271
4707
|
report.started = []
|
|
4272
|
-
|
|
4708
|
+
|
|
4709
|
+
let pendingReplica
|
|
4273
4710
|
|
|
4274
4711
|
try {
|
|
4275
4712
|
for (let i = currentWorkers; i < workers; i++) {
|
|
4276
4713
|
const newIndex = this.#getNextWorkerIndex(applicationId)
|
|
4277
|
-
|
|
4278
|
-
|
|
4714
|
+
pendingReplica = await this.#setupReplica(
|
|
4715
|
+
config,
|
|
4716
|
+
applicationConfig,
|
|
4717
|
+
workers,
|
|
4718
|
+
applicationId,
|
|
4719
|
+
newIndex,
|
|
4720
|
+
this.#getNextReplicaPortOffset(applicationId)
|
|
4721
|
+
)
|
|
4722
|
+
|
|
4723
|
+
// The replica was removed in the meanwhile (i.e. scale-down or application stopped)
|
|
4724
|
+
if (!pendingReplica) {
|
|
4725
|
+
continue
|
|
4726
|
+
}
|
|
4279
4727
|
|
|
4280
|
-
await this.#
|
|
4281
|
-
|
|
4728
|
+
await this.#startWorker(
|
|
4729
|
+
config,
|
|
4730
|
+
applicationConfig,
|
|
4731
|
+
workers,
|
|
4732
|
+
applicationId,
|
|
4733
|
+
newIndex,
|
|
4734
|
+
false,
|
|
4735
|
+
0,
|
|
4736
|
+
pendingReplica.worker
|
|
4737
|
+
)
|
|
4282
4738
|
|
|
4283
|
-
|
|
4739
|
+
pendingReplica = undefined
|
|
4284
4740
|
report.started.push(newIndex)
|
|
4285
4741
|
startedWorkersCount++
|
|
4286
4742
|
}
|
|
4743
|
+
|
|
4744
|
+
// A worker added after boot re-runs the application's code, so it can answer differently
|
|
4745
|
+
// from the workers already running. Scale-up is the second place the answers must agree.
|
|
4746
|
+
await this.#collectServingState(applicationId)
|
|
4287
4747
|
report.success = true
|
|
4288
4748
|
} catch (err) {
|
|
4289
|
-
|
|
4749
|
+
// The worker of the replica could not be started
|
|
4750
|
+
if (pendingReplica && !pendingReplica.worker) {
|
|
4751
|
+
await this.#detachReplica(applicationId, pendingReplica)
|
|
4752
|
+
}
|
|
4290
4753
|
|
|
4291
4754
|
if (startedWorkersCount < 1) {
|
|
4292
4755
|
this.logger.error({ err }, 'Cannot start application workers, no worker started')
|
|
@@ -4303,31 +4766,30 @@ export class Runtime extends EventEmitter {
|
|
|
4303
4766
|
report.stopped = []
|
|
4304
4767
|
try {
|
|
4305
4768
|
const workersToStop = currentWorkers - workers
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4769
|
+
// Remove the empty replicas first (their worker crashed and they are waiting to be refilled), then the ones
|
|
4770
|
+
// with the highest port offsets, so that applications using server.portAssignment=perWorkerIncrement keep
|
|
4771
|
+
// listening on a contiguous range of ports starting from the configured one.
|
|
4772
|
+
const removed = (this.#replicas.get(applicationId) ?? [])
|
|
4773
|
+
.toSorted((a, b) => Number(Boolean(a.worker)) - Number(Boolean(b.worker)) || b.portOffset - a.portOffset)
|
|
4774
|
+
.slice(0, workersToStop)
|
|
4775
|
+
|
|
4776
|
+
// Detached right away, so that concurrent operations see the requested workers.
|
|
4777
|
+
// This also cancels the work in progress for them, i.e. refills and replacements.
|
|
4778
|
+
const detached = Promise.all(removed.map(replica => this.#detachReplica(applicationId, replica)))
|
|
4779
|
+
|
|
4780
|
+
for (const replica of removed) {
|
|
4781
|
+
const worker = replica.worker
|
|
4782
|
+
if (worker && typeof worker[kWorkerExitCode] !== 'number') {
|
|
4783
|
+
await sendViaITC(worker, 'removeFromMesh')
|
|
4784
|
+
await this.#stopWorker(currentWorkers, applicationId, worker[kWorkerId], false, worker, [])
|
|
4785
|
+
report.stopped.push(worker[kWorkerId])
|
|
4786
|
+
}
|
|
4322
4787
|
|
|
4323
|
-
for (const workerIndex of workerIdsToStop.splice(0, workersToStop)) {
|
|
4324
|
-
const worker = this.#workers.get(`${applicationId}:${workerIndex}`)
|
|
4325
|
-
await sendViaITC(worker, 'removeFromMesh')
|
|
4326
|
-
await this.#stopWorker(currentWorkers, applicationId, workerIndex, false, worker, [])
|
|
4327
|
-
report.stopped.push(workerIndex)
|
|
4328
4788
|
stoppedWorkersCount++
|
|
4329
4789
|
}
|
|
4330
4790
|
|
|
4791
|
+
await detached
|
|
4792
|
+
|
|
4331
4793
|
report.success = true
|
|
4332
4794
|
} catch (err) {
|
|
4333
4795
|
if (stoppedWorkersCount < 1) {
|
|
@@ -4373,6 +4835,10 @@ export class Runtime extends EventEmitter {
|
|
|
4373
4835
|
}
|
|
4374
4836
|
}
|
|
4375
4837
|
|
|
4838
|
+
if (applicationConfig.sourcePath) {
|
|
4839
|
+
allows.add(`--allow-fs-read=${join(applicationConfig.sourcePath, '*')}`)
|
|
4840
|
+
}
|
|
4841
|
+
|
|
4376
4842
|
if (write?.length) {
|
|
4377
4843
|
for (const p of write) {
|
|
4378
4844
|
allows.add(`--allow-fs-write=${isAbsolute(p) ? p : join(applicationConfig.path, p)}`)
|
|
@@ -4418,6 +4884,11 @@ export class Runtime extends EventEmitter {
|
|
|
4418
4884
|
#processHealthSignals ({ workerId, signals }) {
|
|
4419
4885
|
const worker = this.#workers.get(workerId)
|
|
4420
4886
|
|
|
4887
|
+
// Signals can arrive from a worker which is not registered (anymore)
|
|
4888
|
+
if (!worker) {
|
|
4889
|
+
return
|
|
4890
|
+
}
|
|
4891
|
+
|
|
4421
4892
|
worker[kWorkerHealthSignals] ??= new HealthSignalsQueue()
|
|
4422
4893
|
worker[kWorkerHealthSignals].add(signals)
|
|
4423
4894
|
}
|
|
@@ -4493,18 +4964,16 @@ export class Runtime extends EventEmitter {
|
|
|
4493
4964
|
}
|
|
4494
4965
|
|
|
4495
4966
|
#isApplicationStarted (id) {
|
|
4496
|
-
|
|
4497
|
-
if (!applicationConfig) {
|
|
4967
|
+
if (!this.#applications.has(id)) {
|
|
4498
4968
|
return false
|
|
4499
4969
|
}
|
|
4500
4970
|
|
|
4501
|
-
|
|
4502
|
-
|
|
4503
|
-
|
|
4504
|
-
const status =
|
|
4971
|
+
// Workers replaced by restarts get new indexes, so check all the workers of the application.
|
|
4972
|
+
// Anything past boot/init means already started.
|
|
4973
|
+
for (const workerId of this.#workers.getKeys(id)) {
|
|
4974
|
+
const status = this.#workers.get(workerId)[kWorkerStatus]
|
|
4505
4975
|
|
|
4506
|
-
|
|
4507
|
-
if (status && status !== 'boot' && status !== 'init') {
|
|
4976
|
+
if (status !== 'boot' && status !== 'init') {
|
|
4508
4977
|
return true
|
|
4509
4978
|
}
|
|
4510
4979
|
}
|
|
@@ -4847,15 +5316,55 @@ export class Runtime extends EventEmitter {
|
|
|
4847
5316
|
this.#loggerContext.updatePrefixes(ids)
|
|
4848
5317
|
}
|
|
4849
5318
|
|
|
4850
|
-
#
|
|
4851
|
-
|
|
5319
|
+
#getPortOwner (port, applicationId, hostname, includeSameApplication = false) {
|
|
5320
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
5321
|
+
return null
|
|
5322
|
+
}
|
|
4852
5323
|
|
|
4853
|
-
this.
|
|
4854
|
-
|
|
4855
|
-
|
|
4856
|
-
|
|
5324
|
+
for (const worker of this.#workers.values()) {
|
|
5325
|
+
if (!worker[kWorkerUrl] || (!includeSameApplication && worker[kApplicationId] === applicationId)) {
|
|
5326
|
+
continue
|
|
5327
|
+
}
|
|
5328
|
+
|
|
5329
|
+
try {
|
|
5330
|
+
const url = new URL(worker[kWorkerUrl])
|
|
5331
|
+
const workerPort = Number(url.port || (url.protocol === 'https:' ? 443 : 80))
|
|
5332
|
+
const workerHost = url.hostname
|
|
5333
|
+
if (workerPort === port && this.#listenersOverlap(hostname ?? workerHost, workerHost)) {
|
|
5334
|
+
return worker[kApplicationId]
|
|
5335
|
+
}
|
|
5336
|
+
} catch {}
|
|
5337
|
+
}
|
|
5338
|
+
|
|
5339
|
+
return null
|
|
5340
|
+
}
|
|
5341
|
+
|
|
5342
|
+
#listenersOverlap (host, otherHost) {
|
|
5343
|
+
host = host.toLowerCase()
|
|
5344
|
+
const wildcards = new Set(['0.0.0.0', '::', '[::]'])
|
|
5345
|
+
otherHost = otherHost.toLowerCase()
|
|
5346
|
+
return host === otherHost || wildcards.has(host) || wildcards.has(otherHost)
|
|
5347
|
+
}
|
|
5348
|
+
|
|
5349
|
+
#recordWorkerUrl (worker, applicationId, workerUrl) {
|
|
5350
|
+
if (!workerUrl) {
|
|
5351
|
+
worker[kWorkerUrl] = undefined
|
|
5352
|
+
return
|
|
5353
|
+
}
|
|
5354
|
+
|
|
5355
|
+
let hostname
|
|
5356
|
+
let port
|
|
5357
|
+
try {
|
|
5358
|
+
const url = new URL(workerUrl)
|
|
5359
|
+
hostname = url.hostname
|
|
5360
|
+
port = Number(url.port || (url.protocol === 'https:' ? 443 : 80))
|
|
5361
|
+
} catch {}
|
|
5362
|
+
|
|
5363
|
+
const owner = this.#getPortOwner(port, applicationId, hostname)
|
|
5364
|
+
if (owner) {
|
|
5365
|
+
throw new AddressInUseError(port, owner, applicationId)
|
|
5366
|
+
}
|
|
4857
5367
|
|
|
4858
|
-
|
|
4859
|
-
worker.terminate()
|
|
5368
|
+
worker[kWorkerUrl] = workerUrl
|
|
4860
5369
|
}
|
|
4861
5370
|
}
|