@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/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
- const details = await this.getApplicationDetails(application)
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 = format === 'json'
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', format),
1417
+ sendViaITC(worker, 'getMetrics', 'json'),
1274
1418
  this.#config.metrics?.timeout ?? 10000
1275
1419
  )
1276
1420
 
1277
1421
  if (applicationMetrics && applicationMetrics !== kTimeout) {
1278
- if (metrics === null) {
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
- if (format === 'json') {
1293
- // Duplicate process metrics with worker labels and add to output
1294
- if (processMetricsJson) {
1295
- this.#applyLabelsToMetrics(processMetricsJson, workerLabels, metrics)
1296
- }
1297
- // Add worker's thread-specific metrics
1298
- for (let i = 0; i < applicationMetrics.length; i++) {
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 = format === 'json'
1325
- ? [...applicationRestartMetrics, ...metrics]
1326
- : applicationRestartMetrics + metrics
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
- // Format process metrics as Prometheus text format with labels
1410
- #formatProcessMetricsText (processMetricsJson, labels) {
1411
- let output = ''
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 (let i = 0; i < processMetricsJson.length; i++) {
1414
- const metric = processMetricsJson[i]
1415
- const name = metric.name
1416
- const help = metric.help
1417
- const type = metric.type
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
- // Add HELP and TYPE lines
1420
- output += `# HELP ${name} ${help}\n`
1421
- output += `# TYPE ${name} ${type}\n`
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 values = metric.values
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(combinedLabels)) {
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 { restartOnError } = config
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
- // Need health metrics if dynamic workers scaler exists (for vertical scaling)
2194
- // or if any worker has health checks enabled
2195
- let needsHealthMetrics = !!this.#dynamicWorkersScaler
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.restartOnError > 0) {
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 } = worker[kConfig].health
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 unhealthy = health.elu > maxELU || memoryUsage > maxHeapUsed
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
- { elu: health.elu, maxELU, memoryUsage: health.heapUsed, maxMemoryUsage: maxHeapUsed },
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.restartOnError > 0) {
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.restartOnError
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 (config.restartOnError < IMMEDIATE_RESTART_MAX_THRESHOLD) {
3088
+ if (restartOnError < IMMEDIATE_RESTART_MAX_THRESHOLD) {
2707
3089
  process.nextTick(restart.bind(this))
2708
3090
  } else {
2709
- setTimeout(restart.bind(this), config.restartOnError)
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