@platformatic/runtime 3.61.1 → 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/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
 
@@ -818,6 +871,91 @@ export class Runtime extends EventEmitter {
818
871
  return sendViaITC(service, 'stopProfiling', options)
819
872
  }
820
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
+
821
959
  async takeApplicationHeapSnapshot (id, ensureStarted = true) {
822
960
  const service = await this.#getApplicationById(id, ensureStarted)
823
961
 
@@ -1251,9 +1389,7 @@ export class Runtime extends EventEmitter {
1251
1389
 
1252
1390
  let metrics = null
1253
1391
 
1254
- const applicationRestartMetrics = format === 'json'
1255
- ? this.#getApplicationRestartMetricsJson()
1256
- : this.#formatApplicationRestartMetricsText()
1392
+ const applicationRestartMetrics = this.#getApplicationRestartMetricsJson()
1257
1393
 
1258
1394
  // Get process-level metrics once from main thread registry (if available)
1259
1395
  let processMetricsJson = null
@@ -1268,16 +1404,16 @@ export class Runtime extends EventEmitter {
1268
1404
  continue
1269
1405
  }
1270
1406
 
1271
- // 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.
1272
1410
  const applicationMetrics = await executeWithTimeout(
1273
- sendViaITC(worker, 'getMetrics', format),
1411
+ sendViaITC(worker, 'getMetrics', 'json'),
1274
1412
  this.#config.metrics?.timeout ?? 10000
1275
1413
  )
1276
1414
 
1277
1415
  if (applicationMetrics && applicationMetrics !== kTimeout) {
1278
- if (metrics === null) {
1279
- metrics = format === 'json' ? [] : ''
1280
- }
1416
+ metrics ??= []
1281
1417
 
1282
1418
  // Build worker labels including custom labels from metrics config
1283
1419
  const workerLabels = {
@@ -1289,21 +1425,13 @@ export class Runtime extends EventEmitter {
1289
1425
  workerLabels.workerId = workerId
1290
1426
  }
1291
1427
 
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
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])
1307
1435
  }
1308
1436
  }
1309
1437
  } catch (e) {
@@ -1321,9 +1449,11 @@ export class Runtime extends EventEmitter {
1321
1449
  }
1322
1450
 
1323
1451
  if (metrics !== null && applicationRestartMetrics.length > 0) {
1324
- metrics = format === 'json'
1325
- ? [...applicationRestartMetrics, ...metrics]
1326
- : applicationRestartMetrics + metrics
1452
+ metrics = [...applicationRestartMetrics, ...metrics]
1453
+ }
1454
+
1455
+ if (metrics !== null && format !== 'json') {
1456
+ metrics = this.#formatMetricsAsText(metrics)
1327
1457
  }
1328
1458
 
1329
1459
  return { metrics }
@@ -1360,28 +1490,6 @@ export class Runtime extends EventEmitter {
1360
1490
  return metrics
1361
1491
  }
1362
1492
 
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
1493
  // Apply labels to process metrics and push to output array (for JSON format)
1386
1494
  #applyLabelsToMetrics (processMetrics, labels, outputArray) {
1387
1495
  for (let i = 0; i < processMetrics.length; i++) {
@@ -1406,27 +1514,33 @@ export class Runtime extends EventEmitter {
1406
1514
  }
1407
1515
  }
1408
1516
 
1409
- // Format process metrics as Prometheus text format with labels
1410
- #formatProcessMetricsText (processMetricsJson, labels) {
1411
- let output = ''
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()
1412
1524
 
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
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
+ }
1418
1533
 
