@platformatic/runtime 3.61.1 → 3.62.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 +25 -0
- package/index.d.ts +32 -1
- package/lib/errors.js +25 -0
- package/lib/runtime.js +584 -85
- package/lib/worker/health-signals.js +34 -0
- package/lib/worker/main.js +18 -2
- package/package.json +18 -18
- package/schema.json +212 -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,6 +186,10 @@ 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
|
|
@@ -222,6 +261,12 @@ export class Runtime extends EventEmitter {
|
|
|
222
261
|
getSharedContext: this.getSharedContext.bind(this),
|
|
223
262
|
sendHealthSignals: this.#processHealthSignals.bind(this)
|
|
224
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()
|
|
225
270
|
this.#sharedContext = {}
|
|
226
271
|
|
|
227
272
|
if (this.#isProduction) {
|
|
@@ -286,6 +331,10 @@ export class Runtime extends EventEmitter {
|
|
|
286
331
|
}
|
|
287
332
|
}
|
|
288
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
|
+
|
|
289
338
|
await this.addApplications(this.#config.applications)
|
|
290
339
|
await this.#setDispatcher(config.undici)
|
|
291
340
|
|
|
@@ -412,10 +461,14 @@ export class Runtime extends EventEmitter {
|
|
|
412
461
|
|
|
413
462
|
async close (silent = false) {
|
|
414
463
|
clearTimeout(this.#healthMetricsTimer)
|
|
464
|
+
this.#healthMetricsCollectionActive = false
|
|
465
|
+
this.#lastOverloadProfiles.clear()
|
|
415
466
|
|
|
416
467
|
await this.stop(silent)
|
|
417
468
|
this.#updateStatus('closing')
|
|
418
469
|
|
|
470
|
+
await this.#closeExtensions()
|
|
471
|
+
|
|
419
472
|
// The management API autocloses by itself via event in management-api.js.
|
|
420
473
|
// This is needed to let management API stop endpoint to reply.
|
|
421
474
|
|
|
@@ -598,7 +651,13 @@ export class Runtime extends EventEmitter {
|
|
|
598
651
|
|
|
599
652
|
const removed = []
|
|
600
653
|
for (const application of applications) {
|
|
601
|
-
|
|
654
|
+
if (!this.#applications.has(application)) {
|
|
655
|
+
throw new ApplicationNotFoundError(application, this.getApplicationsIds().join(', '))
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Use allowUnloaded so that applications without a live worker
|
|
659
|
+
// (stopped or crashed with restartOnError: 0) can still be removed.
|
|
660
|
+
const details = await this.getApplicationDetails(application, true)
|
|
602
661
|
details.status = 'removed'
|
|
603
662
|
removed.push(details)
|
|
604
663
|
}
|
|
@@ -818,6 +877,91 @@ export class Runtime extends EventEmitter {
|
|
|
818
877
|
return sendViaITC(service, 'stopProfiling', options)
|
|
819
878
|
}
|
|
820
879
|
|
|
880
|
+
async getApplicationLastProfile (id, options = {}, ensureStarted = true) {
|
|
881
|
+
this.#validatePprofCapturePreload()
|
|
882
|
+
|
|
883
|
+
const type = options.type ?? 'cpu'
|
|
884
|
+
const timeout = options.timeout ?? kLastProfileTimeoutMs
|
|
885
|
+
let error
|
|
886
|
+
|
|
887
|
+
try {
|
|
888
|
+
// Bound the whole retrieval with a single timeout budget: resolving the
|
|
889
|
+
// worker round-trips to it when ensureStarted is set, and the profile
|
|
890
|
+
// pull does too — both hang if the worker event loop is blocked. Attach
|
|
891
|
+
// a noop handler so that a late settlement after the timeout does not
|
|
892
|
+
// surface as an unhandled rejection.
|
|
893
|
+
const pull = (async () => {
|
|
894
|
+
const service = await this.#getApplicationById(id, ensureStarted)
|
|
895
|
+
const result = await sendViaITC(service, 'getLastProfile', { ...options, includeTimestamp: true })
|
|
896
|
+
return { service, result }
|
|
897
|
+
})()
|
|
898
|
+
pull.catch(() => {})
|
|
899
|
+
|
|
900
|
+
const outcome = await executeWithTimeout(pull, timeout, kTimeout)
|
|
901
|
+
|
|
902
|
+
if (outcome !== kTimeout) {
|
|
903
|
+
const { service, result } = outcome
|
|
904
|
+
|
|
905
|
+
// An older capture module which does not support includeTimestamp
|
|
906
|
+
// returns the raw profile.
|
|
907
|
+
const value = result instanceof Uint8Array ? { profile: result, timestamp: null } : result
|
|
908
|
+
|
|
909
|
+
// A strictly newer live window supersedes the preserved overload
|
|
910
|
+
// profile: prune it so the preserved copy naturally expires once the
|
|
911
|
+
// worker is healthy again and its profiles are being consumed.
|
|
912
|
+
if (value.timestamp != null) {
|
|
913
|
+
const key = `${service[kApplicationId]}:${service[kWorkerId]}:${type}`
|
|
914
|
+
const preserved = this.#lastOverloadProfiles.get(key)
|
|
915
|
+
|
|
916
|
+
if (preserved && preserved.timestamp < value.timestamp) {
|
|
917
|
+
this.#lastOverloadProfiles.delete(key)
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
return { ...value, preserved: false }
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// The worker event loop is not responding (e.g. it is hard-blocked).
|
|
925
|
+
error = new LastProfileTimeoutError(id)
|
|
926
|
+
} catch (e) {
|
|
927
|
+
if (!kLastProfileFallbackCodes.has(e.code)) {
|
|
928
|
+
throw e
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
error = e
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const preserved = this.#getPreservedOverloadProfile(id, type)
|
|
935
|
+
|
|
936
|
+
if (preserved) {
|
|
937
|
+
// The preserved flag lets consumers distinguish post-mortem evidence
|
|
938
|
+
// from a live window and judge it together with the timestamp.
|
|
939
|
+
return { profile: preserved.profile, timestamp: preserved.timestamp, preserved: true }
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
throw error
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// The final profile of an overload pause is pushed by the worker and
|
|
946
|
+
// preserved in the main thread (see the profile:overload listener), so that
|
|
947
|
+
// the evidence of what saturated a worker survives the worker being blocked
|
|
948
|
+
// or replaced.
|
|
949
|
+
#getPreservedOverloadProfile (id, type) {
|
|
950
|
+
if (id.includes(':')) {
|
|
951
|
+
return this.#lastOverloadProfiles.get(`${id}:${type}`)
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Application-level id: return the most recent profile among its workers
|
|
955
|
+
let latest = null
|
|
956
|
+
for (const [key, entry] of this.#lastOverloadProfiles) {
|
|
957
|
+
if (key.startsWith(`${id}:`) && key.endsWith(`:${type}`) && (!latest || entry.timestamp > latest.timestamp)) {
|
|
958
|
+
latest = entry
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
return latest
|
|
963
|
+
}
|
|
964
|
+
|
|
821
965
|
async takeApplicationHeapSnapshot (id, ensureStarted = true) {
|
|
822
966
|
const service = await this.#getApplicationById(id, ensureStarted)
|
|
823
967
|
|
|
@@ -1251,9 +1395,7 @@ export class Runtime extends EventEmitter {
|
|
|
1251
1395
|
|
|
1252
1396
|
let metrics = null
|
|
1253
1397
|
|
|
1254
|
-
const applicationRestartMetrics =
|
|
1255
|
-
? this.#getApplicationRestartMetricsJson()
|
|
1256
|
-
: this.#formatApplicationRestartMetricsText()
|
|
1398
|
+
const applicationRestartMetrics = this.#getApplicationRestartMetricsJson()
|
|
1257
1399
|
|
|
1258
1400
|
// Get process-level metrics once from main thread registry (if available)
|
|
1259
1401
|
let processMetricsJson = null
|
|
@@ -1268,16 +1410,16 @@ export class Runtime extends EventEmitter {
|
|
|
1268
1410
|
continue
|
|
1269
1411
|
}
|
|
1270
1412
|
|
|
1271
|
-
// Get thread-specific metrics from worker
|
|
1413
|
+
// Get thread-specific metrics from worker. Always collect JSON so that
|
|
1414
|
+
// the text format can be serialized once with a single HELP/TYPE block
|
|
1415
|
+
// per metric family, as required by the Prometheus exposition format.
|
|
1272
1416
|
const applicationMetrics = await executeWithTimeout(
|
|
1273
|
-
sendViaITC(worker, 'getMetrics',
|
|
1417
|
+
sendViaITC(worker, 'getMetrics', 'json'),
|
|
1274
1418
|
this.#config.metrics?.timeout ?? 10000
|
|
1275
1419
|
)
|
|
1276
1420
|
|
|
1277
1421
|
if (applicationMetrics && applicationMetrics !== kTimeout) {
|
|
1278
|
-
|
|
1279
|
-
metrics = format === 'json' ? [] : ''
|
|
1280
|
-
}
|
|
1422
|
+
metrics ??= []
|
|
1281
1423
|
|
|
1282
1424
|
// Build worker labels including custom labels from metrics config
|
|
1283
1425
|
const workerLabels = {
|
|
@@ -1289,21 +1431,13 @@ export class Runtime extends EventEmitter {
|
|
|
1289
1431
|
workerLabels.workerId = workerId
|
|
1290
1432
|
}
|
|
1291
1433
|
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
metrics.push(applicationMetrics[i])
|
|
1300
|
-
}
|
|
1301
|
-
} else {
|
|
1302
|
-
// Text format: format process metrics with worker labels
|
|
1303
|
-
if (processMetricsJson) {
|
|
1304
|
-
metrics += this.#formatProcessMetricsText(processMetricsJson, workerLabels)
|
|
1305
|
-
}
|
|
1306
|
-
metrics += applicationMetrics
|
|
1434
|
+
// Duplicate process metrics with worker labels and add to output
|
|
1435
|
+
if (processMetricsJson) {
|
|
1436
|
+
this.#applyLabelsToMetrics(processMetricsJson, workerLabels, metrics)
|
|
1437
|
+
}
|
|
1438
|
+
// Add worker's thread-specific metrics
|
|
1439
|
+
for (let i = 0; i < applicationMetrics.length; i++) {
|
|
1440
|
+
metrics.push(applicationMetrics[i])
|
|
1307
1441
|
}
|
|
1308
1442
|
}
|
|
1309
1443
|
} catch (e) {
|
|
@@ -1321,9 +1455,11 @@ export class Runtime extends EventEmitter {
|
|
|
1321
1455
|
}
|
|
1322
1456
|
|
|
1323
1457
|
if (metrics !== null && applicationRestartMetrics.length > 0) {
|
|
1324
|
-
metrics =
|
|
1325
|
-
|
|
1326
|
-
|
|
1458
|
+
metrics = [...applicationRestartMetrics, ...metrics]
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
if (metrics !== null && format !== 'json') {
|
|
1462
|
+
metrics = this.#formatMetricsAsText(metrics)
|
|
1327
1463
|
}
|
|
1328
1464
|
|
|
1329
1465
|
return { metrics }
|
|
@@ -1360,28 +1496,6 @@ export class Runtime extends EventEmitter {
|
|
|
1360
1496
|
return metrics
|
|
1361
1497
|
}
|
|
1362
1498
|
|
|
1363
|
-
#formatApplicationRestartMetricsText () {
|
|
1364
|
-
let output = ''
|
|
1365
|
-
|
|
1366
|
-
for (const applicationId of this.#applications.keys()) {
|
|
1367
|
-
const labelParts = []
|
|
1368
|
-
const labels = this.#getApplicationRestartMetricLabels(applicationId)
|
|
1369
|
-
|
|
1370
|
-
for (const [key, val] of Object.entries(labels)) {
|
|
1371
|
-
const escapedVal = String(val).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')
|
|
1372
|
-
labelParts.push(`${key}="${escapedVal}"`)
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
const labelStr = labelParts.length > 0 ? `{${labelParts.join(',')}}` : ''
|
|
1376
|
-
|
|
1377
|
-
output += `# HELP ${kApplicationRestartsMetricName} ${kApplicationRestartsMetricHelp}\n`
|
|
1378
|
-
output += `# TYPE ${kApplicationRestartsMetricName} counter\n`
|
|
1379
|
-
output += `${kApplicationRestartsMetricName}${labelStr} ${this.#applicationRestartCounts.get(applicationId) ?? 0}\n`
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
|
-
return output
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
1499
|
// Apply labels to process metrics and push to output array (for JSON format)
|
|
1386
1500
|
#applyLabelsToMetrics (processMetrics, labels, outputArray) {
|
|
1387
1501
|
for (let i = 0; i < processMetrics.length; i++) {
|
|
@@ -1406,27 +1520,33 @@ export class Runtime extends EventEmitter {
|
|
|
1406
1520
|
}
|
|
1407
1521
|
}
|
|
1408
1522
|
|
|
1409
|
-
//
|
|
1410
|
-
|
|
1411
|
-
|
|
1523
|
+
// Serialize JSON metrics to the Prometheus text exposition format.
|
|
1524
|
+
// Samples are grouped by metric family so each family is emitted as a single
|
|
1525
|
+
// block with one HELP/TYPE header: the format forbids repeating them, and
|
|
1526
|
+
// strict parsers (e.g. OpenMetrics-based ones like Dynatrace) would otherwise
|
|
1527
|
+
// only ingest the first block for each metric name.
|
|
1528
|
+
#formatMetricsAsText (metricsJson) {
|
|
1529
|
+
const families = new Map()
|
|
1412
1530
|
|
|
1413
|
-
for (
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1531
|
+
for (const metric of metricsJson) {
|
|
1532
|
+
let family = families.get(metric.name)
|
|
1533
|
+
if (!family) {
|
|
1534
|
+
family = { help: metric.help, type: metric.type, values: [] }
|
|
1535
|
+
families.set(metric.name, family)
|
|
1536
|
+
}
|
|
1537
|
+
family.values.push(...metric.values)
|
|
1538
|
+
}
|
|
1418
1539
|
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1540
|
+
let output = ''
|
|
1541
|
+
for (const [name, family] of families) {
|
|
1542
|
+
const escapedHelp = String(family.help).replace(/\\/g, '\\\\').replace(/\n/g, '\\n')
|
|
1543
|
+
output += `# HELP ${name} ${escapedHelp}\n`
|
|
1544
|
+
output += `# TYPE ${name} ${family.type}\n`
|
|
1422
1545
|
|
|
1423
|
-
const
|
|
1424
|
-
for (let j = 0; j < values.length; j++) {
|
|
1425
|
-
const v = values[j]
|
|
1426
|
-
const combinedLabels = { ...labels, ...v.labels }
|
|
1546
|
+
for (const v of family.values) {
|
|
1427
1547
|
const labelParts = []
|
|
1428
1548
|
|
|
1429
|
-
for (const [key, val] of Object.entries(
|
|
1549
|
+
for (const [key, val] of Object.entries(v.labels ?? {})) {
|
|
1430
1550
|
// Escape label values for Prometheus format
|
|
1431
1551
|
const escapedVal = String(val).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')
|
|
1432
1552
|
labelParts.push(`${key}="${escapedVal}"`)
|
|
@@ -1434,7 +1554,7 @@ export class Runtime extends EventEmitter {
|
|
|
1434
1554
|
|
|
1435
1555
|
const labelStr = labelParts.length > 0 ? `{${labelParts.join(',')}}` : ''
|
|
1436
1556
|
const metricName = v.metricName || name
|
|
1437
|
-
output += `${metricName}${labelStr} ${v.value}\n`
|
|
1557
|
+
output += `${metricName}${labelStr} ${formatMetricValue(v.value)}\n`
|
|
1438
1558
|
}
|
|
1439
1559
|
}
|
|
1440
1560
|
|
|
@@ -1856,7 +1976,7 @@ export class Runtime extends EventEmitter {
|
|
|
1856
1976
|
}
|
|
1857
1977
|
|
|
1858
1978
|
async #setupWorker (config, applicationConfig, workersCount, applicationId, index, enabled = true, attempt = 0) {
|
|
1859
|
-
const
|
|
1979
|
+
const restartOnError = this.#getApplicationRestartOnError(config, applicationConfig)
|
|
1860
1980
|
const workerId = `${applicationId}:${index}`
|
|
1861
1981
|
|
|
1862
1982
|
// Handle inspector
|
|
@@ -2102,6 +2222,120 @@ export class Runtime extends EventEmitter {
|
|
|
2102
2222
|
this.#opentelemetryMetricsForwarder?.collect(resourceMetrics)
|
|
2103
2223
|
})
|
|
2104
2224
|
|
|
2225
|
+
// The continuous profiler notifies us when a profile window is completed.
|
|
2226
|
+
// The event only carries metadata: the profile can be retrieved on demand
|
|
2227
|
+
// via getApplicationLastProfile. We use emit instead of emitAndNotify since
|
|
2228
|
+
// other workers are not interested in this event.
|
|
2229
|
+
worker[kITC].on('profile:captured', ({ type, timestamp }) => {
|
|
2230
|
+
// A strictly newer completed window supersedes the preserved overload
|
|
2231
|
+
// profile: once the worker is past the overload and producing windows
|
|
2232
|
+
// again, its old evidence must not be served anymore.
|
|
2233
|
+
const preservedKey = `${workerId}:${type}`
|
|
2234
|
+
const preservedEntry = this.#lastOverloadProfiles.get(preservedKey)
|
|
2235
|
+
|
|
2236
|
+
if (preservedEntry && preservedEntry.timestamp < timestamp) {
|
|
2237
|
+
this.#lastOverloadProfiles.delete(preservedKey)
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
this.emit('application:worker:profile:captured', {
|
|
2241
|
+
id: workerId,
|
|
2242
|
+
application: applicationId,
|
|
2243
|
+
worker: index,
|
|
2244
|
+
type,
|
|
2245
|
+
timestamp
|
|
2246
|
+
})
|
|
2247
|
+
})
|
|
2248
|
+
|
|
2249
|
+
// The continuous profiler registers its gating needs when profiling starts.
|
|
2250
|
+
// The main thread drives it based on the ELU measured by the health
|
|
2251
|
+
// metrics cycle (see #applyProfilingELUGates): when an ELU threshold is
|
|
2252
|
+
// set the profiler starts paused and only runs while the ELU is above it,
|
|
2253
|
+
// and continuous profiling is paused while the worker ELU is above the
|
|
2254
|
+
// maxELU cutoff so that profiling does not add overhead to an already
|
|
2255
|
+
// overloaded worker. This is a request handler rather than a notification
|
|
2256
|
+
// listener on purpose: the capture module can detect a runtime without
|
|
2257
|
+
// the driver (PLT_ITC_HANDLER_NOT_FOUND) and fall back to ungated
|
|
2258
|
+
// profiling instead of starting paused forever.
|
|
2259
|
+
worker[kITC].handle('profiling:started', ({ type, eluThreshold, maxELU, continuous }) => {
|
|
2260
|
+
// Resolve the overload cutoff: the maxELU profiling option overrides it
|
|
2261
|
+
// (false disables it), otherwise continuous profiling defaults to the
|
|
2262
|
+
// worker health.maxELU.
|
|
2263
|
+
let overloadELU = null
|
|
2264
|
+
if (typeof maxELU === 'number') {
|
|
2265
|
+
overloadELU = maxELU
|
|
2266
|
+
} else if (maxELU !== false && continuous) {
|
|
2267
|
+
const healthConfig = worker[kConfig]?.health
|
|
2268
|
+
const configMaxELU = Number(healthConfig?.maxELU)
|
|
2269
|
+
|
|
2270
|
+
if (healthConfig?.enabled !== false && Number.isFinite(configMaxELU)) {
|
|
2271
|
+
overloadELU = configMaxELU
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
if (eluThreshold == null && overloadELU == null) {
|
|
2276
|
+
worker[kProfilingELUGates]?.delete(type)
|
|
2277
|
+
return true
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
worker[kProfilingELUGates] ??= new Map()
|
|
2281
|
+
worker[kProfilingELUGates].set(type, {
|
|
2282
|
+
eluThreshold: eluThreshold ?? null,
|
|
2283
|
+
maxELU: overloadELU,
|
|
2284
|
+
// Hysteresis memories: `wanted` tracks the eluThreshold demand,
|
|
2285
|
+
// `overloaded` tracks the maxELU cutoff. `running` is the last state
|
|
2286
|
+
// commanded to the worker: the profiler starts paused when an ELU
|
|
2287
|
+
// threshold is set and running otherwise.
|
|
2288
|
+
wanted: eluThreshold == null,
|
|
2289
|
+
overloaded: false,
|
|
2290
|
+
running: eluThreshold == null
|
|
2291
|
+
})
|
|
2292
|
+
this.#startHealthMetricsCollectionIfNeeded()
|
|
2293
|
+
|
|
2294
|
+
return true
|
|
2295
|
+
})
|
|
2296
|
+
|
|
2297
|
+
worker[kITC].on('profiling:stopped', ({ type }) => {
|
|
2298
|
+
worker[kProfilingELUGates]?.delete(type)
|
|
2299
|
+
this.#lastOverloadProfiles.delete(`${workerId}:${type}`)
|
|
2300
|
+
})
|
|
2301
|
+
|
|
2302
|
+
// When an overload pause is applied, the worker pushes the encoded final
|
|
2303
|
+
// profile. It is preserved here so that the evidence of what saturated
|
|
2304
|
+
// the worker can be retrieved (see getApplicationLastProfile) even while
|
|
2305
|
+
// the worker event loop is blocked, and it survives the worker being
|
|
2306
|
+
// replaced by the health checks.
|
|
2307
|
+
worker[kITC].on('profile:overload', ({ type, timestamp, profile }) => {
|
|
2308
|
+
this.#lastOverloadProfiles.set(`${workerId}:${type}`, { profile, timestamp })
|
|
2309
|
+
})
|
|
2310
|
+
|
|
2311
|
+
// Preserved overload profiles outlive their worker only for a grace
|
|
2312
|
+
// period of twice the runtime graceful shutdown timeout: post-mortem
|
|
2313
|
+
// collectors (alert or health-event driven) have time to fetch the
|
|
2314
|
+
// evidence, but a long-dead worker's profile is not served forever and
|
|
2315
|
+
// entries cannot pile up across replacements (replacement workers get
|
|
2316
|
+
// fresh indices, so their keys are never reused).
|
|
2317
|
+
worker.on('exit', () => {
|
|
2318
|
+
for (const type of ['cpu', 'heap']) {
|
|
2319
|
+
const key = `${workerId}:${type}`
|
|
2320
|
+
const entry = this.#lastOverloadProfiles.get(key)
|
|
2321
|
+
|
|
2322
|
+
if (!entry) {
|
|
2323
|
+
continue
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
const gracefulShutdown = Number(this.#config?.gracefulShutdown?.runtime)
|
|
2327
|
+
const grace = Number.isFinite(gracefulShutdown) && gracefulShutdown > 0 ? gracefulShutdown * 2 : 20_000
|
|
2328
|
+
|
|
2329
|
+
setTimeout(() => {
|
|
2330
|
+
// Only delete the exact entry scheduled here: a successor worker
|
|
2331
|
+
// reusing the index may have preserved a newer profile meanwhile.
|
|
2332
|
+
if (this.#lastOverloadProfiles.get(key) === entry) {
|
|
2333
|
+
this.#lastOverloadProfiles.delete(key)
|
|
2334
|
+
}
|
|
2335
|
+
}, grace).unref()
|
|
2336
|
+
}
|
|
2337
|
+
})
|
|
2338
|
+
|
|
2105
2339
|
worker[kITC].on('request:restart', async () => {
|
|
2106
2340
|
// Do not restart applications that are not fully started yet or when the runtime is still starting.
|
|
2107
2341
|
// The gateway sends request:restart when it receives application:added events,
|
|
@@ -2190,15 +2424,24 @@ export class Runtime extends EventEmitter {
|
|
|
2190
2424
|
}
|
|
2191
2425
|
|
|
2192
2426
|
#startHealthMetricsCollectionIfNeeded () {
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2427
|
+
if (this.#healthMetricsCollectionActive || this.#status !== 'started') {
|
|
2428
|
+
return
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
// Need health metrics if dynamic workers scaler exists (for vertical scaling),
|
|
2432
|
+
// if an extension subscribed to them, if any worker has health checks enabled
|
|
2433
|
+
// or if any worker runs ELU-gated continuous profiling
|
|
2434
|
+
let needsHealthMetrics = !!this.#dynamicWorkersScaler || this.#extensionsWantHealthMetrics
|
|
2196
2435
|
|
|
2197
2436
|
if (!needsHealthMetrics) {
|
|
2198
|
-
// Check if any worker has health checks enabled
|
|
2199
2437
|
for (const worker of this.#workers.values()) {
|
|
2200
2438
|
const healthConfig = worker[kConfig]?.health
|
|
2201
|
-
if (healthConfig?.enabled && this.#config
|
|
2439
|
+
if (healthConfig?.enabled && this.#getApplicationRestartOnError(this.#config, worker[kConfig]) > 0) {
|
|
2440
|
+
needsHealthMetrics = true
|
|
2441
|
+
break
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
if (worker[kProfilingELUGates]?.size > 0) {
|
|
2202
2445
|
needsHealthMetrics = true
|
|
2203
2446
|
break
|
|
2204
2447
|
}
|
|
@@ -2211,8 +2454,11 @@ export class Runtime extends EventEmitter {
|
|
|
2211
2454
|
}
|
|
2212
2455
|
|
|
2213
2456
|
#startHealthMetricsCollection () {
|
|
2457
|
+
this.#healthMetricsCollectionActive = true
|
|
2458
|
+
|
|
2214
2459
|
const collectHealthMetrics = async () => {
|
|
2215
2460
|
if (this.#status !== 'started') {
|
|
2461
|
+
this.#healthMetricsCollectionActive = false
|
|
2216
2462
|
return
|
|
2217
2463
|
}
|
|
2218
2464
|
|
|
@@ -2228,12 +2474,11 @@ export class Runtime extends EventEmitter {
|
|
|
2228
2474
|
const id = worker[kApplicationId]
|
|
2229
2475
|
const index = worker[kWorkerId]
|
|
2230
2476
|
const errorLabel = this.#workerExtendedLabel(id, index, worker[kConfig].workers)
|
|
2477
|
+
const previousELU = worker[kLastHealthCheckELU]
|
|
2231
2478
|
|
|
2232
2479
|
let health = null
|
|
2233
2480
|
try {
|
|
2234
|
-
health = await this.getWorkerHealth(worker, {
|
|
2235
|
-
previousELU: worker[kLastHealthCheckELU]
|
|
2236
|
-
})
|
|
2481
|
+
health = await this.getWorkerHealth(worker, { previousELU })
|
|
2237
2482
|
} catch (err) {
|
|
2238
2483
|
this.logger.error({ err }, `Failed to get health for ${errorLabel}.`)
|
|
2239
2484
|
} finally {
|
|
@@ -2252,6 +2497,13 @@ export class Runtime extends EventEmitter {
|
|
|
2252
2497
|
currentHealth: health,
|
|
2253
2498
|
healthSignals
|
|
2254
2499
|
})
|
|
2500
|
+
|
|
2501
|
+
// Drive the ELU gating of the continuous profiler. The first sample
|
|
2502
|
+
// is skipped as it reports the utilization since the thread started
|
|
2503
|
+
// rather than over the last collection interval.
|
|
2504
|
+
if (health && previousELU != null) {
|
|
2505
|
+
this.#applyProfilingELUGates(worker, health.elu)
|
|
2506
|
+
}
|
|
2255
2507
|
})())
|
|
2256
2508
|
}
|
|
2257
2509
|
|
|
@@ -2266,6 +2518,56 @@ export class Runtime extends EventEmitter {
|
|
|
2266
2518
|
this.#healthMetricsTimer = setTimeout(collectHealthMetrics, 1000).unref()
|
|
2267
2519
|
}
|
|
2268
2520
|
|
|
2521
|
+
// Drive the ELU gating of the continuous profiler from the main thread.
|
|
2522
|
+
// The health metrics cycle measures the worker ELU (without depending on the
|
|
2523
|
+
// worker's event loop being responsive, and consistently with health checks)
|
|
2524
|
+
// and resumes/pauses the in-worker profiler accordingly. The profiler runs
|
|
2525
|
+
// while the ELU is above the eluThreshold demand (if one is set) and below
|
|
2526
|
+
// the maxELU overload cutoff (if one is set): crossing the cutoff captures
|
|
2527
|
+
// one last profile and pauses profiling until the worker recovers, so that
|
|
2528
|
+
// profiling does not add overhead to an already overloaded worker.
|
|
2529
|
+
#applyProfilingELUGates (worker, elu) {
|
|
2530
|
+
const gates = worker[kProfilingELUGates]
|
|
2531
|
+
|
|
2532
|
+
if (!gates?.size || typeof elu !== 'number') {
|
|
2533
|
+
return
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
for (const [type, gate] of gates) {
|
|
2537
|
+
// Hysteresis on both bounds to prevent rapid toggling: each state only
|
|
2538
|
+
// flips back once the ELU moves kProfilingELUHysteresis past the bound.
|
|
2539
|
+
if (gate.eluThreshold != null) {
|
|
2540
|
+
gate.wanted = gate.wanted ? elu >= gate.eluThreshold - kProfilingELUHysteresis : elu > gate.eluThreshold
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
if (gate.maxELU != null) {
|
|
2544
|
+
gate.overloaded = gate.overloaded ? elu >= gate.maxELU - kProfilingELUHysteresis : elu > gate.maxELU
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
const shouldRun = gate.wanted && !gate.overloaded
|
|
2548
|
+
|
|
2549
|
+
if (shouldRun === gate.running) {
|
|
2550
|
+
continue
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
gate.running = shouldRun
|
|
2554
|
+
|
|
2555
|
+
try {
|
|
2556
|
+
if (shouldRun) {
|
|
2557
|
+
worker[kITC].notify('resumeProfiling', { type })
|
|
2558
|
+
} else {
|
|
2559
|
+
// The reason matters to the worker: when pausing for overload it
|
|
2560
|
+
// keeps the final profile available for the whole pause, so that
|
|
2561
|
+
// consumers can still retrieve the evidence of what saturated the
|
|
2562
|
+
// worker.
|
|
2563
|
+
worker[kITC].notify('pauseProfiling', { type, reason: gate.overloaded ? 'overload' : 'threshold' })
|
|
2564
|
+
}
|
|
2565
|
+
} catch (err) {
|
|
2566
|
+
this.logger.error({ err }, 'Failed to toggle the continuous profiler')
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2269
2571
|
#setupHealthCheck (config, applicationConfig, workersCount, id, index, worker, errorLabel) {
|
|
2270
2572
|
let healthMetricsListener = null
|
|
2271
2573
|
|
|
@@ -2279,12 +2581,19 @@ export class Runtime extends EventEmitter {
|
|
|
2279
2581
|
|
|
2280
2582
|
const healthConfig = worker[kConfig].health
|
|
2281
2583
|
|
|
2282
|
-
let { maxELU, maxHeapUsed, maxHeapTotal, maxUnhealthyChecks, interval } =
|
|
2584
|
+
let { maxELU, maxHeapUsed, maxHeapTotal, maxUnhealthyChecks, interval, maxEventLoopDelay, maxEventLoopDelayP99 } =
|
|
2585
|
+
worker[kConfig].health
|
|
2283
2586
|
|
|
2284
2587
|
if (typeof maxHeapTotal === 'string') {
|
|
2285
2588
|
maxHeapTotal = parseMemorySize(maxHeapTotal)
|
|
2286
2589
|
}
|
|
2287
2590
|
|
|
2591
|
+
maxEventLoopDelay = Number(maxEventLoopDelay)
|
|
2592
|
+
maxEventLoopDelayP99 = Number(maxEventLoopDelayP99)
|
|
2593
|
+
const eventLoopDelayEnabled = Number.isFinite(maxEventLoopDelay) && maxEventLoopDelay > 0
|
|
2594
|
+
const eventLoopDelayP99Enabled = Number.isFinite(maxEventLoopDelayP99) && maxEventLoopDelayP99 > 0
|
|
2595
|
+
const eventLoopDelayMonitored = eventLoopDelayEnabled || eventLoopDelayP99Enabled
|
|
2596
|
+
|
|
2288
2597
|
if (interval < 1000) {
|
|
2289
2598
|
interval = 1000
|
|
2290
2599
|
this.logger.warn(
|
|
@@ -2295,9 +2604,32 @@ export class Runtime extends EventEmitter {
|
|
|
2295
2604
|
|
|
2296
2605
|
let lastHealthMetrics = null
|
|
2297
2606
|
|
|
2607
|
+
// Health metrics arrive every second while the check runs every
|
|
2608
|
+
// `interval`: track the maximum event loop delay (and the worst reported
|
|
2609
|
+
// per-second p99) across the whole check window, so that stalls between
|
|
2610
|
+
// checks are not missed.
|
|
2611
|
+
let maxObservedEventLoopDelay = 0
|
|
2612
|
+
let maxObservedEventLoopDelayP99 = 0
|
|
2613
|
+
|
|
2298
2614
|
healthMetricsListener = healthCheck => {
|
|
2299
2615
|
if (healthCheck.id === worker[kId]) {
|
|
2300
2616
|
lastHealthMetrics = healthCheck
|
|
2617
|
+
|
|
2618
|
+
if (eventLoopDelayMonitored) {
|
|
2619
|
+
for (const signal of healthCheck.healthSignals) {
|
|
2620
|
+
if (signal.type !== 'eventLoopDelay') {
|
|
2621
|
+
continue
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
if (signal.max > maxObservedEventLoopDelay) {
|
|
2625
|
+
maxObservedEventLoopDelay = signal.max
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
if (signal.p99 > maxObservedEventLoopDelayP99) {
|
|
2629
|
+
maxObservedEventLoopDelayP99 = signal.p99
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2301
2633
|
}
|
|
2302
2634
|
}
|
|
2303
2635
|
|
|
@@ -2360,13 +2692,22 @@ export class Runtime extends EventEmitter {
|
|
|
2360
2692
|
}
|
|
2361
2693
|
|
|
2362
2694
|
const memoryUsage = health.heapUsed != null ? health.heapUsed / maxHeapTotal : 0
|
|
2363
|
-
const
|
|
2695
|
+
const eventLoopDelay = maxObservedEventLoopDelay
|
|
2696
|
+
const eventLoopDelayP99 = maxObservedEventLoopDelayP99
|
|
2697
|
+
maxObservedEventLoopDelay = 0
|
|
2698
|
+
maxObservedEventLoopDelayP99 = 0
|
|
2699
|
+
const eventLoopDelayExceeded = eventLoopDelayEnabled && eventLoopDelay > maxEventLoopDelay
|
|
2700
|
+
const eventLoopDelayP99Exceeded = eventLoopDelayP99Enabled && eventLoopDelayP99 > maxEventLoopDelayP99
|
|
2701
|
+
const unhealthy =
|
|
2702
|
+
health.elu > maxELU || memoryUsage > maxHeapUsed || eventLoopDelayExceeded || eventLoopDelayP99Exceeded
|
|
2364
2703
|
|
|
2365
2704
|
this.emit('application:worker:health', {
|
|
2366
2705
|
id: worker[kId],
|
|
2367
2706
|
application: id,
|
|
2368
2707
|
worker: index,
|
|
2369
2708
|
currentHealth: health,
|
|
2709
|
+
eventLoopDelay: eventLoopDelayMonitored ? eventLoopDelay : undefined,
|
|
2710
|
+
eventLoopDelayP99: eventLoopDelayMonitored ? eventLoopDelayP99 : undefined,
|
|
2370
2711
|
unhealthy,
|
|
2371
2712
|
healthConfig
|
|
2372
2713
|
})
|
|
@@ -2385,6 +2726,20 @@ export class Runtime extends EventEmitter {
|
|
|
2385
2726
|
)
|
|
2386
2727
|
}
|
|
2387
2728
|
|
|
2729
|
+
if (eventLoopDelayExceeded) {
|
|
2730
|
+
this.logger.error(
|
|
2731
|
+
`The ${errorLabel} had a maximum event loop delay of ${eventLoopDelay.toFixed(2)} ms, ` +
|
|
2732
|
+
`above the maximum allowed delay of ${maxEventLoopDelay} ms.`
|
|
2733
|
+
)
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
if (eventLoopDelayP99Exceeded) {
|
|
2737
|
+
this.logger.error(
|
|
2738
|
+
`The ${errorLabel} had a p99 event loop delay of ${eventLoopDelayP99.toFixed(2)} ms, ` +
|
|
2739
|
+
`above the maximum allowed p99 delay of ${maxEventLoopDelayP99} ms.`
|
|
2740
|
+
)
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2388
2743
|
if (unhealthy) {
|
|
2389
2744
|
unhealthyChecks++
|
|
2390
2745
|
} else {
|
|
@@ -2396,7 +2751,16 @@ export class Runtime extends EventEmitter {
|
|
|
2396
2751
|
this.emitAndNotify('application:worker:unhealthy', { application: id, worker: index })
|
|
2397
2752
|
|
|
2398
2753
|
this.logger.error(
|
|
2399
|
-
{
|
|
2754
|
+
{
|
|
2755
|
+
elu: health.elu,
|
|
2756
|
+
maxELU,
|
|
2757
|
+
memoryUsage: health.heapUsed,
|
|
2758
|
+
maxMemoryUsage: maxHeapUsed,
|
|
2759
|
+
eventLoopDelay,
|
|
2760
|
+
maxEventLoopDelay,
|
|
2761
|
+
eventLoopDelayP99,
|
|
2762
|
+
maxEventLoopDelayP99
|
|
2763
|
+
},
|
|
2400
2764
|
`The ${errorLabel} is unhealthy. Replacing it ...`
|
|
2401
2765
|
)
|
|
2402
2766
|
|
|
@@ -2499,7 +2863,7 @@ export class Runtime extends EventEmitter {
|
|
|
2499
2863
|
}
|
|
2500
2864
|
|
|
2501
2865
|
const { enabled, gracePeriod } = worker[kConfig].health
|
|
2502
|
-
if (enabled && config
|
|
2866
|
+
if (enabled && this.#getApplicationRestartOnError(config, applicationConfig) > 0) {
|
|
2503
2867
|
// if gracePeriod is 0, it will be set to 1 to start health checks immediately
|
|
2504
2868
|
// however, the health event will start when the worker is started
|
|
2505
2869
|
setTimeout(
|
|
@@ -2537,7 +2901,7 @@ export class Runtime extends EventEmitter {
|
|
|
2537
2901
|
this.logger.error({ err: ensureLoggableError(error) }, `Failed to start ${label}: ${error.message}`)
|
|
2538
2902
|
}
|
|
2539
2903
|
|
|
2540
|
-
const restartOnError = config
|
|
2904
|
+
const restartOnError = this.#getApplicationRestartOnError(config, applicationConfig)
|
|
2541
2905
|
|
|
2542
2906
|
if (disableRestartAttempts || !restartOnError) {
|
|
2543
2907
|
throw error
|
|
@@ -2657,7 +3021,25 @@ export class Runtime extends EventEmitter {
|
|
|
2657
3021
|
return index
|
|
2658
3022
|
}
|
|
2659
3023
|
|
|
3024
|
+
// Returns the effective restartOnError value for an application: the application-level
|
|
3025
|
+
// value, when defined, takes precedence over the runtime-level one.
|
|
3026
|
+
// The value is normalized to a number: false becomes 0 (never restart),
|
|
3027
|
+
// true becomes the default delay.
|
|
3028
|
+
#getApplicationRestartOnError (config, applicationConfig) {
|
|
3029
|
+
let restartOnError = applicationConfig?.restartOnError ?? config.restartOnError
|
|
3030
|
+
|
|
3031
|
+
if (restartOnError === true) {
|
|
3032
|
+
restartOnError = DEFAULT_RESTART_ON_ERROR_DELAY
|
|
3033
|
+
} else if (restartOnError === false || restartOnError < 0) {
|
|
3034
|
+
restartOnError = 0
|
|
3035
|
+
}
|
|
3036
|
+
|
|
3037
|
+
return restartOnError
|
|
3038
|
+
}
|
|
3039
|
+
|
|
2660
3040
|
async #restartCrashedWorker (config, applicationConfig, workersCount, id, oldIndex, silent, bootstrapAttempt, portOffset) {
|
|
3041
|
+
const restartOnError = this.#getApplicationRestartOnError(config, applicationConfig)
|
|
3042
|
+
|
|
2661
3043
|
// Use oldIndex for tracking to prevent duplicate restarts of the same crashed worker
|
|
2662
3044
|
const restartKey = `${id}:${oldIndex}`
|
|
2663
3045
|
|
|
@@ -2703,10 +3085,10 @@ export class Runtime extends EventEmitter {
|
|
|
2703
3085
|
}
|
|
2704
3086
|
}
|
|
2705
3087
|
|
|
2706
|
-
if (
|
|
3088
|
+
if (restartOnError < IMMEDIATE_RESTART_MAX_THRESHOLD) {
|
|
2707
3089
|
process.nextTick(restart.bind(this))
|
|
2708
3090
|
} else {
|
|
2709
|
-
setTimeout(restart.bind(this),
|
|
3091
|
+
setTimeout(restart.bind(this), restartOnError)
|
|
2710
3092
|
}
|
|
2711
3093
|
})
|
|
2712
3094
|
|
|
@@ -2756,6 +3138,11 @@ export class Runtime extends EventEmitter {
|
|
|
2756
3138
|
return this.#discardWorker(newWorker)
|
|
2757
3139
|
}
|
|
2758
3140
|
|
|
3141
|
+
// Register the worker before starting it, like in the regular startup flow,
|
|
3142
|
+
// so that it is addressable when the application:worker:started event is emitted.
|
|
3143
|
+
// The discard paths below remove it from the map via #cleanupWorker.
|
|
3144
|
+
this.#workers.set(newWorkerId, newWorker)
|
|
3145
|
+
|
|
2759
3146
|
// Add the worker to the mesh
|
|
2760
3147
|
await this.#startWorker(configForNewWorker, applicationConfig, workersCount, applicationId, newIndex, false, 0, newWorker, true)
|
|
2761
3148
|
|
|
@@ -2763,10 +3150,13 @@ export class Runtime extends EventEmitter {
|
|
|
2763
3150
|
if (this.#status !== 'started') {
|
|
2764
3151
|
return this.#discardWorker(newWorker)
|
|
2765
3152
|
}
|
|
2766
|
-
|
|
2767
|
-
this.#workers.set(newWorkerId, newWorker)
|
|
2768
3153
|
} catch (e) {
|
|
2769
3154
|
this.#workerPortOffsets.delete(newWorkerId)
|
|
3155
|
+
|
|
3156
|
+
if (this.#workers.get(newWorkerId) === newWorker) {
|
|
3157
|
+
this.#workers.delete(newWorkerId)
|
|
3158
|
+
}
|
|
3159
|
+
|
|
2770
3160
|
newWorker?.terminate?.()
|
|
2771
3161
|
throw e
|
|
2772
3162
|
}
|
|
@@ -3474,6 +3864,115 @@ export class Runtime extends EventEmitter {
|
|
|
3474
3864
|
worker[kWorkerHealthSignals].add(signals)
|
|
3475
3865
|
}
|
|
3476
3866
|
|
|
3867
|
+
async #loadExtensions () {
|
|
3868
|
+
let extensions = this.#config.extensions
|
|
3869
|
+
|
|
3870
|
+
// Do not load extensions when building applications
|
|
3871
|
+
if (!extensions || this.#context.build) {
|
|
3872
|
+
return
|
|
3873
|
+
}
|
|
3874
|
+
|
|
3875
|
+
if (!Array.isArray(extensions)) {
|
|
3876
|
+
extensions = [extensions]
|
|
3877
|
+
}
|
|
3878
|
+
|
|
3879
|
+
for (const extension of extensions) {
|
|
3880
|
+
const { path, options } = typeof extension === 'string' ? { path: extension } : extension
|
|
3881
|
+
|
|
3882
|
+
let setup
|
|
3883
|
+
try {
|
|
3884
|
+
setup = (await import(pathToFileURL(path))).default
|
|
3885
|
+
} catch (e) {
|
|
3886
|
+
throw new FailedToLoadExtensionError(path, e.message, { cause: e })
|
|
3887
|
+
}
|
|
3888
|
+
|
|
3889
|
+
if (typeof setup !== 'function') {
|
|
3890
|
+
throw new InvalidExtensionError(path)
|
|
3891
|
+
}
|
|
3892
|
+
|
|
3893
|
+
const logger = this.logger.child({ name: `extension:${basename(path)}` })
|
|
3894
|
+
|
|
3895
|
+
try {
|
|
3896
|
+
const instance = await setup({
|
|
3897
|
+
runtime: this,
|
|
3898
|
+
itc: this.#createExtensionITC(),
|
|
3899
|
+
logger,
|
|
3900
|
+
options: options ?? {},
|
|
3901
|
+
root: this.#root
|
|
3902
|
+
})
|
|
3903
|
+
|
|
3904
|
+
this.#extensions.push({ path, instance })
|
|
3905
|
+
} catch (e) {
|
|
3906
|
+
throw new FailedToLoadExtensionError(path, e.message, { cause: e })
|
|
3907
|
+
}
|
|
3908
|
+
}
|
|
3909
|
+
|
|
3910
|
+
// If any extension subscribed to health metrics during its setup, make sure
|
|
3911
|
+
// the health metrics collection is started even if no health check or
|
|
3912
|
+
// dynamic workers scaler is enabled. Note that this is evaluated here on
|
|
3913
|
+
// purpose, before any worker health check listener is registered.
|
|
3914
|
+
this.#extensionsWantHealthMetrics = this.listenerCount('application:worker:health:metrics') > 0
|
|
3915
|
+
}
|
|
3916
|
+
|
|
3917
|
+
async #closeExtensions () {
|
|
3918
|
+
// Close in reverse order, so that extensions loaded later, which might depend
|
|
3919
|
+
// on earlier ones, are closed first.
|
|
3920
|
+
const extensions = this.#extensions.splice(0).reverse()
|
|
3921
|
+
|
|
3922
|
+
for (const { path, instance } of extensions) {
|
|
3923
|
+
try {
|
|
3924
|
+
await instance?.close?.()
|
|
3925
|
+
} catch (e) {
|
|
3926
|
+
this.logger.error({ err: ensureLoggableError(e) }, `Failed to close the extension "${path}".`)
|
|
3927
|
+
}
|
|
3928
|
+
}
|
|
3929
|
+
}
|
|
3930
|
+
|
|
3931
|
+
#createExtensionITC () {
|
|
3932
|
+
return {
|
|
3933
|
+
handle: (name, handler) => {
|
|
3934
|
+
if (this.#reservedITCHandlerNames.has(name)) {
|
|
3935
|
+
throw new ReservedITCHandlerNameError(name)
|
|
3936
|
+
}
|
|
3937
|
+
|
|
3938
|
+
if (name in this.#workerITCHandlers) {
|
|
3939
|
+
throw new DuplicateITCHandlerNameError(name)
|
|
3940
|
+
}
|
|
3941
|
+
|
|
3942
|
+
this.#workerITCHandlers[name] = handler
|
|
3943
|
+
|
|
3944
|
+
// Workers copy the handlers when their ITC is created, so also register
|
|
3945
|
+
// the handler on all running workers.
|
|
3946
|
+
for (const worker of this.#workers.values()) {
|
|
3947
|
+
worker[kITC]?.handle(name, handler)
|
|
3948
|
+
}
|
|
3949
|
+
},
|
|
3950
|
+
send: async (target, name, payload) => {
|
|
3951
|
+
const worker = await this.#getApplicationById(target)
|
|
3952
|
+
return sendViaITC(worker, name, payload)
|
|
3953
|
+
},
|
|
3954
|
+
notify: async (target, name, payload) => {
|
|
3955
|
+
const matched = target.match(/^(.+):(\d+)$/)
|
|
3956
|
+
|
|
3957
|
+
if (matched) {
|
|
3958
|
+
const worker = await this.#getWorkerByIdOrNext(matched[1], matched[2])
|
|
3959
|
+
worker[kITC].notify(name, payload)
|
|
3960
|
+
return
|
|
3961
|
+
}
|
|
3962
|
+
|
|
3963
|
+
if (!this.#applications.has(target)) {
|
|
3964
|
+
throw new ApplicationNotFoundError(target, this.getApplicationsIds().join(', '))
|
|
3965
|
+
}
|
|
3966
|
+
|
|
3967
|
+
for (const worker of this.#workers.values()) {
|
|
3968
|
+
if (worker[kApplicationId] === target && worker[kWorkerStatus] === 'started') {
|
|
3969
|
+
worker[kITC].notify(name, payload)
|
|
3970
|
+
}
|
|
3971
|
+
}
|
|
3972
|
+
}
|
|
3973
|
+
}
|
|
3974
|
+
}
|
|
3975
|
+
|
|
3477
3976
|
#updateLoggingPrefixes () {
|
|
3478
3977
|
if (!this.#loggerContext) {
|
|
3479
3978
|
return
|