@platformatic/runtime 3.63.0-alpha.3 → 3.64.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
@@ -39,12 +39,17 @@ import {
39
39
  ApplicationNotStartedError,
40
40
  ApplicationStartTimeoutError,
41
41
  CannotRemoveEntrypointError,
42
+ DuplicateExtensionHealthCheckError,
42
43
  DuplicateITCHandlerNameError,
44
+ ExtensionHealthRoutesUnavailableError,
43
45
  FailedToLoadExtensionError,
46
+ FailedToStartExtensionError,
47
+ FailedToStopExtensionError,
44
48
  InvalidArgumentError,
45
49
  InvalidExtensionError,
46
50
  LastProfileTimeoutError,
47
51
  MessagingError,
52
+ MetricFamilyCollisionError,
48
53
  MissingPprofCapture,
49
54
  ReservedITCHandlerNameError,
50
55
  RuntimeAbortedError,
@@ -112,6 +117,10 @@ const kApplicationRestartsMetricHelp = 'Total number of restarts triggered by th
112
117
 
113
118
  const MAX_LISTENERS_COUNT = 100
114
119
 
120
+ function hasWorkerIndex (applicationId) {
121
+ return /^.+:\d+$/.test(applicationId)
122
+ }
123
+
115
124
  function parseOrigins (origins) {
116
125
  if (!origins) return undefined
117
126
 
@@ -190,6 +199,9 @@ export class Runtime extends EventEmitter {
190
199
  #reservedITCHandlerNames
191
200
  #extensions
192
201
  #extensionsWantHealthMetrics
202
+ #extensionReadinessChecks
203
+ #extensionLivenessChecks
204
+ #extensionHealthRoutes
193
205
  #lastOverloadProfiles
194
206
  #restartingApplications
195
207
  #restartingWorkers
@@ -267,6 +279,9 @@ export class Runtime extends EventEmitter {
267
279
  this.#reservedITCHandlerNames.add('profiling:started')
268
280
  this.#extensions = []
269
281
  this.#extensionsWantHealthMetrics = false
282
+ this.#extensionReadinessChecks = new Map()
283
+ this.#extensionLivenessChecks = new Map()
284
+ this.#extensionHealthRoutes = []
270
285
  this.#lastOverloadProfiles = new Map()
271
286
  this.#sharedContext = {}
272
287
 
@@ -298,12 +313,6 @@ export class Runtime extends EventEmitter {
298
313
  this.#metricsLabelName = 'applicationId'
299
314
  }
300
315
 
301
- if (config.metrics || (typeof config.healthProbes === 'object' && config.healthProbes !== null)) {
302
- this.#prometheusServer = await startPrometheusServer(this, config.metrics ?? false, config.healthProbes)
303
- }
304
-
305
- this.#healthProbesServer = await startHealthProbesServer(this, config.metrics, config.healthProbes)
306
-
307
316
  // Initialize process-level metrics registry in the main thread if metrics or management API is enabled
308
317
  // These metrics are the same across all workers and only need to be collected once
309
318
  // We need this for management API as it can request metrics even without explicit metrics config
@@ -312,7 +321,7 @@ export class Runtime extends EventEmitter {
312
321
  collectProcessMetrics(this.#processMetricsRegistry)
313
322
  }
314
323
 
315
- // Create the logger
324
+ // Create the logger before extensions and health/metrics servers so that both can use it.
316
325
  const [logger, destination, context] = await createLogger(config)
317
326
  this.logger = logger
318
327
  this.#loggerDestination = destination
@@ -333,9 +342,18 @@ export class Runtime extends EventEmitter {
333
342
  }
334
343
 
335
344
  // Load extensions before creating any worker so that custom ITC handlers
336
- // registered by the extensions are available to all workers.
345
+ // registered by the extensions are available to all workers. Also load them
346
+ // before starting the health/metrics servers so extensions can register
347
+ // readiness/liveness checks and probe routes before Fastify starts listening.
337
348
  await this.#loadExtensions()
338
349
 
350
+ if (config.metrics || (typeof config.healthProbes === 'object' && config.healthProbes !== null)) {
351
+ this.#prometheusServer = await startPrometheusServer(this, config.metrics ?? false, config.healthProbes)
352
+ }
353
+
354
+ this.#healthProbesServer = await startHealthProbesServer(this, config.metrics, config.healthProbes)
355
+ this.#assertExtensionHealthRoutesApplied()
356
+
339
357
  await this.addApplications(this.#config.applications)
340
358
  await this.#setDispatcher(config.undici)
341
359
 
@@ -355,10 +373,16 @@ export class Runtime extends EventEmitter {
355
373
  this.#createWorkersBroadcastChannel()
356
374
 
357
375
  try {
358
- const applications = this.getApplicationsIds()
359
- await this.startApplications(applications, silent)
376
+ // Snapshot originally configured application IDs before extension start hooks.
377
+ // Dynamic applications started by an extension are excluded from the normal startup pass.
378
+ const configuredApplications = this.getApplicationsIds()
360
379
 
361
- if (applications.length === 0) {
380
+ await this.#startExtensions()
381
+
382
+ const applicationsToStart = configuredApplications.filter(id => !this.#isApplicationStarted(id))
383
+ await this.startApplications(applicationsToStart, silent)
384
+
385
+ if (this.getApplicationsIds().length === 0) {
362
386
  this.#updateStatus('started')
363
387
  await this.close(silent)
364
388
  return
@@ -419,6 +443,15 @@ export class Runtime extends EventEmitter {
419
443
  await once(this, 'started')
420
444
  }
421
445
 
446
+ if (this.#status === 'stopping') {
447
+ await once(this, 'stopped')
448
+ return
449
+ }
450
+
451
+ if (this.#status === 'stopped' || this.#status === 'closing' || this.#status === 'closed') {
452
+ return
453
+ }
454
+
422
455
  this.#updateStatus('stopping')
423
456
 
424
457
  if (this.#scheduler) {
@@ -436,6 +469,10 @@ export class Runtime extends EventEmitter {
436
469
  await this.stopApplication(this.#entrypointId, silent)
437
470
  }
438
471
 
472
+ // Await extension stop hooks before stopping remaining applications so that
473
+ // control-plane extensions can settle work and hand off state first.
474
+ await this.#stopExtensions()
475
+
439
476
  await this.stopApplications(this.getApplicationsIds(), silent)
440
477
 
441
478
  await this.#meshInterceptor.close()
@@ -461,6 +498,15 @@ export class Runtime extends EventEmitter {
461
498
  }
462
499
 
463
500
  async close (silent = false) {
501
+ if (this.#status === 'closing') {
502
+ await once(this, 'closed')
503
+ return
504
+ }
505
+
506
+ if (this.#status === 'closed') {
507
+ return
508
+ }
509
+
464
510
  clearTimeout(this.#healthMetricsTimer)
465
511
  this.#healthMetricsCollectionActive = false
466
512
  this.#lastOverloadProfiles.clear()
@@ -652,7 +698,13 @@ export class Runtime extends EventEmitter {
652
698
 
653
699
  const removed = []
654
700
  for (const application of applications) {
655
- const details = await this.getApplicationDetails(application)
701
+ if (!this.#applications.has(application)) {
702
+ throw new ApplicationNotFoundError(application, this.getApplicationsIds().join(', '))
703
+ }
704
+
705
+ // Use allowUnloaded so that applications without a live worker
706
+ // (stopped or crashed with restartOnError: 0) can still be removed.
707
+ const details = await this.getApplicationDetails(application, true)
656
708
  details.status = 'removed'
657
709
  removed.push(details)
658
710
  }
@@ -773,6 +825,7 @@ export class Runtime extends EventEmitter {
773
825
  await this.#registerApplicationSchedulerJobs(id)
774
826
 
775
827
  this.emitAndNotify('application:started', id)
828
+ await this.#dynamicWorkersScaler?.applyPendingUpdate(id)
776
829
  }
777
830
 
778
831
  async stopApplication (id, silent = false, dependents = []) {
@@ -864,17 +917,68 @@ export class Runtime extends EventEmitter {
864
917
  }
865
918
 
866
919
  async startApplicationProfiling (id, options = {}, ensureStarted = true) {
867
- const service = await this.#getApplicationById(id, ensureStarted)
868
920
  this.#validatePprofCapturePreload()
869
921
 
870
- return sendViaITC(service, 'startProfiling', options)
922
+ const { allWorkers, ...profilingOptions } = options
923
+
924
+ if (!allWorkers || hasWorkerIndex(id)) {
925
+ const service = await this.#getApplicationWorkerForProfiling(id, ensureStarted)
926
+ return sendViaITC(service, 'startProfiling', profilingOptions)
927
+ }
928
+
929
+ const started = []
930
+ const alreadyProfiling = []
931
+ let firstError
932
+
933
+ for (const { workerIndex, worker } of await this.#getApplicationWorkersForProfiling(id, ensureStarted)) {
934
+ try {
935
+ await sendViaITC(worker, 'startProfiling', profilingOptions)
936
+ started.push(workerIndex)
937
+ } catch (error) {
938
+ // A worker which is already being profiled is considered covered, but
939
+ // if no other worker could be started the error is still reported.
940
+ if (error.code === 'PLT_PPROF_PROFILING_ALREADY_STARTED') {
941
+ alreadyProfiling.push(workerIndex)
942
+ }
943
+
944
+ firstError ??= error
945
+ }
946
+ }
947
+
948
+ if (started.length === 0 && firstError) {
949
+ throw firstError
950
+ }
951
+
952
+ return { workers: started.concat(alreadyProfiling).sort((a, b) => a - b) }
871
953
  }
872
954
 
873
955
  async stopApplicationProfiling (id, options = {}, ensureStarted = true) {
874
- const service = await this.#getApplicationById(id, ensureStarted)
875
956
  this.#validatePprofCapturePreload()
876
957
 
877
- return sendViaITC(service, 'stopProfiling', options)
958
+ const { allWorkers, ...profilingOptions } = options
959
+
960
+ if (!allWorkers || hasWorkerIndex(id)) {
961
+ const service = await this.#getApplicationWorkerForProfiling(id, ensureStarted)
962
+ return sendViaITC(service, 'stopProfiling', profilingOptions)
963
+ }
964
+
965
+ const profiles = []
966
+ let firstError
967
+
968
+ for (const { workerIndex, worker } of await this.#getApplicationWorkersForProfiling(id, ensureStarted)) {
969
+ try {
970
+ const profile = await sendViaITC(worker, 'stopProfiling', profilingOptions)
971
+ profiles.push({ workerIndex, profile })
972
+ } catch (error) {
973
+ firstError ??= error
974
+ }
975
+ }
976
+
977
+ if (profiles.length === 0 && firstError) {
978
+ throw firstError
979
+ }
980
+
981
+ return profiles
878
982
  }
879
983
 
880
984
  async getApplicationLastProfile (id, options = {}, ensureStarted = true) {
@@ -892,7 +996,11 @@ export class Runtime extends EventEmitter {
892
996
  // surface as an unhandled rejection.
893
997
  const pull = (async () => {
894
998
  const service = await this.#getApplicationById(id, ensureStarted)
895
- const result = await sendViaITC(service, 'getLastProfile', { ...options, includeTimestamp: true })
999
+ const result = await sendViaITC(service, 'getLastProfile', {
1000
+ ...options,
1001
+ includeTimestamp: true,
1002
+ includeSampleCount: true
1003
+ })
896
1004
  return { service, result }
897
1005
  })()
898
1006
  pull.catch(() => {})
@@ -904,7 +1012,9 @@ export class Runtime extends EventEmitter {
904
1012
 
905
1013
  // An older capture module which does not support includeTimestamp
906
1014
  // returns the raw profile.
907
- const value = result instanceof Uint8Array ? { profile: result, timestamp: null } : result
1015
+ const value = result instanceof Uint8Array
1016
+ ? { profile: result, timestamp: null, sampleCount: null }
1017
+ : { sampleCount: null, ...result }
908
1018
 
909
1019
  // A strictly newer live window supersedes the preserved overload
910
1020
  // profile: prune it so the preserved copy naturally expires once the
@@ -936,7 +1046,12 @@ export class Runtime extends EventEmitter {
936
1046
  if (preserved) {
937
1047
  // The preserved flag lets consumers distinguish post-mortem evidence
938
1048
  // from a live window and judge it together with the timestamp.
939
- return { profile: preserved.profile, timestamp: preserved.timestamp, preserved: true }
1049
+ return {
1050
+ profile: preserved.profile,
1051
+ timestamp: preserved.timestamp,
1052
+ sampleCount: preserved.sampleCount,
1053
+ preserved: true
1054
+ }
940
1055
  }
941
1056
 
942
1057
  throw error
@@ -1053,9 +1168,15 @@ export class Runtime extends EventEmitter {
1053
1168
  this.#config.metrics = metricsConfig
1054
1169
  this.#metricsLabelName = metricsConfig?.applicationLabel || 'applicationId'
1055
1170
 
1171
+ // Allow extension health routes to be re-applied on the restarted servers.
1172
+ for (const entry of this.#extensionHealthRoutes) {
1173
+ entry.applied = false
1174
+ }
1175
+
1056
1176
  this.#prometheusServer = await startPrometheusServer(this, metricsConfig, this.#config.healthProbes)
1057
1177
 
1058
1178
  this.#healthProbesServer = await startHealthProbesServer(this, metricsConfig, this.#config.healthProbes)
1179
+ this.#assertExtensionHealthRoutesApplied()
1059
1180
 
1060
1181
  await this.#startOpenTelemetryMetricsForwarder(metricsConfig?.opentelemetry)
1061
1182
 
@@ -1362,7 +1483,7 @@ export class Runtime extends EventEmitter {
1362
1483
  undefined,
1363
1484
  [],
1364
1485
  this.#concurrency,
1365
- this.#config.metrics.healthChecksTimeout,
1486
+ this.#getHealthChecksTimeout(),
1366
1487
  {}
1367
1488
  )
1368
1489
  }
@@ -1383,11 +1504,23 @@ export class Runtime extends EventEmitter {
1383
1504
  undefined,
1384
1505
  [],
1385
1506
  this.#concurrency,
1386
- this.#config.metrics.healthChecksTimeout,
1507
+ this.#getHealthChecksTimeout(),
1387
1508
  {}
1388
1509
  )
1389
1510
  }
1390
1511
 
1512
+ getExtensionHealthRoutes () {
1513
+ return this.#extensionHealthRoutes
1514
+ }
1515
+
1516
+ async runExtensionReadinessChecks () {
1517
+ return this.#runExtensionHealthChecks(this.#extensionReadinessChecks, 'readiness')
1518
+ }
1519
+
1520
+ async runExtensionLivenessChecks () {
1521
+ return this.#runExtensionHealthChecks(this.#extensionLivenessChecks, 'liveness')
1522
+ }
1523
+
1391
1524
  async getMetrics (format = 'json') {
1392
1525
  if (this.#config.metrics === false || this.#config.metrics?.enabled === false) {
1393
1526
  throw new Error('Metrics are disabled')
@@ -1403,6 +1536,15 @@ export class Runtime extends EventEmitter {
1403
1536
  processMetricsJson = await this.#processMetricsRegistry.getMetricsAsJSON()
1404
1537
  }
1405
1538
 
1539
+ // Collect main-thread extension metrics once. Each extension has its own
1540
+ // registry so metric registration and cleanup stay isolated. Collisions
1541
+ // with other extensions, process metrics, restart metrics, or worker
1542
+ // metrics fail with a coded error identifying the extension and family.
1543
+ const extensionMetrics = await this.#getExtensionMetricsJson({
1544
+ processMetricsJson,
1545
+ applicationRestartMetrics
1546
+ })
1547
+
1406
1548
  for (const worker of this.#workers.values()) {
1407
1549
  try {
1408
1550
  // The application might be temporarily unavailable
@@ -1421,20 +1563,6 @@ export class Runtime extends EventEmitter {
1421
1563
  if (applicationMetrics && applicationMetrics !== kTimeout) {
1422
1564
  metrics ??= []
1423
1565
 
1424
- // Build worker labels including custom labels from metrics config
1425
- const workerLabels = {
1426
- ...this.#config.metrics?.labels,
1427
- [this.#metricsLabelName]: worker[kApplicationId]
1428
- }
1429
- const workerId = worker[kWorkerId]
1430
- if (workerId >= 0) {
1431
- workerLabels.workerId = workerId
1432
- }
1433
-
1434
- // Duplicate process metrics with worker labels and add to output
1435
- if (processMetricsJson) {
1436
- this.#applyLabelsToMetrics(processMetricsJson, workerLabels, metrics)
1437
- }
1438
1566
  // Add worker's thread-specific metrics
1439
1567
  for (let i = 0; i < applicationMetrics.length; i++) {
1440
1568
  metrics.push(applicationMetrics[i])
@@ -1454,6 +1582,49 @@ export class Runtime extends EventEmitter {
1454
1582
  }
1455
1583
  }
1456
1584
 
1585
+ // Extension metrics must not share a family name with any worker metric.
1586
+ if (metrics !== null && extensionMetrics.length > 0) {
1587
+ const workerMetricNames = new Set()
1588
+ for (let i = 0; i < metrics.length; i++) {
1589
+ workerMetricNames.add(metrics[i].name)
1590
+ }
1591
+
1592
+ for (const { path, metricNames } of extensionMetrics) {
1593
+ for (const name of metricNames) {
1594
+ if (workerMetricNames.has(name)) {
1595
+ throw new MetricFamilyCollisionError(path, name, 'application worker metrics')
1596
+ }
1597
+ }
1598
+ }
1599
+ }
1600
+
1601
+ if (extensionMetrics.length > 0) {
1602
+ metrics ??= []
1603
+ const extensionMetricsJson = []
1604
+ for (const { metrics: extensionMetricList } of extensionMetrics) {
1605
+ extensionMetricsJson.push(...extensionMetricList)
1606
+ }
1607
+ metrics = [...extensionMetricsJson, ...metrics]
1608
+ }
1609
+
1610
+ // Report process-level metrics (e.g. process_resident_memory_bytes) only once:
1611
+ // they describe the whole runtime process, so replicating them for each
1612
+ // application running in a worker thread would just duplicate the same value.
1613
+ // Applications running as separate OS processes report their own process-level
1614
+ // metrics, with their own labels, as part of their thread metrics above.
1615
+ // See https://github.com/platformatic/platformatic/issues/3332.
1616
+ if (metrics !== null && processMetricsJson) {
1617
+ const processMetrics = []
1618
+ // Drop any configured custom label that shares the name of the application
1619
+ // label (a config can set both `applicationLabel: 'serviceId'` and a static
1620
+ // `serviceId` label): keeping it would make these runtime-wide metrics look
1621
+ // like they belong to an application, both here and in getFormattedMetrics().
1622
+ const processLabels = { ...this.#config.metrics?.labels }
1623
+ delete processLabels[this.#metricsLabelName]
1624
+ this.#applyLabelsToMetrics(processMetricsJson, processLabels, processMetrics)
1625
+ metrics = [...processMetrics, ...metrics]
1626
+ }
1627
+
1457
1628
  if (metrics !== null && applicationRestartMetrics.length > 0) {
1458
1629
  metrics = [...applicationRestartMetrics, ...metrics]
1459
1630
  }
@@ -1465,6 +1636,57 @@ export class Runtime extends EventEmitter {
1465
1636
  return { metrics }
1466
1637
  }
1467
1638
 
1639
+ async #getExtensionMetricsJson ({ processMetricsJson, applicationRestartMetrics }) {
1640
+ const results = []
1641
+ const metricSources = new Map()
1642
+
1643
+ // Static labels from metrics config apply, but Runtime never invents a
1644
+ // worker ID or application ID for main-thread extension metrics.
1645
+ const extensionLabels = typeof this.#config.metrics === 'object' && this.#config.metrics
1646
+ ? { ...this.#config.metrics.labels }
1647
+ : {}
1648
+ delete extensionLabels[this.#metricsLabelName]
1649
+
1650
+ const processMetricNames = new Set((processMetricsJson ?? []).map(metric => metric.name))
1651
+ const restartMetricNames = new Set((applicationRestartMetrics ?? []).map(metric => metric.name))
1652
+
1653
+ for (const { path, registry } of this.#extensions) {
1654
+ if (!registry) {
1655
+ continue
1656
+ }
1657
+
1658
+ const registryMetrics = await registry.getMetricsAsJSON()
1659
+ if (!registryMetrics || registryMetrics.length === 0) {
1660
+ continue
1661
+ }
1662
+
1663
+ const metricNames = []
1664
+ for (const metric of registryMetrics) {
1665
+ const existing = metricSources.get(metric.name)
1666
+ if (existing) {
1667
+ throw new MetricFamilyCollisionError(path, metric.name, `extension "${existing}"`)
1668
+ }
1669
+
1670
+ if (processMetricNames.has(metric.name)) {
1671
+ throw new MetricFamilyCollisionError(path, metric.name, 'runtime process metrics')
1672
+ }
1673
+
1674
+ if (restartMetricNames.has(metric.name)) {
1675
+ throw new MetricFamilyCollisionError(path, metric.name, 'runtime restart metrics')
1676
+ }
1677
+
1678
+ metricSources.set(metric.name, path)
1679
+ metricNames.push(metric.name)
1680
+ }
1681
+
1682
+ const labeledMetrics = []
1683
+ this.#applyLabelsToMetrics(registryMetrics, extensionLabels, labeledMetrics)
1684
+ results.push({ path, metrics: labeledMetrics, metricNames })
1685
+ }
1686
+
1687
+ return results
1688
+ }
1689
+
1468
1690
  #incrementApplicationRestartCount (applicationId) {
1469
1691
  this.#applicationRestartCounts.set(applicationId, (this.#applicationRestartCounts.get(applicationId) ?? 0) + 1)
1470
1692
  }
@@ -1581,6 +1803,13 @@ export class Runtime extends EventEmitter {
1581
1803
 
1582
1804
  const applicationsMetrics = {}
1583
1805
 
1806
+ // Process-level metrics are reported only once for the whole runtime, without
1807
+ // an application label, since they are shared by all the applications running
1808
+ // in worker threads (see issue #3332). Applications running as separate OS
1809
+ // processes report their own labeled values, which take precedence below.
1810
+ const runtimeProcessMetrics = {}
1811
+ const applicationProcessMetrics = new Set()
1812
+
1584
1813
  for (const metric of metrics) {
1585
1814
  const { name, values } = metric
1586
1815
 
@@ -1592,9 +1821,22 @@ export class Runtime extends EventEmitter {
1592
1821
  const applicationId = labels?.[this.#metricsLabelName]
1593
1822
 
1594
1823
  if (!applicationId) {
1824
+ if (name === 'process_cpu_percent_usage') {
1825
+ runtimeProcessMetrics.cpu = values[0].value
1826
+ continue
1827
+ }
1828
+ if (name === 'process_resident_memory_bytes') {
1829
+ runtimeProcessMetrics.rss = values[0].value
1830
+ continue
1831
+ }
1832
+
1595
1833
  throw new Error(`Missing ${this.#metricsLabelName} label in metrics`)
1596
1834
  }
1597
1835
 
1836
+ if (name === 'process_cpu_percent_usage' || name === 'process_resident_memory_bytes') {
1837
+ applicationProcessMetrics.add(`${applicationId}:${name}`)
1838
+ }
1839
+
1598
1840
  let applicationMetrics = applicationsMetrics[applicationId]
1599
1841
  if (!applicationMetrics) {
1600
1842
  applicationMetrics = {
@@ -1618,6 +1860,24 @@ export class Runtime extends EventEmitter {
1618
1860
  parsePromMetric(applicationMetrics, metric)
1619
1861
  }
1620
1862
 
1863
+ // Apply the runtime-wide process-level values to every application that did
1864
+ // not report its own (i.e. every application running in a worker thread).
1865
+ for (const [applicationId, applicationMetrics] of Object.entries(applicationsMetrics)) {
1866
+ if (
1867
+ runtimeProcessMetrics.cpu !== undefined &&
1868
+ !applicationProcessMetrics.has(`${applicationId}:process_cpu_percent_usage`)
1869
+ ) {
1870
+ applicationMetrics.cpu = runtimeProcessMetrics.cpu
1871
+ }
1872
+
1873
+ if (
1874
+ runtimeProcessMetrics.rss !== undefined &&
1875
+ !applicationProcessMetrics.has(`${applicationId}:process_resident_memory_bytes`)
1876
+ ) {
1877
+ applicationMetrics.rss = runtimeProcessMetrics.rss
1878
+ }
1879
+ }
1880
+
1621
1881
  function parsePromMetric (applicationMetrics, promMetric) {
1622
1882
  const { name } = promMetric
1623
1883
 
@@ -1700,17 +1960,27 @@ export class Runtime extends EventEmitter {
1700
1960
  }
1701
1961
 
1702
1962
  async getApplicationMeta (id) {
1703
- const application = await this.#getApplicationById(id)
1963
+ const hasWorkerId = /^.+:\d+$/.test(id)
1964
+ const attempts = hasWorkerId ? 1 : Math.max(1, this.#workers.getKeys(id).length)
1704
1965
 
1705
- try {
1706
- return await sendViaITC(application, 'getApplicationMeta')
1707
- } catch (e) {
1708
- // The application exports no meta, return an empty object
1709
- if (e.code === 'PLT_ITC_HANDLER_NOT_FOUND') {
1710
- return {}
1711
- }
1966
+ for (let attempt = 0; attempt < attempts; attempt++) {
1967
+ const application = await this.#getApplicationById(id)
1712
1968
 
1713
- throw e
1969
+ try {
1970
+ return await sendViaITC(application, 'getApplicationMeta')
1971
+ } catch (e) {
1972
+ // The application exports no meta, return an empty object
1973
+ if (e.code === 'PLT_ITC_HANDLER_NOT_FOUND') {
1974
+ return {}
1975
+ }
1976
+
1977
+ // A parallel restart can stop the selected worker while metadata is
1978
+ // being retrieved. Retry another worker unless one was requested
1979
+ // explicitly or every worker available at the start has been tried.
1980
+ if (e.code !== 'PLT_RUNTIME_APPLICATION_WORKER_EXIT' || attempt === attempts - 1) {
1981
+ throw e
1982
+ }
1983
+ }
1714
1984
  }
1715
1985
  }
1716
1986
 
@@ -1797,7 +2067,7 @@ export class Runtime extends EventEmitter {
1797
2067
  return sendViaITC(application, 'runApplicationScheduledTasks', { scheduleId, scheduledTime })
1798
2068
  }
1799
2069
 
1800
- getScheduler () {
2070
+ getSchedulerJobs () {
1801
2071
  return this.#scheduler?.getJobs() ?? []
1802
2072
  }
1803
2073
 
@@ -2299,7 +2569,7 @@ export class Runtime extends EventEmitter {
2299
2569
  // The event only carries metadata: the profile can be retrieved on demand
2300
2570
  // via getApplicationLastProfile. We use emit instead of emitAndNotify since
2301
2571
  // other workers are not interested in this event.
2302
- worker[kITC].on('profile:captured', ({ type, timestamp }) => {
2572
+ worker[kITC].on('profile:captured', ({ type, timestamp, sampleCount }) => {
2303
2573
  // A strictly newer completed window supersedes the preserved overload
2304
2574
  // profile: once the worker is past the overload and producing windows
2305
2575
  // again, its old evidence must not be served anymore.
@@ -2315,7 +2585,8 @@ export class Runtime extends EventEmitter {
2315
2585
  application: applicationId,
2316
2586
  worker: index,
2317
2587
  type,
2318
- timestamp
2588
+ timestamp,
2589
+ sampleCount: sampleCount ?? null
2319
2590
  })
2320
2591
  })
2321
2592
 
@@ -2377,8 +2648,12 @@ export class Runtime extends EventEmitter {
2377
2648
  // the worker can be retrieved (see getApplicationLastProfile) even while
2378
2649
  // the worker event loop is blocked, and it survives the worker being
2379
2650
  // replaced by the health checks.
2380
- worker[kITC].on('profile:overload', ({ type, timestamp, profile }) => {
2381
- this.#lastOverloadProfiles.set(`${workerId}:${type}`, { profile, timestamp })
2651
+ worker[kITC].on('profile:overload', ({ type, timestamp, profile, sampleCount }) => {
2652
+ this.#lastOverloadProfiles.set(`${workerId}:${type}`, {
2653
+ profile,
2654
+ timestamp,
2655
+ sampleCount: sampleCount ?? null
2656
+ })
2382
2657
  })
2383
2658
 
2384
2659
  // Preserved overload profiles outlive their worker only for a grace
@@ -3033,10 +3308,15 @@ export class Runtime extends EventEmitter {
3033
3308
 
3034
3309
  // Always send the stop message, it will shut down workers that only had ITC and interceptors setup
3035
3310
  try {
3036
- await executeWithTimeout(sendViaITC(worker, 'stop', { force: !!this.error, dependents }), exitTimeout)
3311
+ const res = await executeWithTimeout(sendViaITC(worker, 'stop', { force: !!this.error, dependents }), exitTimeout)
3312
+
3313
+ if (res === kTimeout) {
3314
+ this.emitAndNotify('application:worker:stop:timeout', eventPayload)
3315
+ this.logger.error(`Timeout while stopping ${label}. Killing a worker thread.`)
3316
+ }
3037
3317
  } catch (error) {
3038
3318
  this.emitAndNotify('application:worker:stop:error', eventPayload)
3039
- this.logger.info({ error: ensureLoggableError(error) }, `Failed to stop ${label}. Killing a worker thread.`)
3319
+ this.logger.error({ err: ensureLoggableError(error) }, `Failed to stop ${label}. Killing a worker thread.`)
3040
3320
  } finally {
3041
3321
  worker[kITC].notify('application:worker:stop:processed')
3042
3322
  // Wait for the processed message to be received
@@ -3055,6 +3335,7 @@ export class Runtime extends EventEmitter {
3055
3335
  // If the worker didn't exit in time, kill it
3056
3336
  if (res === kTimeout) {
3057
3337
  this.emitAndNotify('application:worker:exit:timeout', eventPayload)
3338
+ this.logger.error(`Timeout while waiting for ${label} to exit. Killing a worker thread.`)
3058
3339
  await worker.terminate()
3059
3340
  }
3060
3341
 
@@ -3279,6 +3560,37 @@ export class Runtime extends EventEmitter {
3279
3560
  return this.#getWorkerByIdOrNext(applicationId, workerId, ensureStarted, mustExist)
3280
3561
  }
3281
3562
 
3563
+ // Profiling start and stop must address the same worker: resolve an id
3564
+ // without an explicit worker index to the first worker deterministically,
3565
+ // as the round-robin used by #getApplicationById would rotate to a
3566
+ // different worker between the two calls.
3567
+ async #getApplicationWorkerForProfiling (applicationId, ensureStarted) {
3568
+ if (hasWorkerIndex(applicationId)) {
3569
+ return this.#getApplicationById(applicationId, ensureStarted)
3570
+ }
3571
+
3572
+ if (!this.#applications.has(applicationId)) {
3573
+ throw new ApplicationNotFoundError(applicationId, this.getApplicationsIds().join(', '))
3574
+ }
3575
+
3576
+ const [firstWorker] = this.#workers.getKeys(applicationId)
3577
+ return this.#getWorkerByIdOrNext(applicationId, firstWorker?.split(':')[1], ensureStarted)
3578
+ }
3579
+
3580
+ async #getApplicationWorkersForProfiling (applicationId, ensureStarted) {
3581
+ if (!this.#applications.has(applicationId)) {
3582
+ throw new ApplicationNotFoundError(applicationId, this.getApplicationsIds().join(', '))
3583
+ }
3584
+
3585
+ const workers = []
3586
+ for (const key of this.#workers.getKeys(applicationId)) {
3587
+ const workerIndex = parseInt(key.split(':')[1], 10)
3588
+ workers.push({ workerIndex, worker: await this.#getWorkerByIdOrNext(applicationId, workerIndex, ensureStarted) })
3589
+ }
3590
+
3591
+ return workers
3592
+ }
3593
+
3282
3594
  // This method can work in two modes: when workerId is provided, it will return the specific worker
3283
3595
  // otherwise it will return the next available worker for the application.
3284
3596
  async #getWorkerByIdOrNext (applicationId, workerId, ensureStarted = false, mustExist = true) {
@@ -3976,18 +4288,30 @@ export class Runtime extends EventEmitter {
3976
4288
  }
3977
4289
 
3978
4290
  const logger = this.logger.child({ name: `extension:${basename(path)}` })
4291
+ const health = this.#createExtensionHealth(path, logger)
4292
+
4293
+ // One registry per extension so metric conflicts and cleanup are isolated.
4294
+ // Extension metrics are main-thread only: Runtime never invents a worker ID.
4295
+ const registry = new metricsClient.Registry()
4296
+ const metrics = { client: metricsClient, registry }
3979
4297
 
3980
4298
  try {
3981
4299
  const instance = await setup({
3982
4300
  runtime: this,
3983
4301
  itc: this.#createExtensionITC(),
4302
+ sharedContext: this.#createExtensionSharedContext(),
3984
4303
  logger,
3985
4304
  options: options ?? {},
3986
- root: this.#root
4305
+ root: this.#root,
4306
+ metrics,
4307
+ health
3987
4308
  })
3988
4309
 
3989
- this.#extensions.push({ path, instance })
4310
+ this.#extensions.push({ path, instance, registry, health, started: false, stopped: false, closed: false })
3990
4311
  } catch (e) {
4312
+ registry.clear()
4313
+ // Drop any health contributions from a failed extension setup.
4314
+ health.cleanup()
3991
4315
  throw new FailedToLoadExtensionError(path, e.message, { cause: e })
3992
4316
  }
3993
4317
  }
@@ -3999,18 +4323,301 @@ export class Runtime extends EventEmitter {
3999
4323
  this.#extensionsWantHealthMetrics = this.listenerCount('application:worker:health:metrics') > 0
4000
4324
  }
4001
4325
 
4326
+ #isApplicationStarted (id) {
4327
+ const applicationConfig = this.#applications.get(id)
4328
+ if (!applicationConfig) {
4329
+ return false
4330
+ }
4331
+
4332
+ const workers = applicationConfig.workers.static
4333
+ for (let i = 0; i < workers; i++) {
4334
+ const worker = this.#workers.get(`${id}:${i}`)
4335
+ const status = worker?.[kWorkerStatus]
4336
+
4337
+ // Match startApplication(): anything past boot/init means already started.
4338
+ if (status && status !== 'boot' && status !== 'init') {
4339
+ return true
4340
+ }
4341
+ }
4342
+
4343
+ return false
4344
+ }
4345
+
4346
+ async #startExtensions () {
4347
+ for (const extension of this.#extensions) {
4348
+ if (extension.started) {
4349
+ continue
4350
+ }
4351
+
4352
+ try {
4353
+ await extension.instance?.start?.()
4354
+ extension.started = true
4355
+ } catch (e) {
4356
+ throw new FailedToStartExtensionError(extension.path, e.message, { cause: e })
4357
+ }
4358
+ }
4359
+ }
4360
+
4361
+ async #stopExtensions () {
4362
+ // Stop in reverse order, so that extensions loaded later, which might depend
4363
+ // on earlier ones, are stopped first. Only extensions that completed start
4364
+ // (including those without a start hook) are stopped, and at most once.
4365
+ for (const extension of [...this.#extensions].reverse()) {
4366
+ if (!extension.started || extension.stopped) {
4367
+ continue
4368
+ }
4369
+
4370
+ // Mark before invoking so repeated stop is idempotent even if stop throws.
4371
+ extension.stopped = true
4372
+
4373
+ try {
4374
+ await extension.instance?.stop?.()
4375
+ } catch (e) {
4376
+ const err = new FailedToStopExtensionError(extension.path, e.message, { cause: e })
4377
+ this.logger.error({ err: ensureLoggableError(err) }, `Failed to stop the extension "${extension.path}".`)
4378
+ }
4379
+ }
4380
+ }
4381
+
4002
4382
  async #closeExtensions () {
4003
4383
  // Close in reverse order, so that extensions loaded later, which might depend
4004
- // on earlier ones, are closed first.
4384
+ // on earlier ones, are closed first. Close is invoked at most once per extension.
4005
4385
  const extensions = this.#extensions.splice(0).reverse()
4006
4386
 
4007
- for (const { path, instance } of extensions) {
4387
+ for (const extension of extensions) {
4388
+ if (extension.closed) {
4389
+ continue
4390
+ }
4391
+
4392
+ extension.closed = true
4393
+
4394
+ try {
4395
+ await extension.instance?.close?.()
4396
+ } catch (e) {
4397
+ this.logger.error(
4398
+ { err: ensureLoggableError(e) },
4399
+ `Failed to close the extension "${extension.path}".`
4400
+ )
4401
+ } finally {
4402
+ // Always drop health contributions with the extension, even if close fails.
4403
+ extension.health?.cleanup?.()
4404
+ }
4405
+
4406
+ // Drop the extension registry after close so its metrics stop appearing in
4407
+ // getMetrics()/exporters and collectors cannot keep running in the background.
4008
4408
  try {
4009
- await instance?.close?.()
4409
+ extension.registry?.clear()
4010
4410
  } catch (e) {
4011
- this.logger.error({ err: ensureLoggableError(e) }, `Failed to close the extension "${path}".`)
4411
+ this.logger.error(
4412
+ { err: ensureLoggableError(e) },
4413
+ `Failed to clear metrics registry for extension "${extension.path}".`
4414
+ )
4415
+ }
4416
+ }
4417
+ }
4418
+
4419
+ #createExtensionSharedContext () {
4420
+ return {
4421
+ // Synchronous on the main thread. Return an isolated snapshot so an
4422
+ // extension cannot mutate the store without going through update(),
4423
+ // which broadcasts changes to workers.
4424
+ get: () => structuredClone(this.getSharedContext()),
4425
+ update: async (update, options = {}) => {
4426
+ // Keep the positional update authoritative, matching the worker API.
4427
+ await this.updateSharedContext({ ...options, context: update })
4428
+ }
4429
+ }
4430
+ }
4431
+
4432
+ #getHealthChecksTimeout () {
4433
+ const metrics = this.#config.metrics
4434
+ if (typeof metrics !== 'object' || metrics === null) {
4435
+ return 5000
4436
+ }
4437
+
4438
+ // Prefer the schema property; keep the historical misspelled key as a fallback.
4439
+ return metrics.healthChecksTimeouts ?? metrics.healthChecksTimeout ?? 5000
4440
+ }
4441
+
4442
+ #assertExtensionHealthRoutesApplied () {
4443
+ const pending = this.#extensionHealthRoutes.filter(entry => entry.active && !entry.applied)
4444
+ if (pending.length > 0) {
4445
+ throw new ExtensionHealthRoutesUnavailableError()
4446
+ }
4447
+ }
4448
+
4449
+ async #runExtensionHealthChecks (checks, kind) {
4450
+ if (checks.size === 0) {
4451
+ return { status: true }
4452
+ }
4453
+
4454
+ const timeout = this.#getHealthChecksTimeout()
4455
+ let response
4456
+
4457
+ for (const [name, entry] of checks) {
4458
+ const result = await this.#runExtensionHealthCheck(name, entry, kind, timeout)
4459
+
4460
+ if (typeof result === 'object' && result !== null) {
4461
+ response = result
4462
+ }
4463
+
4464
+ if (!this.#isExtensionHealthCheckSuccessful(result)) {
4465
+ this.logger.error(
4466
+ { extension: entry.extensionPath, check: name, kind },
4467
+ `Extension ${kind} check "${name}" failed.`
4468
+ )
4469
+ return { status: false, response }
4470
+ }
4471
+ }
4472
+
4473
+ return { status: true, response }
4474
+ }
4475
+
4476
+ async #runExtensionHealthCheck (name, entry, kind, timeout) {
4477
+ try {
4478
+ const result = await executeWithTimeout(
4479
+ Promise.resolve().then(() => entry.check()),
4480
+ timeout,
4481
+ kTimeout
4482
+ )
4483
+
4484
+ if (result === kTimeout) {
4485
+ this.logger.error(
4486
+ { extension: entry.extensionPath, check: name, kind, timeout },
4487
+ `Extension ${kind} check "${name}" timed out.`
4488
+ )
4489
+ return false
4490
+ }
4491
+
4492
+ if (typeof result === 'boolean') {
4493
+ return result
4494
+ }
4495
+
4496
+ if (typeof result === 'object' && result !== null && typeof result.status === 'boolean') {
4497
+ return result
4498
+ }
4499
+
4500
+ this.logger.error(
4501
+ { extension: entry.extensionPath, check: name, kind, result },
4502
+ `Extension ${kind} check "${name}" returned a malformed result.`
4503
+ )
4504
+ return false
4505
+ } catch (err) {
4506
+ this.logger.error(
4507
+ { err: ensureLoggableError(err), extension: entry.extensionPath, check: name, kind },
4508
+ `Extension ${kind} check "${name}" rejected.`
4509
+ )
4510
+ return false
4511
+ }
4512
+ }
4513
+
4514
+ #isExtensionHealthCheckSuccessful (result) {
4515
+ if (typeof result === 'boolean') {
4516
+ return result
4517
+ }
4518
+
4519
+ if (typeof result === 'object' && result !== null) {
4520
+ return !!result.status
4521
+ }
4522
+
4523
+ return false
4524
+ }
4525
+
4526
+ #createExtensionHealth (extensionPath, logger) {
4527
+ const readinessNames = new Set()
4528
+ const livenessNames = new Set()
4529
+ const routeEntries = []
4530
+
4531
+ const registerCheck = (map, names, kind, name, check) => {
4532
+ if (typeof name !== 'string' || name.length === 0) {
4533
+ throw new InvalidArgumentError(`${kind} check name must be a non-empty string`)
4534
+ }
4535
+
4536
+ if (typeof check !== 'function') {
4537
+ throw new InvalidArgumentError(`${kind} check "${name}" must be a function`)
4538
+ }
4539
+
4540
+ const existing = map.get(name)
4541
+ if (existing) {
4542
+ throw new DuplicateExtensionHealthCheckError(kind, name, existing.extensionPath)
4543
+ }
4544
+
4545
+ const entry = { extensionPath, check }
4546
+ map.set(name, entry)
4547
+ names.add(name)
4548
+
4549
+ logger.debug({ check: name, kind }, `Registered extension ${kind} check "${name}"`)
4550
+
4551
+ return () => {
4552
+ const current = map.get(name)
4553
+ if (current === entry) {
4554
+ map.delete(name)
4555
+ }
4556
+ names.delete(name)
4557
+ }
4558
+ }
4559
+
4560
+ const api = {
4561
+ registerReadinessCheck: (name, check) => {
4562
+ return registerCheck(this.#extensionReadinessChecks, readinessNames, 'readiness', name, check)
4563
+ },
4564
+ registerLivenessCheck: (name, check) => {
4565
+ return registerCheck(this.#extensionLivenessChecks, livenessNames, 'liveness', name, check)
4566
+ },
4567
+ registerRoutes: plugin => {
4568
+ if (typeof plugin !== 'function') {
4569
+ throw new InvalidArgumentError('health route plugin must be a function')
4570
+ }
4571
+
4572
+ const entry = {
4573
+ extensionPath,
4574
+ plugin,
4575
+ active: true,
4576
+ applied: false
4577
+ }
4578
+
4579
+ this.#extensionHealthRoutes.push(entry)
4580
+ routeEntries.push(entry)
4581
+
4582
+ logger.debug('Registered extension health routes plugin')
4583
+
4584
+ return () => {
4585
+ entry.active = false
4586
+ const index = this.#extensionHealthRoutes.indexOf(entry)
4587
+ if (index !== -1) {
4588
+ this.#extensionHealthRoutes.splice(index, 1)
4589
+ }
4590
+ }
4591
+ },
4592
+ cleanup: () => {
4593
+ for (const name of readinessNames) {
4594
+ const current = this.#extensionReadinessChecks.get(name)
4595
+ if (current?.extensionPath === extensionPath) {
4596
+ this.#extensionReadinessChecks.delete(name)
4597
+ }
4598
+ }
4599
+ readinessNames.clear()
4600
+
4601
+ for (const name of livenessNames) {
4602
+ const current = this.#extensionLivenessChecks.get(name)
4603
+ if (current?.extensionPath === extensionPath) {
4604
+ this.#extensionLivenessChecks.delete(name)
4605
+ }
4606
+ }
4607
+ livenessNames.clear()
4608
+
4609
+ for (const entry of routeEntries) {
4610
+ entry.active = false
4611
+ const index = this.#extensionHealthRoutes.indexOf(entry)
4612
+ if (index !== -1) {
4613
+ this.#extensionHealthRoutes.splice(index, 1)
4614
+ }
4615
+ }
4616
+ routeEntries.length = 0
4012
4617
  }
4013
4618
  }
4619
+
4620
+ return api
4014
4621
  }
4015
4622
 
4016
4623
  #createExtensionITC () {