1419
- // Add HELP and TYPE lines
1420
- output += `# HELP ${name} ${help}\n`
1421
- output += `# TYPE ${name} ${type}\n`
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`
1422
1539
 
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 }
1540
+ for (const v of family.values) {
1427
1541
  const labelParts = []
1428
1542
 
1429
- for (const [key, val] of Object.entries(combinedLabels)) {
1543
+ for (const [key, val] of Object.entries(v.labels ?? {})) {
1430
1544
  // Escape label values for Prometheus format
1431
1545
  const escapedVal = String(val).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')
1432
1546
  labelParts.push(`${key}="${escapedVal}"`)
@@ -1434,7 +1548,7 @@ export class Runtime extends EventEmitter {
1434
1548
 
1435
1549
  const labelStr = labelParts.length > 0 ? `{${labelParts.join(',')}}` : ''
1436
1550
  const metricName = v.metricName || name
1437
- output += `${metricName}${labelStr} ${v.value}\n`
1551
+ output += `${metricName}${labelStr} ${formatMetricValue(v.value)}\n`
1438
1552
  }
1439
1553
  }
1440
1554
 
@@ -1856,7 +1970,7 @@ export class Runtime extends EventEmitter {
1856
1970
  }
1857
1971
 
1858
1972
  async #setupWorker (config, applicationConfig, workersCount, applicationId, index, enabled = true, attempt = 0) {
1859
- const { restartOnError } = config
1973
+ const restartOnError = this.#getApplicationRestartOnError(config, applicationConfig)
1860
1974
  const workerId = `${applicationId}:${index}`
1861
1975
 
1862
1976
  // Handle inspector
@@ -2102,6 +2216,120 @@ export class Runtime extends EventEmitter {
2102
2216
  this.#opentelemetryMetricsForwarder?.collect(resourceMetrics)
2103
2217
  })
2104
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
+
2105
2333
  worker[kITC].on('request:restart', async () => {
2106
2334
  // Do not restart applications that are not fully started yet or when the runtime is still starting.
2107
2335
  // The gateway sends request:restart when it receives application:added events,
@@ -2190,15 +2418,24 @@ export class Runtime extends EventEmitter {
2190
2418
  }
2191
2419
 
2192
2420
  #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
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
2196
2429
 
2197
2430
  if (!needsHealthMetrics) {
2198
- // Check if any worker has health checks enabled
2199
2431
  for (const worker of this.#workers.values()) {
2200
2432
  const healthConfig = worker[kConfig]?.health
2201
- if (healthConfig?.enabled && this.#config.restartOnError > 0) {
2433
+ if (healthConfig?.enabled && this.#getApplicationRestartOnError(this.#config, worker[kConfig]) > 0) {
2434
+ needsHealthMetrics = true
2435
+ break
2436
+ }
2437
+
2438
+ if (worker[kProfilingELUGates]?.size > 0) {
2202
2439
  needsHealthMetrics = true
2203
2440
  break
2204
2441
  }
@@ -2211,8 +2448,11 @@ export class Runtime extends EventEmitter {
2211
2448
  }
2212
2449
 
2213
2450
  #startHealthMetricsCollection () {
2451
+ this.#healthMetricsCollectionActive = true
2452
+
2214
2453
  const collectHealthMetrics = async () => {
2215
2454
  if (this.#status !== 'started') {
2455
+ this.#healthMetricsCollectionActive = false
2216
2456
  return
2217
2457
  }
2218
2458
 
@@ -2228,12 +2468,11 @@ export class Runtime extends EventEmitter {
2228
2468
  const id = worker[kApplicationId]
2229
2469
  const index = worker[kWorkerId]
2230
2470
  const errorLabel = this.#workerExtendedLabel(id, index, worker[kConfig].workers)
2471
+ const previousELU = worker[kLastHealthCheckELU]
2231
2472
 
2232
2473
  let health = null
2233
2474
  try {
2234
- health = await this.getWorkerHealth(worker, {
2235
- previousELU: worker[kLastHealthCheckELU]
2236
- })
2475
+ health = await this.getWorkerHealth(worker, { previousELU })
2237
2476
  } catch (err) {
2238
2477
  this.logger.error({ err }, `Failed to get health for ${errorLabel}.`)
2239
2478
  } finally {
@@ -2252,6 +2491,13 @@ export class Runtime extends EventEmitter {
2252
2491
  currentHealth: health,
2253
2492
  healthSignals
2254
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
+ }
2255
2501
  })())
2256
2502
  }
2257
2503
 
@@ -2266,6 +2512,56 @@ export class Runtime extends EventEmitter {
2266
2512
  this.#healthMetricsTimer = setTimeout(collectHealthMetrics, 1000).unref()
2267
2513
  }
2268
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
+
2269
2565
  #setupHealthCheck (config, applicationConfig, workersCount, id, index, worker, errorLabel) {
2270
2566
  let healthMetricsListener = null
2271
2567
 
@@ -2279,12 +2575,19 @@ export class Runtime extends EventEmitter {
2279
2575
 
2280
2576
  const healthConfig = worker[kConfig].health
2281
2577
 
2282
- let { maxELU, maxHeapUsed, maxHeapTotal, maxUnhealthyChecks, interval } = worker[kConfig].health
2578
+ let { maxELU, maxHeapUsed, maxHeapTotal, maxUnhealthyChecks, interval, maxEventLoopDelay, maxEventLoopDelayP99 } =
2579
+ worker[kConfig].health
2283
2580
 
2284
2581
  if (typeof maxHeapTotal === 'string') {
2285
2582
  maxHeapTotal = parseMemorySize(maxHeapTotal)
2286
2583
  }
2287
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
+
2288
2591
  if (interval < 1000) {
2289
2592
  interval = 1000
2290
2593
  this.logger.warn(
@@ -2295,9 +2598,32 @@ export class Runtime extends EventEmitter {
2295
2598
 
2296
2599
  let lastHealthMetrics = null
2297
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
+
2298
2608
  healthMetricsListener = healthCheck => {
2299
2609
  if (healthCheck.id === worker[kId]) {
2300
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
+ }
2301
2627
  }
2302
2628
  }
2303
2629
 
@@ -2360,13 +2686,22 @@ export class Runtime extends EventEmitter {
2360
2686
  }
2361
2687
 
2362
2688
  const memoryUsage = health.heapUsed != null ? health.heapUsed / maxHeapTotal : 0
2363
- const unhealthy = health.elu > maxELU || memoryUsage > maxHeapUsed
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
2364
2697
 
2365
2698
  this.emit('application:worker:health', {
2366
2699
  id: worker[kId],
2367
2700
  application: id,
2368
2701
  worker: index,
2369
2702
  currentHealth: health,
2703
+ eventLoopDelay: eventLoopDelayMonitored ? eventLoopDelay : undefined,
2704
+ eventLoopDelayP99: eventLoopDelayMonitored ? eventLoopDelayP99 : undefined,
2370
2705
  unhealthy,
2371
2706
  healthConfig
2372
2707
  })
@@ -2385,6 +2720,20 @@ export class Runtime extends EventEmitter {
2385
2720
  )
2386
2721
  }
2387
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
+
2388
2737
  if (unhealthy) {
2389
2738
  unhealthyChecks++
2390
2739
  } else {
@@ -2396,7 +2745,16 @@ export class Runtime extends EventEmitter {
2396
2745
  this.emitAndNotify('application:worker:unhealthy', { application: id, worker: index })
2397
2746
 
2398
2747
  this.logger.error(
2399
- { elu: health.elu, maxELU, memoryUsage: health.heapUsed, maxMemoryUsage: maxHeapUsed },
2748
+ {
2749
+ elu: health.elu,
2750
+ maxELU,
2751
+ memoryUsage: health.heapUsed,
2752
+ maxMemoryUsage: maxHeapUsed,
2753
+ eventLoopDelay,
2754
+ maxEventLoopDelay,
2755
+ eventLoopDelayP99,
2756
+ maxEventLoopDelayP99
2757
+ },
2400
2758
  `The ${errorLabel} is unhealthy. Replacing it ...`
2401
2759
  )
2402
2760
 
@@ -2499,7 +2857,7 @@ export class Runtime extends EventEmitter {
2499
2857
  }
2500
2858
 
2501
2859
  const { enabled, gracePeriod } = worker[kConfig].health
2502
- if (enabled && config.restartOnError > 0) {
2860
+ if (enabled && this.#getApplicationRestartOnError(config, applicationConfig) > 0) {
2503
2861
  // if gracePeriod is 0, it will be set to 1 to start health checks immediately
2504
2862
  // however, the health event will start when the worker is started
2505
2863
  setTimeout(
@@ -2537,7 +2895,7 @@ export class Runtime extends EventEmitter {
2537
2895
  this.logger.error({ err: ensureLoggableError(error) }, `Failed to start ${label}: ${error.message}`)
2538
2896
  }
2539
2897
 
2540
- const restartOnError = config.restartOnError
2898
+ const restartOnError = this.#getApplicationRestartOnError(config, applicationConfig)
2541
2899
 
2542
2900
  if (disableRestartAttempts || !restartOnError) {
2543
2901
  throw error
@@ -2657,7 +3015,25 @@ export class Runtime extends EventEmitter {
2657
3015
  return index
2658
3016
  }
2659
3017
 
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
+
2660
3034
  async #restartCrashedWorker (config, applicationConfig, workersCount, id, oldIndex, silent, bootstrapAttempt, portOffset) {
3035
+ const restartOnError = this.#getApplicationRestartOnError(config, applicationConfig)
3036
+
2661
3037
  // Use oldIndex for tracking to prevent duplicate restarts of the same crashed worker
2662
3038
  const restartKey = `${id}:${oldIndex}`
2663
3039
 
@@ -2703,10 +3079,10 @@ export class Runtime extends EventEmitter {
2703
3079
  }
2704
3080
  }
2705
3081
 
2706
- if (config.restartOnError < IMMEDIATE_RESTART_MAX_THRESHOLD) {
3082
+ if (restartOnError < IMMEDIATE_RESTART_MAX_THRESHOLD) {
2707
3083
  process.nextTick(restart.bind(this))
2708
3084
  } else {
2709
- setTimeout(restart.bind(this), config.restartOnError)
3085
+ setTimeout(restart.bind(this), restartOnError)
2710
3086
  }
2711
3087
  })
2712
3088
 
@@ -2756,6 +3132,11 @@ export class Runtime extends EventEmitter {
2756
3132
  return this.#discardWorker(newWorker)
2757
3133
  }
2758
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
+
2759
3140
  // Add the worker to the mesh
2760
3141
  await this.#startWorker(configForNewWorker, applicationConfig, workersCount, applicationId, newIndex, false, 0, newWorker, true)
2761
3142
 
@@ -2763,10 +3144,13 @@ export class Runtime extends EventEmitter {
2763
3144
  if (this.#status !== 'started') {
2764
3145
  return this.#discardWorker(newWorker)
2765
3146
  }
2766
-
2767
- this.#workers.set(newWorkerId, newWorker)
2768
3147
  } catch (e) {
2769
3148
  this.#workerPortOffsets.delete(newWorkerId)
3149
+
3150
+ if (this.#workers.get(newWorkerId) === newWorker) {
3151
+ this.#workers.delete(newWorkerId)
3152
+ }
3153
+
2770
3154
  newWorker?.terminate?.()
2771
3155
  throw e
2772
3156
  }
@@ -3474,6 +3858,115 @@ export class Runtime extends EventEmitter {
3474
3858
  worker[kWorkerHealthSignals].add(signals)
3475
3859
  }
3476
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
+
3477
3970
  #updateLoggingPrefixes () {
3478
3971
  if (!this.#loggerContext) {
3479
3972
  return