@platformatic/runtime 3.61.0 → 3.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config.d.ts +29 -0
- package/index.d.ts +32 -1
- package/lib/errors.js +25 -0
- package/lib/runtime.js +655 -107
- package/lib/worker/health-signals.js +34 -0
- package/lib/worker/main.js +18 -2
- package/lib/worker/round-robin-map.js +1 -1
- package/package.json +18 -18
- package/schema.json +220 -1
package/lib/runtime.js
CHANGED
|
@@ -23,7 +23,7 @@ import { readFile } from 'node:fs/promises'
|
|
|
23
23
|
import { STATUS_CODES } from 'node:http'
|
|
24
24
|
import { createRequire } from 'node:module'
|
|
25
25
|
import { availableParallelism } from 'node:os'
|
|
26
|
-
import { dirname, isAbsolute, join } from 'node:path'
|
|
26
|
+
import { basename, dirname, isAbsolute, join } from 'node:path'
|
|
27
27
|
import { Readable } from 'node:stream'
|
|
28
28
|
import { finished } from 'node:stream/promises'
|
|
29
29
|
import { setImmediate as immediate, setTimeout as sleep } from 'node:timers/promises'
|
|
@@ -39,9 +39,14 @@ import {
|
|
|
39
39
|
ApplicationNotStartedError,
|
|
40
40
|
ApplicationStartTimeoutError,
|
|
41
41
|
CannotRemoveEntrypointError,
|
|
42
|
+
DuplicateITCHandlerNameError,
|
|
43
|
+
FailedToLoadExtensionError,
|
|
42
44
|
InvalidArgumentError,
|
|
45
|
+
InvalidExtensionError,
|
|
46
|
+
LastProfileTimeoutError,
|
|
43
47
|
MessagingError,
|
|
44
48
|
MissingPprofCapture,
|
|
49
|
+
ReservedITCHandlerNameError,
|
|
45
50
|
RuntimeAbortedError,
|
|
46
51
|
WorkerInterceptorJoinTimeoutError,
|
|
47
52
|
WorkerNotFoundError
|
|
@@ -82,7 +87,25 @@ const kWorkerFile = join(import.meta.dirname, 'worker/main.js')
|
|
|
82
87
|
const kInspectorOptions = Symbol('plt.runtime.worker.inspectorOptions')
|
|
83
88
|
const kHeapCheckCounter = Symbol('plt.runtime.worker.heapCheckCounter')
|
|
84
89
|
const kLastHeapStats = Symbol('plt.runtime.worker.lastHeapStats')
|
|
90
|
+
const kProfilingELUGates = Symbol('plt.runtime.worker.profilingELUGates')
|
|
85
91
|
const kHealthITCTimeoutMs = 5000
|
|
92
|
+
const kProfilingELUHysteresis = 0.1
|
|
93
|
+
const kLastProfileTimeoutMs = 10000
|
|
94
|
+
|
|
95
|
+
// getApplicationLastProfile falls back to the preserved overload profile
|
|
96
|
+
// whenever the worker cannot currently provide one: it is gone, profiling was
|
|
97
|
+
// not (re)started, no window has completed yet (e.g. profiling was just
|
|
98
|
+
// restarted on a replacement worker) or the profiler is paused with no recent
|
|
99
|
+
// window. The returned timestamp lets consumers judge freshness. Any other
|
|
100
|
+
// error is rethrown.
|
|
101
|
+
const kLastProfileFallbackCodes = new Set([
|
|
102
|
+
'PLT_RUNTIME_APPLICATION_NOT_FOUND',
|
|
103
|
+
'PLT_RUNTIME_WORKER_NOT_FOUND',
|
|
104
|
+
'PLT_RUNTIME_APPLICATION_NOT_STARTED',
|
|
105
|
+
'PLT_PPROF_PROFILING_NOT_STARTED',
|
|
106
|
+
'PLT_PPROF_NO_PROFILE_AVAILABLE',
|
|
107
|
+
'PLT_PPROF_NOT_ENOUGH_ELU'
|
|
108
|
+
])
|
|
86
109
|
const kApplicationRestartsMetricName = 'platformatic_application_restarts_total'
|
|
87
110
|
const kApplicationRestartsMetricHelp = 'Total number of restarts triggered by the runtime for an application.'
|
|
88
111
|
|
|
@@ -103,11 +126,22 @@ function parseOrigins (origins) {
|
|
|
103
126
|
})
|
|
104
127
|
}
|
|
105
128
|
|
|
129
|
+
function formatMetricValue (value) {
|
|
130
|
+
if (Number.isNaN(value)) {
|
|
131
|
+
return 'NaN'
|
|
132
|
+
} else if (!Number.isFinite(value)) {
|
|
133
|
+
return value < 0 ? '-Inf' : '+Inf'
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return `${value}`
|
|
137
|
+
}
|
|
138
|
+
|
|
106
139
|
// Always run operations in parallel to avoid deadlocks when services have dependencies
|
|
107
140
|
const DEFAULT_CONCURRENCY = availableParallelism() * 2
|
|
108
141
|
const MAX_BOOTSTRAP_ATTEMPTS = 5
|
|
109
142
|
const IMMEDIATE_RESTART_MAX_THRESHOLD = 10
|
|
110
143
|
const MAX_WORKERS = 100
|
|
144
|
+
const DEFAULT_RESTART_ON_ERROR_DELAY = 5000
|
|
111
145
|
|
|
112
146
|
export class Runtime extends EventEmitter {
|
|
113
147
|
logger
|
|
@@ -134,6 +168,7 @@ export class Runtime extends EventEmitter {
|
|
|
134
168
|
#entrypointPort
|
|
135
169
|
|
|
136
170
|
#healthMetricsTimer
|
|
171
|
+
#healthMetricsCollectionActive
|
|
137
172
|
|
|
138
173
|
#meshInterceptor
|
|
139
174
|
#dispatcher
|
|
@@ -151,10 +186,15 @@ export class Runtime extends EventEmitter {
|
|
|
151
186
|
#workers
|
|
152
187
|
#workersBroadcastChannel
|
|
153
188
|
#workerITCHandlers
|
|
189
|
+
#reservedITCHandlerNames
|
|
190
|
+
#extensions
|
|
191
|
+
#extensionsWantHealthMetrics
|
|
192
|
+
#lastOverloadProfiles
|
|
154
193
|
#restartingApplications
|
|
155
194
|
#restartingWorkers
|
|
156
195
|
#dynamicWorkersScaler
|
|
157
196
|
#nextWorkerIndex
|
|
197
|
+
#workerPortOffsets
|
|
158
198
|
|
|
159
199
|
#sharedHttpCache
|
|
160
200
|
#scheduler
|
|
@@ -196,6 +236,7 @@ export class Runtime extends EventEmitter {
|
|
|
196
236
|
this.#restartingApplications = new Set()
|
|
197
237
|
this.#restartingWorkers = new Map()
|
|
198
238
|
this.#nextWorkerIndex = new Map()
|
|
239
|
+
this.#workerPortOffsets = new Map() // fullWorkerId => portOffset
|
|
199
240
|
this.#sharedHttpCache = null
|
|
200
241
|
this.#applicationsConfigsPatches = new Map()
|
|
201
242
|
|
|
@@ -220,6 +261,12 @@ export class Runtime extends EventEmitter {
|
|
|
220
261
|
getSharedContext: this.getSharedContext.bind(this),
|
|
221
262
|
sendHealthSignals: this.#processHealthSignals.bind(this)
|
|
222
263
|
}
|
|
264
|
+
this.#reservedITCHandlerNames = new Set(Object.keys(this.#workerITCHandlers))
|
|
265
|
+
// Registered per-worker in #setupWorker, reserved so extensions cannot clobber it
|
|
266
|
+
this.#reservedITCHandlerNames.add('profiling:started')
|
|
267
|
+
this.#extensions = []
|
|
268
|
+
this.#extensionsWantHealthMetrics = false
|
|
269
|
+
this.#lastOverloadProfiles = new Map()
|
|
223
270
|
this.#sharedContext = {}
|
|
224
271
|
|
|
225
272
|
if (this.#isProduction) {
|
|
@@ -284,6 +331,10 @@ export class Runtime extends EventEmitter {
|
|
|
284
331
|
}
|
|
285
332
|
}
|
|
286
333
|
|
|
334
|
+
// Load extensions before creating any worker so that custom ITC handlers
|
|
335
|
+
// registered by the extensions are available to all workers.
|
|
336
|
+
await this.#loadExtensions()
|
|
337
|
+
|
|
287
338
|
await this.addApplications(this.#config.applications)
|
|
288
339
|
await this.#setDispatcher(config.undici)
|
|
289
340
|
|
|
@@ -410,10 +461,14 @@ export class Runtime extends EventEmitter {
|
|
|
410
461
|
|
|
411
462
|
async close (silent = false) {
|
|
412
463
|
clearTimeout(this.#healthMetricsTimer)
|
|
464
|
+
this.#healthMetricsCollectionActive = false
|
|
465
|
+
this.#lastOverloadProfiles.clear()
|
|
413
466
|
|
|
414
467
|
await this.stop(silent)
|
|
415
468
|
this.#updateStatus('closing')
|
|
416
469
|
|
|
470
|
+
await this.#closeExtensions()
|
|
471
|
+
|
|
417
472
|
// The management API autocloses by itself via event in management-api.js.
|
|
418
473
|
// This is needed to let management API stop endpoint to reply.
|
|
419
474
|
|
|
@@ -544,12 +599,19 @@ export class Runtime extends EventEmitter {
|
|
|
544
599
|
|
|
545
600
|
async addApplications (applications, start = false) {
|
|
546
601
|
const setupInvocations = []
|
|
602
|
+
// Per-worker ports do not need SO_REUSEPORT because each worker binds a different port.
|
|
603
|
+
const usesPerWorkerPorts = this.#config.server?.portAssignment === 'perWorkerIncrement'
|
|
547
604
|
|
|
548
605
|
const toStart = []
|
|
549
606
|
for (const application of applications) {
|
|
550
607
|
const workers = application.workers
|
|
551
608
|
|
|
552
|
-
if (
|
|
609
|
+
if (
|
|
610
|
+
(workers.static > 1 || workers.minimum > 1) &&
|
|
611
|
+
application.entrypoint &&
|
|
612
|
+
!features.node.reusePort &&
|
|
613
|
+
!usesPerWorkerPorts
|
|
614
|
+
) {
|
|
553
615
|
this.logger.warn(
|
|
554
616
|
`"${application.id}" is set as the entrypoint, but reusePort is not available in your OS; setting workers to 1 instead of ${workers.static}`
|
|
555
617
|
)
|
|
@@ -809,6 +871,91 @@ export class Runtime extends EventEmitter {
|
|
|
809
871
|
return sendViaITC(service, 'stopProfiling', options)
|
|
810
872
|
}
|
|
811
873
|
|
|
874
|
+
async getApplicationLastProfile (id, options = {}, ensureStarted = true) {
|
|
875
|
+
this.#validatePprofCapturePreload()
|
|
876
|
+
|
|
877
|
+
const type = options.type ?? 'cpu'
|
|
878
|
+
const timeout = options.timeout ?? kLastProfileTimeoutMs
|
|
879
|
+
let error
|
|
880
|
+
|
|
881
|
+
try {
|
|
882
|
+
// Bound the whole retrieval with a single timeout budget: resolving the
|
|
883
|
+
// worker round-trips to it when ensureStarted is set, and the profile
|
|
884
|
+
// pull does too — both hang if the worker event loop is blocked. Attach
|
|
885
|
+
// a noop handler so that a late settlement after the timeout does not
|
|
886
|
+
// surface as an unhandled rejection.
|
|
887
|
+
const pull = (async () => {
|
|
888
|
+
const service = await this.#getApplicationById(id, ensureStarted)
|
|
889
|
+
const result = await sendViaITC(service, 'getLastProfile', { ...options, includeTimestamp: true })
|
|
890
|
+
return { service, result }
|
|
891
|
+
})()
|
|
892
|
+
pull.catch(() => {})
|
|
893
|
+
|
|
894
|
+
const outcome = await executeWithTimeout(pull, timeout, kTimeout)
|
|
895
|
+
|
|
896
|
+
if (outcome !== kTimeout) {
|
|
897
|
+
const { service, result } = outcome
|
|
898
|
+
|
|
899
|
+
// An older capture module which does not support includeTimestamp
|
|
900
|
+
// returns the raw profile.
|
|
901
|
+
const value = result instanceof Uint8Array ? { profile: result, timestamp: null } : result
|
|
902
|
+
|
|
903
|
+
// A strictly newer live window supersedes the preserved overload
|
|
904
|
+
// profile: prune it so the preserved copy naturally expires once the
|
|
905
|
+
// worker is healthy again and its profiles are being consumed.
|
|
906
|
+
if (value.timestamp != null) {
|
|
907
|
+
const key = `${service[kApplicationId]}:${service[kWorkerId]}:${type}`
|
|
908
|
+
const preserved = this.#lastOverloadProfiles.get(key)
|
|
909
|
+
|
|
910
|
+
if (preserved && preserved.timestamp < value.timestamp) {
|
|
911
|
+
this.#lastOverloadProfiles.delete(key)
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
return { ...value, preserved: false }
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// The worker event loop is not responding (e.g. it is hard-blocked).
|
|
919
|
+
error = new LastProfileTimeoutError(id)
|
|
920
|
+
} catch (e) {
|
|
921
|
+
if (!kLastProfileFallbackCodes.has(e.code)) {
|
|
922
|
+
throw e
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
error = e
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const preserved = this.#getPreservedOverloadProfile(id, type)
|
|
929
|
+
|
|
930
|
+
if (preserved) {
|
|
931
|
+
// The preserved flag lets consumers distinguish post-mortem evidence
|
|
932
|
+
// from a live window and judge it together with the timestamp.
|
|
933
|
+
return { profile: preserved.profile, timestamp: preserved.timestamp, preserved: true }
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
throw error
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// The final profile of an overload pause is pushed by the worker and
|
|
940
|
+
// preserved in the main thread (see the profile:overload listener), so that
|
|
941
|
+
// the evidence of what saturated a worker survives the worker being blocked
|
|
942
|
+
// or replaced.
|
|
943
|
+
#getPreservedOverloadProfile (id, type) {
|
|
944
|
+
if (id.includes(':')) {
|
|
945
|
+
return this.#lastOverloadProfiles.get(`${id}:${type}`)
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Application-level id: return the most recent profile among its workers
|
|
949
|
+
let latest = null
|
|
950
|
+
for (const [key, entry] of this.#lastOverloadProfiles) {
|
|
951
|
+
if (key.startsWith(`${id}:`) && key.endsWith(`:${type}`) && (!latest || entry.timestamp > latest.timestamp)) {
|
|
952
|
+
latest = entry
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
return latest
|
|
957
|
+
}
|
|
958
|
+
|
|
812
959
|
async takeApplicationHeapSnapshot (id, ensureStarted = true) {
|
|
813
960
|
const service = await this.#getApplicationById(id, ensureStarted)
|
|
814
961
|
|
|
@@ -1242,9 +1389,7 @@ export class Runtime extends EventEmitter {
|
|
|
1242
1389
|
|
|
1243
1390
|
let metrics = null
|
|
1244
1391
|
|
|
1245
|
-
const applicationRestartMetrics =
|
|
1246
|
-
? this.#getApplicationRestartMetricsJson()
|
|
1247
|
-
: this.#formatApplicationRestartMetricsText()
|
|
1392
|
+
const applicationRestartMetrics = this.#getApplicationRestartMetricsJson()
|
|
1248
1393
|
|
|
1249
1394
|
// Get process-level metrics once from main thread registry (if available)
|
|
1250
1395
|
let processMetricsJson = null
|
|
@@ -1259,16 +1404,16 @@ export class Runtime extends EventEmitter {
|
|
|
1259
1404
|
continue
|
|
1260
1405
|
}
|
|
1261
1406
|
|
|
1262
|
-
// Get thread-specific metrics from worker
|
|
1407
|
+
// Get thread-specific metrics from worker. Always collect JSON so that
|
|
1408
|
+
// the text format can be serialized once with a single HELP/TYPE block
|
|
1409
|
+
// per metric family, as required by the Prometheus exposition format.
|
|
1263
1410
|
const applicationMetrics = await executeWithTimeout(
|
|
1264
|
-
sendViaITC(worker, 'getMetrics',
|
|
1411
|
+
sendViaITC(worker, 'getMetrics', 'json'),
|
|
1265
1412
|
this.#config.metrics?.timeout ?? 10000
|
|
1266
1413
|
)
|
|
1267
1414
|
|
|
1268
1415
|
if (applicationMetrics && applicationMetrics !== kTimeout) {
|
|
1269
|
-
|
|
1270
|
-
metrics = format === 'json' ? [] : ''
|
|
1271
|
-
}
|
|
1416
|
+
metrics ??= []
|
|
1272
1417
|
|
|
1273
1418
|
// Build worker labels including custom labels from metrics config
|
|
1274
1419
|
const workerLabels = {
|
|
@@ -1280,21 +1425,13 @@ export class Runtime extends EventEmitter {
|
|
|
1280
1425
|
workerLabels.workerId = workerId
|
|
1281
1426
|
}
|
|
1282
1427
|
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
metrics.push(applicationMetrics[i])
|
|
1291
|
-
}
|
|
1292
|
-
} else {
|
|
1293
|
-
// Text format: format process metrics with worker labels
|
|
1294
|
-
if (processMetricsJson) {
|
|
1295
|
-
metrics += this.#formatProcessMetricsText(processMetricsJson, workerLabels)
|
|
1296
|
-
}
|
|
1297
|
-
metrics += applicationMetrics
|
|
1428
|
+
// Duplicate process metrics with worker labels and add to output
|
|
1429
|
+
if (processMetricsJson) {
|
|
1430
|
+
this.#applyLabelsToMetrics(processMetricsJson, workerLabels, metrics)
|
|
1431
|
+
}
|
|
1432
|
+
// Add worker's thread-specific metrics
|
|
1433
|
+
for (let i = 0; i < applicationMetrics.length; i++) {
|
|
1434
|
+
metrics.push(applicationMetrics[i])
|
|
1298
1435
|
}
|
|
1299
1436
|
}
|
|
1300
1437
|
} catch (e) {
|
|
@@ -1312,9 +1449,11 @@ export class Runtime extends EventEmitter {
|
|
|
1312
1449
|
}
|
|
1313
1450
|
|
|
1314
1451
|
if (metrics !== null && applicationRestartMetrics.length > 0) {
|
|
1315
|
-
metrics =
|
|
1316
|
-
|
|
1317
|
-
|
|
1452
|
+
metrics = [...applicationRestartMetrics, ...metrics]
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
if (metrics !== null && format !== 'json') {
|
|
1456
|
+
metrics = this.#formatMetricsAsText(metrics)
|
|
1318
1457
|
}
|
|
1319
1458
|
|
|
1320
1459
|
return { metrics }
|
|
@@ -1351,28 +1490,6 @@ export class Runtime extends EventEmitter {
|
|
|
1351
1490
|
return metrics
|
|
1352
1491
|
}
|
|
1353
1492
|
|
|
1354
|
-
#formatApplicationRestartMetricsText () {
|
|
1355
|
-
let output = ''
|
|
1356
|
-
|
|
1357
|
-
for (const applicationId of this.#applications.keys()) {
|
|
1358
|
-
const labelParts = []
|
|
1359
|
-
const labels = this.#getApplicationRestartMetricLabels(applicationId)
|
|
1360
|
-
|
|
1361
|
-
for (const [key, val] of Object.entries(labels)) {
|
|
1362
|
-
const escapedVal = String(val).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')
|
|
1363
|
-
labelParts.push(`${key}="${escapedVal}"`)
|
|
1364
|
-
}
|
|
1365
|
-
|
|
1366
|
-
const labelStr = labelParts.length > 0 ? `{${labelParts.join(',')}}` : ''
|
|
1367
|
-
|
|
1368
|
-
output += `# HELP ${kApplicationRestartsMetricName} ${kApplicationRestartsMetricHelp}\n`
|
|
1369
|
-
output += `# TYPE ${kApplicationRestartsMetricName} counter\n`
|
|
1370
|
-
output += `${kApplicationRestartsMetricName}${labelStr} ${this.#applicationRestartCounts.get(applicationId) ?? 0}\n`
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
return output
|
|
1374
|
-
}
|
|
1375
|
-
|
|
1376
1493
|
// Apply labels to process metrics and push to output array (for JSON format)
|
|
1377
1494
|
#applyLabelsToMetrics (processMetrics, labels, outputArray) {
|
|
1378
1495
|
for (let i = 0; i < processMetrics.length; i++) {
|
|
@@ -1397,27 +1514,33 @@ export class Runtime extends EventEmitter {
|
|
|
1397
1514
|
}
|
|
1398
1515
|
}
|
|
1399
1516
|
|
|
1400
|
-
//
|
|
1401
|
-
|
|
1402
|
-
|
|
1517
|
+
// Serialize JSON metrics to the Prometheus text exposition format.
|
|
1518
|
+
// Samples are grouped by metric family so each family is emitted as a single
|
|
1519
|
+
// block with one HELP/TYPE header: the format forbids repeating them, and
|
|
1520
|
+
// strict parsers (e.g. OpenMetrics-based ones like Dynatrace) would otherwise
|
|
1521
|
+
// only ingest the first block for each metric name.
|
|
1522
|
+
#formatMetricsAsText (metricsJson) {
|
|
1523
|
+
const families = new Map()
|
|
1403
1524
|
|
|
1404
|
-
for (
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1525
|
+
for (const metric of metricsJson) {
|
|
1526
|
+
let family = families.get(metric.name)
|
|
1527
|
+
if (!family) {
|
|
1528
|
+
family = { help: metric.help, type: metric.type, values: [] }
|
|
1529
|
+
families.set(metric.name, family)
|
|
1530
|
+
}
|
|
1531
|
+
family.values.push(...metric.values)
|
|
1532
|
+
}
|
|
1409
1533
|
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1534
|
+
let output = ''
|
|
1535
|
+
for (const [name, family] of families) {
|
|
1536
|
+
const escapedHelp = String(family.help).replace(/\\/g, '\\\\').replace(/\n/g, '\\n')
|
|
1537
|
+
output += `# HELP ${name} ${escapedHelp}\n`
|
|
1538
|
+
output += `# TYPE ${name} ${family.type}\n`
|
|
1413
1539
|
|
|
1414
|
-
const
|
|
1415
|
-
for (let j = 0; j < values.length; j++) {
|
|
1416
|
-
const v = values[j]
|
|
1417
|
-
const combinedLabels = { ...labels, ...v.labels }
|
|
1540
|
+
for (const v of family.values) {
|
|
1418
1541
|
const labelParts = []
|
|
1419
1542
|
|
|
1420
|
-
for (const [key, val] of Object.entries(
|
|
1543
|
+
for (const [key, val] of Object.entries(v.labels ?? {})) {
|
|
1421
1544
|
// Escape label values for Prometheus format
|
|
1422
1545
|
const escapedVal = String(val).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')
|
|
1423
1546
|
labelParts.push(`${key}="${escapedVal}"`)
|
|
@@ -1425,7 +1548,7 @@ export class Runtime extends EventEmitter {
|
|
|
1425
1548
|
|
|
1426
1549
|
const labelStr = labelParts.length > 0 ? `{${labelParts.join(',')}}` : ''
|
|
1427
1550
|
const metricName = v.metricName || name
|
|
1428
|
-
output += `${metricName}${labelStr} ${v.value}\n`
|
|
1551
|
+
output += `${metricName}${labelStr} ${formatMetricValue(v.value)}\n`
|
|
1429
1552
|
}
|
|
1430
1553
|
}
|
|
1431
1554
|
|
|
@@ -1833,6 +1956,7 @@ export class Runtime extends EventEmitter {
|
|
|
1833
1956
|
const setupInvocations = []
|
|
1834
1957
|
|
|
1835
1958
|
for (let i = 0; i < workers; i++) {
|
|
1959
|
+
this.#workerPortOffsets.set(`${id}:${i}`, i)
|
|
1836
1960
|
setupInvocations.push([config, applicationConfig, workers, id, i])
|
|
1837
1961
|
}
|
|
1838
1962
|
|
|
@@ -1846,7 +1970,7 @@ export class Runtime extends EventEmitter {
|
|
|
1846
1970
|
}
|
|
1847
1971
|
|
|
1848
1972
|
async #setupWorker (config, applicationConfig, workersCount, applicationId, index, enabled = true, attempt = 0) {
|
|
1849
|
-
const
|
|
1973
|
+
const restartOnError = this.#getApplicationRestartOnError(config, applicationConfig)
|
|
1850
1974
|
const workerId = `${applicationId}:${index}`
|
|
1851
1975
|
|
|
1852
1976
|
// Handle inspector
|
|
@@ -1860,6 +1984,12 @@ export class Runtime extends EventEmitter {
|
|
|
1860
1984
|
inspectorOptions.port = inspectorOptions.port + this.#workers.size + 1
|
|
1861
1985
|
}
|
|
1862
1986
|
|
|
1987
|
+
let serverConfigOverride
|
|
1988
|
+
if (this.#config.server?.portAssignment === 'perWorkerIncrement') {
|
|
1989
|
+
const portOffset = this.#workerPortOffsets.get(workerId)
|
|
1990
|
+
serverConfigOverride = { port: Number(this.#config.server.port) + portOffset }
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1863
1993
|
if (config.telemetry) {
|
|
1864
1994
|
applicationConfig.telemetry = {
|
|
1865
1995
|
...config.telemetry,
|
|
@@ -1923,12 +2053,21 @@ export class Runtime extends EventEmitter {
|
|
|
1923
2053
|
const maxYoungGenerationSizeMb = maxYoungGeneration ? Math.floor(maxYoungGeneration / (1024 * 1024)) : undefined
|
|
1924
2054
|
const codeRangeSizeMb = codeRangeSize ? Math.floor(codeRangeSize / (1024 * 1024)) : undefined
|
|
1925
2055
|
|
|
2056
|
+
const workerConfig = {
|
|
2057
|
+
...config,
|
|
2058
|
+
preload
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
if (config.server && serverConfigOverride) {
|
|
2062
|
+
workerConfig.server = {
|
|
2063
|
+
...config.server,
|
|
2064
|
+
...serverConfigOverride
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
|
|
1926
2068
|
const worker = new Worker(kWorkerFile, {
|
|
1927
2069
|
workerData: {
|
|
1928
|
-
config:
|
|
1929
|
-
...config,
|
|
1930
|
-
preload
|
|
1931
|
-
},
|
|
2070
|
+
config: workerConfig,
|
|
1932
2071
|
applicationConfig: {
|
|
1933
2072
|
...applicationConfig,
|
|
1934
2073
|
isProduction: this.#isProduction,
|
|
@@ -1977,6 +2116,7 @@ export class Runtime extends EventEmitter {
|
|
|
1977
2116
|
worker[kWorkerStatus] = 'exited'
|
|
1978
2117
|
this.emitAndNotify('application:worker:exited', eventPayload)
|
|
1979
2118
|
|
|
2119
|
+
const portOffset = this.#workerPortOffsets.get(worker[kFullId]) ?? index
|
|
1980
2120
|
this.#cleanupWorker(worker)
|
|
1981
2121
|
|
|
1982
2122
|
if (this.#status === 'stopping') {
|
|
@@ -2001,7 +2141,7 @@ export class Runtime extends EventEmitter {
|
|
|
2001
2141
|
this.logger.warn(`The ${errorLabel} will be restarted in ${restartOnError}ms ...`)
|
|
2002
2142
|
}
|
|
2003
2143
|
|
|
2004
|
-
this.#restartCrashedWorker(config, applicationConfig, workersCount, applicationId, index, false, 0).catch(
|
|
2144
|
+
this.#restartCrashedWorker(config, applicationConfig, workersCount, applicationId, index, false, 0, portOffset).catch(
|
|
2005
2145
|
err => {
|
|
2006
2146
|
this.logger.error({ err: ensureLoggableError(err) }, `${errorLabel} could not be restarted.`)
|
|
2007
2147
|
}
|
|
@@ -2076,6 +2216,120 @@ export class Runtime extends EventEmitter {
|
|
|
2076
2216
|
this.#opentelemetryMetricsForwarder?.collect(resourceMetrics)
|
|
2077
2217
|
})
|
|
2078
2218
|
|
|
2219
|
+
// The continuous profiler notifies us when a profile window is completed.
|
|
2220
|
+
// The event only carries metadata: the profile can be retrieved on demand
|
|
2221
|
+
// via getApplicationLastProfile. We use emit instead of emitAndNotify since
|
|
2222
|
+
// other workers are not interested in this event.
|
|
2223
|
+
worker[kITC].on('profile:captured', ({ type, timestamp }) => {
|
|
2224
|
+
// A strictly newer completed window supersedes the preserved overload
|
|
2225
|
+
// profile: once the worker is past the overload and producing windows
|
|
2226
|
+
// again, its old evidence must not be served anymore.
|
|
2227
|
+
const preservedKey = `${workerId}:${type}`
|
|
2228
|
+
const preservedEntry = this.#lastOverloadProfiles.get(preservedKey)
|
|
2229
|
+
|
|
2230
|
+
if (preservedEntry && preservedEntry.timestamp < timestamp) {
|
|
2231
|
+
this.#lastOverloadProfiles.delete(preservedKey)
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
this.emit('application:worker:profile:captured', {
|
|
2235
|
+
id: workerId,
|
|
2236
|
+
application: applicationId,
|
|
2237
|
+
worker: index,
|
|
2238
|
+
type,
|
|
2239
|
+
timestamp
|
|
2240
|
+
})
|
|
2241
|
+
})
|
|
2242
|
+
|
|
2243
|
+
// The continuous profiler registers its gating needs when profiling starts.
|
|
2244
|
+
// The main thread drives it based on the ELU measured by the health
|
|
2245
|
+
// metrics cycle (see #applyProfilingELUGates): when an ELU threshold is
|
|
2246
|
+
// set the profiler starts paused and only runs while the ELU is above it,
|
|
2247
|
+
// and continuous profiling is paused while the worker ELU is above the
|
|
2248
|
+
// maxELU cutoff so that profiling does not add overhead to an already
|
|
2249
|
+
// overloaded worker. This is a request handler rather than a notification
|
|
2250
|
+
// listener on purpose: the capture module can detect a runtime without
|
|
2251
|
+
// the driver (PLT_ITC_HANDLER_NOT_FOUND) and fall back to ungated
|
|
2252
|
+
// profiling instead of starting paused forever.
|
|
2253
|
+
worker[kITC].handle('profiling:started', ({ type, eluThreshold, maxELU, continuous }) => {
|
|
2254
|
+
// Resolve the overload cutoff: the maxELU profiling option overrides it
|
|
2255
|
+
// (false disables it), otherwise continuous profiling defaults to the
|
|
2256
|
+
// worker health.maxELU.
|
|
2257
|
+
let overloadELU = null
|
|
2258
|
+
if (typeof maxELU === 'number') {
|
|
2259
|
+
overloadELU = maxELU
|
|
2260
|
+
} else if (maxELU !== false && continuous) {
|
|
2261
|
+
const healthConfig = worker[kConfig]?.health
|
|
2262
|
+
const configMaxELU = Number(healthConfig?.maxELU)
|
|
2263
|
+
|
|
2264
|
+
if (healthConfig?.enabled !== false && Number.isFinite(configMaxELU)) {
|
|
2265
|
+
overloadELU = configMaxELU
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
if (eluThreshold == null && overloadELU == null) {
|
|
2270
|
+
worker[kProfilingELUGates]?.delete(type)
|
|
2271
|
+
return true
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
worker[kProfilingELUGates] ??= new Map()
|
|
2275
|
+
worker[kProfilingELUGates].set(type, {
|
|
2276
|
+
eluThreshold: eluThreshold ?? null,
|
|
2277
|
+
maxELU: overloadELU,
|
|
2278
|
+
// Hysteresis memories: `wanted` tracks the eluThreshold demand,
|
|
2279
|
+
// `overloaded` tracks the maxELU cutoff. `running` is the last state
|
|
2280
|
+
// commanded to the worker: the profiler starts paused when an ELU
|
|
2281
|
+
// threshold is set and running otherwise.
|
|
2282
|
+
wanted: eluThreshold == null,
|
|
2283
|
+
overloaded: false,
|
|
2284
|
+
running: eluThreshold == null
|
|
2285
|
+
})
|
|
2286
|
+
this.#startHealthMetricsCollectionIfNeeded()
|
|
2287
|
+
|
|
2288
|
+
return true
|
|
2289
|
+
})
|
|
2290
|
+
|
|
2291
|
+
worker[kITC].on('profiling:stopped', ({ type }) => {
|
|
2292
|
+
worker[kProfilingELUGates]?.delete(type)
|
|
2293
|
+
this.#lastOverloadProfiles.delete(`${workerId}:${type}`)
|
|
2294
|
+
})
|
|
2295
|
+
|
|
2296
|
+
// When an overload pause is applied, the worker pushes the encoded final
|
|
2297
|
+
// profile. It is preserved here so that the evidence of what saturated
|
|
2298
|
+
// the worker can be retrieved (see getApplicationLastProfile) even while
|
|
2299
|
+
// the worker event loop is blocked, and it survives the worker being
|
|
2300
|
+
// replaced by the health checks.
|
|
2301
|
+
worker[kITC].on('profile:overload', ({ type, timestamp, profile }) => {
|
|
2302
|
+
this.#lastOverloadProfiles.set(`${workerId}:${type}`, { profile, timestamp })
|
|
2303
|
+
})
|
|
2304
|
+
|
|
2305
|
+
// Preserved overload profiles outlive their worker only for a grace
|
|
2306
|
+
// period of twice the runtime graceful shutdown timeout: post-mortem
|
|
2307
|
+
// collectors (alert or health-event driven) have time to fetch the
|
|
2308
|
+
// evidence, but a long-dead worker's profile is not served forever and
|
|
2309
|
+
// entries cannot pile up across replacements (replacement workers get
|
|
2310
|
+
// fresh indices, so their keys are never reused).
|
|
2311
|
+
worker.on('exit', () => {
|
|
2312
|
+
for (const type of ['cpu', 'heap']) {
|
|
2313
|
+
const key = `${workerId}:${type}`
|
|
2314
|
+
const entry = this.#lastOverloadProfiles.get(key)
|
|
2315
|
+
|
|
2316
|
+
if (!entry) {
|
|
2317
|
+
continue
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
const gracefulShutdown = Number(this.#config?.gracefulShutdown?.runtime)
|
|
2321
|
+
const grace = Number.isFinite(gracefulShutdown) && gracefulShutdown > 0 ? gracefulShutdown * 2 : 20_000
|
|
2322
|
+
|
|
2323
|
+
setTimeout(() => {
|
|
2324
|
+
// Only delete the exact entry scheduled here: a successor worker
|
|
2325
|
+
// reusing the index may have preserved a newer profile meanwhile.
|
|
2326
|
+
if (this.#lastOverloadProfiles.get(key) === entry) {
|
|
2327
|
+
this.#lastOverloadProfiles.delete(key)
|
|
2328
|
+
}
|
|
2329
|
+
}, grace).unref()
|
|
2330
|
+
}
|
|
2331
|
+
})
|
|
2332
|
+
|
|
2079
2333
|
worker[kITC].on('request:restart', async () => {
|
|
2080
2334
|
// Do not restart applications that are not fully started yet or when the runtime is still starting.
|
|
2081
2335
|
// The gateway sends request:restart when it receives application:added events,
|
|
@@ -2164,15 +2418,24 @@ export class Runtime extends EventEmitter {
|
|
|
2164
2418
|
}
|
|
2165
2419
|
|
|
2166
2420
|
#startHealthMetricsCollectionIfNeeded () {
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2421
|
+
if (this.#healthMetricsCollectionActive || this.#status !== 'started') {
|
|
2422
|
+
return
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
// Need health metrics if dynamic workers scaler exists (for vertical scaling),
|
|
2426
|
+
// if an extension subscribed to them, if any worker has health checks enabled
|
|
2427
|
+
// or if any worker runs ELU-gated continuous profiling
|
|
2428
|
+
let needsHealthMetrics = !!this.#dynamicWorkersScaler || this.#extensionsWantHealthMetrics
|
|
2170
2429
|
|
|
2171
2430
|
if (!needsHealthMetrics) {
|
|
2172
|
-
// Check if any worker has health checks enabled
|
|
2173
2431
|
for (const worker of this.#workers.values()) {
|
|
2174
2432
|
const healthConfig = worker[kConfig]?.health
|
|
2175
|
-
if (healthConfig?.enabled && this.#config
|
|
2433
|
+
if (healthConfig?.enabled && this.#getApplicationRestartOnError(this.#config, worker[kConfig]) > 0) {
|
|
2434
|
+
needsHealthMetrics = true
|
|
2435
|
+
break
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
if (worker[kProfilingELUGates]?.size > 0) {
|
|
2176
2439
|
needsHealthMetrics = true
|
|
2177
2440
|
break
|
|
2178
2441
|
}
|
|
@@ -2185,8 +2448,11 @@ export class Runtime extends EventEmitter {
|
|
|
2185
2448
|
}
|
|
2186
2449
|
|
|
2187
2450
|
#startHealthMetricsCollection () {
|
|
2451
|
+
this.#healthMetricsCollectionActive = true
|
|
2452
|
+
|
|
2188
2453
|
const collectHealthMetrics = async () => {
|
|
2189
2454
|
if (this.#status !== 'started') {
|
|
2455
|
+
this.#healthMetricsCollectionActive = false
|
|
2190
2456
|
return
|
|
2191
2457
|
}
|
|
2192
2458
|
|
|
@@ -2202,12 +2468,11 @@ export class Runtime extends EventEmitter {
|
|
|
2202
2468
|
const id = worker[kApplicationId]
|
|
2203
2469
|
const index = worker[kWorkerId]
|
|
2204
2470
|
const errorLabel = this.#workerExtendedLabel(id, index, worker[kConfig].workers)
|
|
2471
|
+
const previousELU = worker[kLastHealthCheckELU]
|
|
2205
2472
|
|
|
2206
2473
|
let health = null
|
|
2207
2474
|
try {
|
|
2208
|
-
health = await this.getWorkerHealth(worker, {
|
|
2209
|
-
previousELU: worker[kLastHealthCheckELU]
|
|
2210
|
-
})
|
|
2475
|
+
health = await this.getWorkerHealth(worker, { previousELU })
|
|
2211
2476
|
} catch (err) {
|
|
2212
2477
|
this.logger.error({ err }, `Failed to get health for ${errorLabel}.`)
|
|
2213
2478
|
} finally {
|
|
@@ -2226,6 +2491,13 @@ export class Runtime extends EventEmitter {
|
|
|
2226
2491
|
currentHealth: health,
|
|
2227
2492
|
healthSignals
|
|
2228
2493
|
})
|
|
2494
|
+
|
|
2495
|
+
// Drive the ELU gating of the continuous profiler. The first sample
|
|
2496
|
+
// is skipped as it reports the utilization since the thread started
|
|
2497
|
+
// rather than over the last collection interval.
|
|
2498
|
+
if (health && previousELU != null) {
|
|
2499
|
+
this.#applyProfilingELUGates(worker, health.elu)
|
|
2500
|
+
}
|
|
2229
2501
|
})())
|
|
2230
2502
|
}
|
|
2231
2503
|
|
|
@@ -2240,6 +2512,56 @@ export class Runtime extends EventEmitter {
|
|
|
2240
2512
|
this.#healthMetricsTimer = setTimeout(collectHealthMetrics, 1000).unref()
|
|
2241
2513
|
}
|
|
2242
2514
|
|
|
2515
|
+
// Drive the ELU gating of the continuous profiler from the main thread.
|
|
2516
|
+
// The health metrics cycle measures the worker ELU (without depending on the
|
|
2517
|
+
// worker's event loop being responsive, and consistently with health checks)
|
|
2518
|
+
// and resumes/pauses the in-worker profiler accordingly. The profiler runs
|
|
2519
|
+
// while the ELU is above the eluThreshold demand (if one is set) and below
|
|
2520
|
+
// the maxELU overload cutoff (if one is set): crossing the cutoff captures
|
|
2521
|
+
// one last profile and pauses profiling until the worker recovers, so that
|
|
2522
|
+
// profiling does not add overhead to an already overloaded worker.
|
|
2523
|
+
#applyProfilingELUGates (worker, elu) {
|
|
2524
|
+
const gates = worker[kProfilingELUGates]
|
|
2525
|
+
|
|
2526
|
+
if (!gates?.size || typeof elu !== 'number') {
|
|
2527
|
+
return
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
for (const [type, gate] of gates) {
|
|
2531
|
+
// Hysteresis on both bounds to prevent rapid toggling: each state only
|
|
2532
|
+
// flips back once the ELU moves kProfilingELUHysteresis past the bound.
|
|
2533
|
+
if (gate.eluThreshold != null) {
|
|
2534
|
+
gate.wanted = gate.wanted ? elu >= gate.eluThreshold - kProfilingELUHysteresis : elu > gate.eluThreshold
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
if (gate.maxELU != null) {
|
|
2538
|
+
gate.overloaded = gate.overloaded ? elu >= gate.maxELU - kProfilingELUHysteresis : elu > gate.maxELU
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
const shouldRun = gate.wanted && !gate.overloaded
|
|
2542
|
+
|
|
2543
|
+
if (shouldRun === gate.running) {
|
|
2544
|
+
continue
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
gate.running = shouldRun
|
|
2548
|
+
|
|
2549
|
+
try {
|
|
2550
|
+
if (shouldRun) {
|
|
2551
|
+
worker[kITC].notify('resumeProfiling', { type })
|
|
2552
|
+
} else {
|
|
2553
|
+
// The reason matters to the worker: when pausing for overload it
|
|
2554
|
+
// keeps the final profile available for the whole pause, so that
|
|
2555
|
+
// consumers can still retrieve the evidence of what saturated the
|
|
2556
|
+
// worker.
|
|
2557
|
+
worker[kITC].notify('pauseProfiling', { type, reason: gate.overloaded ? 'overload' : 'threshold' })
|
|
2558
|
+
}
|
|
2559
|
+
} catch (err) {
|
|
2560
|
+
this.logger.error({ err }, 'Failed to toggle the continuous profiler')
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2243
2565
|
#setupHealthCheck (config, applicationConfig, workersCount, id, index, worker, errorLabel) {
|
|
2244
2566
|
let healthMetricsListener = null
|
|
2245
2567
|
|
|
@@ -2253,12 +2575,19 @@ export class Runtime extends EventEmitter {
|
|
|
2253
2575
|
|
|
2254
2576
|
const healthConfig = worker[kConfig].health
|
|
2255
2577
|
|
|
2256
|
-
let { maxELU, maxHeapUsed, maxHeapTotal, maxUnhealthyChecks, interval } =
|
|
2578
|
+
let { maxELU, maxHeapUsed, maxHeapTotal, maxUnhealthyChecks, interval, maxEventLoopDelay, maxEventLoopDelayP99 } =
|
|
2579
|
+
worker[kConfig].health
|
|
2257
2580
|
|
|
2258
2581
|
if (typeof maxHeapTotal === 'string') {
|
|
2259
2582
|
maxHeapTotal = parseMemorySize(maxHeapTotal)
|
|
2260
2583
|
}
|
|
2261
2584
|
|
|
2585
|
+
maxEventLoopDelay = Number(maxEventLoopDelay)
|
|
2586
|
+
maxEventLoopDelayP99 = Number(maxEventLoopDelayP99)
|
|
2587
|
+
const eventLoopDelayEnabled = Number.isFinite(maxEventLoopDelay) && maxEventLoopDelay > 0
|
|
2588
|
+
const eventLoopDelayP99Enabled = Number.isFinite(maxEventLoopDelayP99) && maxEventLoopDelayP99 > 0
|
|
2589
|
+
const eventLoopDelayMonitored = eventLoopDelayEnabled || eventLoopDelayP99Enabled
|
|
2590
|
+
|
|
2262
2591
|
if (interval < 1000) {
|
|
2263
2592
|
interval = 1000
|
|
2264
2593
|
this.logger.warn(
|
|
@@ -2269,9 +2598,32 @@ export class Runtime extends EventEmitter {
|
|
|
2269
2598
|
|
|
2270
2599
|
let lastHealthMetrics = null
|
|
2271
2600
|
|
|
2601
|
+
// Health metrics arrive every second while the check runs every
|
|
2602
|
+
// `interval`: track the maximum event loop delay (and the worst reported
|
|
2603
|
+
// per-second p99) across the whole check window, so that stalls between
|
|
2604
|
+
// checks are not missed.
|
|
2605
|
+
let maxObservedEventLoopDelay = 0
|
|
2606
|
+
let maxObservedEventLoopDelayP99 = 0
|
|
2607
|
+
|
|
2272
2608
|
healthMetricsListener = healthCheck => {
|
|
2273
2609
|
if (healthCheck.id === worker[kId]) {
|
|
2274
2610
|
lastHealthMetrics = healthCheck
|
|
2611
|
+
|
|
2612
|
+
if (eventLoopDelayMonitored) {
|
|
2613
|
+
for (const signal of healthCheck.healthSignals) {
|
|
2614
|
+
if (signal.type !== 'eventLoopDelay') {
|
|
2615
|
+
continue
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
if (signal.max > maxObservedEventLoopDelay) {
|
|
2619
|
+
maxObservedEventLoopDelay = signal.max
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
if (signal.p99 > maxObservedEventLoopDelayP99) {
|
|
2623
|
+
maxObservedEventLoopDelayP99 = signal.p99
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2275
2627
|
}
|
|
2276
2628
|
}
|
|
2277
2629
|
|
|
@@ -2334,13 +2686,22 @@ export class Runtime extends EventEmitter {
|
|
|
2334
2686
|
}
|
|
2335
2687
|
|
|
2336
2688
|
const memoryUsage = health.heapUsed != null ? health.heapUsed / maxHeapTotal : 0
|
|
2337
|
-
const
|
|
2689
|
+
const eventLoopDelay = maxObservedEventLoopDelay
|
|
2690
|
+
const eventLoopDelayP99 = maxObservedEventLoopDelayP99
|
|
2691
|
+
maxObservedEventLoopDelay = 0
|
|
2692
|
+
maxObservedEventLoopDelayP99 = 0
|
|
2693
|
+
const eventLoopDelayExceeded = eventLoopDelayEnabled && eventLoopDelay > maxEventLoopDelay
|
|
2694
|
+
const eventLoopDelayP99Exceeded = eventLoopDelayP99Enabled && eventLoopDelayP99 > maxEventLoopDelayP99
|
|
2695
|
+
const unhealthy =
|
|
2696
|
+
health.elu > maxELU || memoryUsage > maxHeapUsed || eventLoopDelayExceeded || eventLoopDelayP99Exceeded
|
|
2338
2697
|
|
|
2339
2698
|
this.emit('application:worker:health', {
|
|
2340
2699
|
id: worker[kId],
|
|
2341
2700
|
application: id,
|
|
2342
2701
|
worker: index,
|
|
2343
2702
|
currentHealth: health,
|
|
2703
|
+
eventLoopDelay: eventLoopDelayMonitored ? eventLoopDelay : undefined,
|
|
2704
|
+
eventLoopDelayP99: eventLoopDelayMonitored ? eventLoopDelayP99 : undefined,
|
|
2344
2705
|
unhealthy,
|
|
2345
2706
|
healthConfig
|
|
2346
2707
|
})
|
|
@@ -2359,6 +2720,20 @@ export class Runtime extends EventEmitter {
|
|
|
2359
2720
|
)
|
|
2360
2721
|
}
|
|
2361
2722
|
|
|
2723
|
+
if (eventLoopDelayExceeded) {
|
|
2724
|
+
this.logger.error(
|
|
2725
|
+
`The ${errorLabel} had a maximum event loop delay of ${eventLoopDelay.toFixed(2)} ms, ` +
|
|
2726
|
+
`above the maximum allowed delay of ${maxEventLoopDelay} ms.`
|
|
2727
|
+
)
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
if (eventLoopDelayP99Exceeded) {
|
|
2731
|
+
this.logger.error(
|
|
2732
|
+
`The ${errorLabel} had a p99 event loop delay of ${eventLoopDelayP99.toFixed(2)} ms, ` +
|
|
2733
|
+
`above the maximum allowed p99 delay of ${maxEventLoopDelayP99} ms.`
|
|
2734
|
+
)
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2362
2737
|
if (unhealthy) {
|
|
2363
2738
|
unhealthyChecks++
|
|
2364
2739
|
} else {
|
|
@@ -2370,7 +2745,16 @@ export class Runtime extends EventEmitter {
|
|
|
2370
2745
|
this.emitAndNotify('application:worker:unhealthy', { application: id, worker: index })
|
|
2371
2746
|
|
|
2372
2747
|
this.logger.error(
|
|
2373
|
-
{
|
|
2748
|
+
{
|
|
2749
|
+
elu: health.elu,
|
|
2750
|
+
maxELU,
|
|
2751
|
+
memoryUsage: health.heapUsed,
|
|
2752
|
+
maxMemoryUsage: maxHeapUsed,
|
|
2753
|
+
eventLoopDelay,
|
|
2754
|
+
maxEventLoopDelay,
|
|
2755
|
+
eventLoopDelayP99,
|
|
2756
|
+
maxEventLoopDelayP99
|
|
2757
|
+
},
|
|
2374
2758
|
`The ${errorLabel} is unhealthy. Replacing it ...`
|
|
2375
2759
|
)
|
|
2376
2760
|
|
|
@@ -2473,7 +2857,7 @@ export class Runtime extends EventEmitter {
|
|
|
2473
2857
|
}
|
|
2474
2858
|
|
|
2475
2859
|
const { enabled, gracePeriod } = worker[kConfig].health
|
|
2476
|
-
if (enabled && config
|
|
2860
|
+
if (enabled && this.#getApplicationRestartOnError(config, applicationConfig) > 0) {
|
|
2477
2861
|
// if gracePeriod is 0, it will be set to 1 to start health checks immediately
|
|
2478
2862
|
// however, the health event will start when the worker is started
|
|
2479
2863
|
setTimeout(
|
|
@@ -2486,6 +2870,7 @@ export class Runtime extends EventEmitter {
|
|
|
2486
2870
|
} catch (err) {
|
|
2487
2871
|
const error = ensureError(err)
|
|
2488
2872
|
worker[kITC].notify('application:worker:start:processed')
|
|
2873
|
+
const portOffset = this.#workerPortOffsets.get(worker[kFullId]) ?? index
|
|
2489
2874
|
|
|
2490
2875
|
// TODO: handle port allocation error here
|
|
2491
2876
|
if (error.code === 'EADDRINUSE' || error.code === 'EACCES') throw error
|
|
@@ -2510,7 +2895,7 @@ export class Runtime extends EventEmitter {
|
|
|
2510
2895
|
this.logger.error({ err: ensureLoggableError(error) }, `Failed to start ${label}: ${error.message}`)
|
|
2511
2896
|
}
|
|
2512
2897
|
|
|
2513
|
-
const restartOnError = config
|
|
2898
|
+
const restartOnError = this.#getApplicationRestartOnError(config, applicationConfig)
|
|
2514
2899
|
|
|
2515
2900
|
if (disableRestartAttempts || !restartOnError) {
|
|
2516
2901
|
throw error
|
|
@@ -2532,7 +2917,7 @@ export class Runtime extends EventEmitter {
|
|
|
2532
2917
|
)
|
|
2533
2918
|
}
|
|
2534
2919
|
|
|
2535
|
-
await this.#restartCrashedWorker(config, applicationConfig, workersCount, id, index, silent, bootstrapAttempt)
|
|
2920
|
+
await this.#restartCrashedWorker(config, applicationConfig, workersCount, id, index, silent, bootstrapAttempt, portOffset)
|
|
2536
2921
|
}
|
|
2537
2922
|
}
|
|
2538
2923
|
|
|
@@ -2601,6 +2986,7 @@ export class Runtime extends EventEmitter {
|
|
|
2601
2986
|
|
|
2602
2987
|
#cleanupWorker (worker) {
|
|
2603
2988
|
clearTimeout(worker[kHealthCheckTimer])
|
|
2989
|
+
this.#workerPortOffsets.delete(worker[kFullId])
|
|
2604
2990
|
|
|
2605
2991
|
const currentWorker = this.#workers.get(worker[kFullId])
|
|
2606
2992
|
|
|
@@ -2629,7 +3015,25 @@ export class Runtime extends EventEmitter {
|
|
|
2629
3015
|
return index
|
|
2630
3016
|
}
|
|
2631
3017
|
|
|
2632
|
-
|
|
3018
|
+
// Returns the effective restartOnError value for an application: the application-level
|
|
3019
|
+
// value, when defined, takes precedence over the runtime-level one.
|
|
3020
|
+
// The value is normalized to a number: false becomes 0 (never restart),
|
|
3021
|
+
// true becomes the default delay.
|
|
3022
|
+
#getApplicationRestartOnError (config, applicationConfig) {
|
|
3023
|
+
let restartOnError = applicationConfig?.restartOnError ?? config.restartOnError
|
|
3024
|
+
|
|
3025
|
+
if (restartOnError === true) {
|
|
3026
|
+
restartOnError = DEFAULT_RESTART_ON_ERROR_DELAY
|
|
3027
|
+
} else if (restartOnError === false || restartOnError < 0) {
|
|
3028
|
+
restartOnError = 0
|
|
3029
|
+
}
|
|
3030
|
+
|
|
3031
|
+
return restartOnError
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
async #restartCrashedWorker (config, applicationConfig, workersCount, id, oldIndex, silent, bootstrapAttempt, portOffset) {
|
|
3035
|
+
const restartOnError = this.#getApplicationRestartOnError(config, applicationConfig)
|
|
3036
|
+
|
|
2633
3037
|
// Use oldIndex for tracking to prevent duplicate restarts of the same crashed worker
|
|
2634
3038
|
const restartKey = `${id}:${oldIndex}`
|
|
2635
3039
|
|
|
@@ -2650,18 +3054,22 @@ export class Runtime extends EventEmitter {
|
|
|
2650
3054
|
}
|
|
2651
3055
|
|
|
2652
3056
|
// Get a new unique index for the restarted worker
|
|
2653
|
-
const
|
|
3057
|
+
const newIndex = this.#getNextWorkerIndex(id)
|
|
3058
|
+
const newWorkerId = `${id}:${newIndex}`
|
|
3059
|
+
this.#workerPortOffsets.set(newWorkerId, portOffset)
|
|
2654
3060
|
|
|
2655
3061
|
try {
|
|
2656
|
-
await this.#setupWorker(config, applicationConfig, workersCount, id,
|
|
2657
|
-
await this.#startWorker(config, applicationConfig, workersCount, id,
|
|
3062
|
+
await this.#setupWorker(config, applicationConfig, workersCount, id, newIndex)
|
|
3063
|
+
await this.#startWorker(config, applicationConfig, workersCount, id, newIndex, silent, bootstrapAttempt)
|
|
2658
3064
|
this.#incrementApplicationRestartCount(id)
|
|
2659
3065
|
|
|
2660
3066
|
this.logger.info(
|
|
2661
|
-
`The ${this.#workerExtendedLabel(id,
|
|
3067
|
+
`The ${this.#workerExtendedLabel(id, newIndex, workersCount)} has been successfully restarted ...`
|
|
2662
3068
|
)
|
|
2663
3069
|
resolve()
|
|
2664
3070
|
} catch (err) {
|
|
3071
|
+
this.#workerPortOffsets.delete(newWorkerId)
|
|
3072
|
+
|
|
2665
3073
|
// The runtime was stopped while the restart was happening, ignore any error.
|
|
2666
3074
|
if (!this.#status.startsWith('start')) {
|
|
2667
3075
|
resolve()
|
|
@@ -2671,10 +3079,10 @@ export class Runtime extends EventEmitter {
|
|
|
2671
3079
|
}
|
|
2672
3080
|
}
|
|
2673
3081
|
|
|
2674
|
-
if (
|
|
3082
|
+
if (restartOnError < IMMEDIATE_RESTART_MAX_THRESHOLD) {
|
|
2675
3083
|
process.nextTick(restart.bind(this))
|
|
2676
3084
|
} else {
|
|
2677
|
-
setTimeout(restart.bind(this),
|
|
3085
|
+
setTimeout(restart.bind(this), restartOnError)
|
|
2678
3086
|
}
|
|
2679
3087
|
})
|
|
2680
3088
|
|
|
@@ -2690,6 +3098,8 @@ export class Runtime extends EventEmitter {
|
|
|
2690
3098
|
const newIndex = this.#getNextWorkerIndex(applicationId)
|
|
2691
3099
|
const newWorkerId = `${applicationId}:${newIndex}`
|
|
2692
3100
|
const newLabel = this.#workerExtendedLabel(applicationId, newIndex, workersCount)
|
|
3101
|
+
const portOffset = this.#workerPortOffsets.get(`${applicationId}:${oldIndex}`)
|
|
3102
|
+
this.#workerPortOffsets.set(newWorkerId, portOffset)
|
|
2693
3103
|
|
|
2694
3104
|
const stopBeforeStart =
|
|
2695
3105
|
applicationConfig.entrypoint &&
|
|
@@ -2705,15 +3115,15 @@ export class Runtime extends EventEmitter {
|
|
|
2705
3115
|
configForNewWorker = { ...config, server: { ...config.server, port: this.#entrypointPort } }
|
|
2706
3116
|
}
|
|
2707
3117
|
|
|
2708
|
-
if (stopBeforeStart) {
|
|
2709
|
-
await this.#removeWorker(workersCount, applicationId, oldIndex, worker, silent, oldLabel)
|
|
2710
|
-
}
|
|
2711
|
-
|
|
2712
3118
|
try {
|
|
2713
3119
|
if (!silent) {
|
|
2714
3120
|
this.logger.debug(`Preparing to start ${newLabel} as replacement for ${oldLabel} ...`)
|
|
2715
3121
|
}
|
|
2716
3122
|
|
|
3123
|
+
if (stopBeforeStart) {
|
|
3124
|
+
await this.#removeWorker(workersCount, applicationId, oldIndex, worker, silent, oldLabel)
|
|
3125
|
+
}
|
|
3126
|
+
|
|
2717
3127
|
// Create a new worker with a new index, preserving any pinned port when stopBeforeStart is required.
|
|
2718
3128
|
newWorker = await this.#setupWorker(configForNewWorker, applicationConfig, workersCount, applicationId, newIndex, false)
|
|
2719
3129
|
|
|
@@ -2722,6 +3132,11 @@ export class Runtime extends EventEmitter {
|
|
|
2722
3132
|
return this.#discardWorker(newWorker)
|
|
2723
3133
|
}
|
|
2724
3134
|
|
|
3135
|
+
// Register the worker before starting it, like in the regular startup flow,
|
|
3136
|
+
// so that it is addressable when the application:worker:started event is emitted.
|
|
3137
|
+
// The discard paths below remove it from the map via #cleanupWorker.
|
|
3138
|
+
this.#workers.set(newWorkerId, newWorker)
|
|
3139
|
+
|
|
2725
3140
|
// Add the worker to the mesh
|
|
2726
3141
|
await this.#startWorker(configForNewWorker, applicationConfig, workersCount, applicationId, newIndex, false, 0, newWorker, true)
|
|
2727
3142
|
|
|
@@ -2729,9 +3144,13 @@ export class Runtime extends EventEmitter {
|
|
|
2729
3144
|
if (this.#status !== 'started') {
|
|
2730
3145
|
return this.#discardWorker(newWorker)
|
|
2731
3146
|
}
|
|
2732
|
-
|
|
2733
|
-
this.#workers.set(newWorkerId, newWorker)
|
|
2734
3147
|
} catch (e) {
|
|
3148
|
+
this.#workerPortOffsets.delete(newWorkerId)
|
|
3149
|
+
|
|
3150
|
+
if (this.#workers.get(newWorkerId) === newWorker) {
|
|
3151
|
+
this.#workers.delete(newWorkerId)
|
|
3152
|
+
}
|
|
3153
|
+
|
|
2735
3154
|
newWorker?.terminate?.()
|
|
2736
3155
|
throw e
|
|
2737
3156
|
}
|
|
@@ -3286,16 +3705,25 @@ export class Runtime extends EventEmitter {
|
|
|
3286
3705
|
|
|
3287
3706
|
if (currentWorkers < workers) {
|
|
3288
3707
|
report.started = []
|
|
3708
|
+
let pendingWorkerId
|
|
3709
|
+
|
|
3289
3710
|
try {
|
|
3290
3711
|
for (let i = currentWorkers; i < workers; i++) {
|
|
3291
3712
|
const newIndex = this.#getNextWorkerIndex(applicationId)
|
|
3713
|
+
pendingWorkerId = `${applicationId}:${newIndex}`
|
|
3714
|
+
this.#workerPortOffsets.set(pendingWorkerId, i)
|
|
3715
|
+
|
|
3292
3716
|
await this.#setupWorker(config, applicationConfig, workers, applicationId, newIndex)
|
|
3293
3717
|
await this.#startWorker(config, applicationConfig, workers, applicationId, newIndex, false, 0)
|
|
3718
|
+
|
|
3719
|
+
pendingWorkerId = undefined
|
|
3294
3720
|
report.started.push(newIndex)
|
|
3295
3721
|
startedWorkersCount++
|
|
3296
3722
|
}
|
|
3297
3723
|
report.success = true
|
|
3298
3724
|
} catch (err) {
|
|
3725
|
+
pendingWorkerId && this.#workerPortOffsets.delete(pendingWorkerId)
|
|
3726
|
+
|
|
3299
3727
|
if (startedWorkersCount < 1) {
|
|
3300
3728
|
this.logger.error({ err }, 'Cannot start application workers, no worker started')
|
|
3301
3729
|
} else {
|
|
@@ -3310,21 +3738,32 @@ export class Runtime extends EventEmitter {
|
|
|
3310
3738
|
// keep the current workers count until all the application workers are all stopped
|
|
3311
3739
|
report.stopped = []
|
|
3312
3740
|
try {
|
|
3313
|
-
|
|
3314
|
-
const
|
|
3315
|
-
const
|
|
3741
|
+
const workersToStop = currentWorkers - workers
|
|
3742
|
+
const allInOnePort = this.#config.server?.portAssignment !== 'perWorkerIncrement'
|
|
3743
|
+
const workerIdsToStop = this.#workers
|
|
3744
|
+
.getKeys(applicationId)
|
|
3316
3745
|
.map(key => parseInt(key.split(':')[1], 10))
|
|
3317
|
-
.sort((a, b) => b - a) // descending order
|
|
3318
3746
|
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3747
|
+
if (allInOnePort) {
|
|
3748
|
+
// Stop most recent workers first.
|
|
3749
|
+
workerIdsToStop.sort((a, b) => b - a)
|
|
3750
|
+
} else {
|
|
3751
|
+
// When port increment is enabled, disable the workers with the highest ports.
|
|
3752
|
+
workerIdsToStop.sort((a, b) => {
|
|
3753
|
+
const offsetA = this.#workerPortOffsets.get(`${applicationId}:${a}`) ?? a
|
|
3754
|
+
const offsetB = this.#workerPortOffsets.get(`${applicationId}:${b}`) ?? b
|
|
3755
|
+
return offsetB - offsetA
|
|
3756
|
+
})
|
|
3757
|
+
}
|
|
3758
|
+
|
|
3759
|
+
for (const workerIndex of workerIdsToStop.splice(0, workersToStop)) {
|
|
3322
3760
|
const worker = this.#workers.get(`${applicationId}:${workerIndex}`)
|
|
3323
3761
|
await sendViaITC(worker, 'removeFromMesh')
|
|
3324
3762
|
await this.#stopWorker(currentWorkers, applicationId, workerIndex, false, worker, [])
|
|
3325
3763
|
report.stopped.push(workerIndex)
|
|
3326
3764
|
stoppedWorkersCount++
|
|
3327
3765
|
}
|
|
3766
|
+
|
|
3328
3767
|
report.success = true
|
|
3329
3768
|
} catch (err) {
|
|
3330
3769
|
if (stoppedWorkersCount < 1) {
|
|
@@ -3419,6 +3858,115 @@ export class Runtime extends EventEmitter {
|
|
|
3419
3858
|
worker[kWorkerHealthSignals].add(signals)
|
|
3420
3859
|
}
|
|
3421
3860
|
|
|
3861
|
+
async #loadExtensions () {
|
|
3862
|
+
let extensions = this.#config.extensions
|
|
3863
|
+
|
|
3864
|
+
// Do not load extensions when building applications
|
|
3865
|
+
if (!extensions || this.#context.build) {
|
|
3866
|
+
return
|
|
3867
|
+
}
|
|
3868
|
+
|
|
3869
|
+
if (!Array.isArray(extensions)) {
|
|
3870
|
+
extensions = [extensions]
|
|
3871
|
+
}
|
|
3872
|
+
|
|
3873
|
+
for (const extension of extensions) {
|
|
3874
|
+
const { path, options } = typeof extension === 'string' ? { path: extension } : extension
|
|
3875
|
+
|
|
3876
|
+
let setup
|
|
3877
|
+
try {
|
|
3878
|
+
setup = (await import(pathToFileURL(path))).default
|
|
3879
|
+
} catch (e) {
|
|
3880
|
+
throw new FailedToLoadExtensionError(path, e.message, { cause: e })
|
|
3881
|
+
}
|
|
3882
|
+
|
|
3883
|
+
if (typeof setup !== 'function') {
|
|
3884
|
+
throw new InvalidExtensionError(path)
|
|
3885
|
+
}
|
|
3886
|
+
|
|
3887
|
+
const logger = this.logger.child({ name: `extension:${basename(path)}` })
|
|
3888
|
+
|
|
3889
|
+
try {
|
|
3890
|
+
const instance = await setup({
|
|
3891
|
+
runtime: this,
|
|
3892
|
+
itc: this.#createExtensionITC(),
|
|
3893
|
+
logger,
|
|
3894
|
+
options: options ?? {},
|
|
3895
|
+
root: this.#root
|
|
3896
|
+
})
|
|
3897
|
+
|
|
3898
|
+
this.#extensions.push({ path, instance })
|
|
3899
|
+
} catch (e) {
|
|
3900
|
+
throw new FailedToLoadExtensionError(path, e.message, { cause: e })
|
|
3901
|
+
}
|
|
3902
|
+
}
|
|
3903
|
+
|
|
3904
|
+
// If any extension subscribed to health metrics during its setup, make sure
|
|
3905
|
+
// the health metrics collection is started even if no health check or
|
|
3906
|
+
// dynamic workers scaler is enabled. Note that this is evaluated here on
|
|
3907
|
+
// purpose, before any worker health check listener is registered.
|
|
3908
|
+
this.#extensionsWantHealthMetrics = this.listenerCount('application:worker:health:metrics') > 0
|
|
3909
|
+
}
|
|
3910
|
+
|
|
3911
|
+
async #closeExtensions () {
|
|
3912
|
+
// Close in reverse order, so that extensions loaded later, which might depend
|
|
3913
|
+
// on earlier ones, are closed first.
|
|
3914
|
+
const extensions = this.#extensions.splice(0).reverse()
|
|
3915
|
+
|
|
3916
|
+
for (const { path, instance } of extensions) {
|
|
3917
|
+
try {
|
|
3918
|
+
await instance?.close?.()
|
|
3919
|
+
} catch (e) {
|
|
3920
|
+
this.logger.error({ err: ensureLoggableError(e) }, `Failed to close the extension "${path}".`)
|
|
3921
|
+
}
|
|
3922
|
+
}
|
|
3923
|
+
}
|
|
3924
|
+
|
|
3925
|
+
#createExtensionITC () {
|
|
3926
|
+
return {
|
|
3927
|
+
handle: (name, handler) => {
|
|
3928
|
+
if (this.#reservedITCHandlerNames.has(name)) {
|
|
3929
|
+
throw new ReservedITCHandlerNameError(name)
|
|
3930
|
+
}
|
|
3931
|
+
|
|
3932
|
+
if (name in this.#workerITCHandlers) {
|
|
3933
|
+
throw new DuplicateITCHandlerNameError(name)
|
|
3934
|
+
}
|
|
3935
|
+
|
|
3936
|
+
this.#workerITCHandlers[name] = handler
|
|
3937
|
+
|
|
3938
|
+
// Workers copy the handlers when their ITC is created, so also register
|
|
3939
|
+
// the handler on all running workers.
|
|
3940
|
+
for (const worker of this.#workers.values()) {
|
|
3941
|
+
worker[kITC]?.handle(name, handler)
|
|
3942
|
+
}
|
|
3943
|
+
},
|
|
3944
|
+
send: async (target, name, payload) => {
|
|
3945
|
+
const worker = await this.#getApplicationById(target)
|
|
3946
|
+
return sendViaITC(worker, name, payload)
|
|
3947
|
+
},
|
|
3948
|
+
notify: async (target, name, payload) => {
|
|
3949
|
+
const matched = target.match(/^(.+):(\d+)$/)
|
|
3950
|
+
|
|
3951
|
+
if (matched) {
|
|
3952
|
+
const worker = await this.#getWorkerByIdOrNext(matched[1], matched[2])
|
|
3953
|
+
worker[kITC].notify(name, payload)
|
|
3954
|
+
return
|
|
3955
|
+
}
|
|
3956
|
+
|
|
3957
|
+
if (!this.#applications.has(target)) {
|
|
3958
|
+
throw new ApplicationNotFoundError(target, this.getApplicationsIds().join(', '))
|
|
3959
|
+
}
|
|
3960
|
+
|
|
3961
|
+
for (const worker of this.#workers.values()) {
|
|
3962
|
+
if (worker[kApplicationId] === target && worker[kWorkerStatus] === 'started') {
|
|
3963
|
+
worker[kITC].notify(name, payload)
|
|
3964
|
+
}
|
|
3965
|
+
}
|
|
3966
|
+
}
|
|
3967
|
+
}
|
|
3968
|
+
}
|
|
3969
|
+
|
|
3422
3970
|
#updateLoggingPrefixes () {
|
|
3423
3971
|
if (!this.#loggerContext) {
|
|
3424
3972
|
return
|