@platformatic/runtime 3.61.0 → 3.62.0

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