@platformatic/runtime 3.63.0-alpha.2 → 3.63.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,
@@ -88,7 +93,6 @@ const kInspectorOptions = Symbol('plt.runtime.worker.inspectorOptions')
88
93
  const kHeapCheckCounter = Symbol('plt.runtime.worker.heapCheckCounter')
89
94
  const kLastHeapStats = Symbol('plt.runtime.worker.lastHeapStats')
90
95
  const kProfilingELUGates = Symbol('plt.runtime.worker.profilingELUGates')
91
- const kWorkerScheduledTasks = Symbol('plt.runtime.worker.scheduledTasks')
92
96
  const kHealthITCTimeoutMs = 5000
93
97
  const kProfilingELUHysteresis = 0.1
94
98
  const kLastProfileTimeoutMs = 10000
@@ -190,6 +194,9 @@ export class Runtime extends EventEmitter {
190
194
  #reservedITCHandlerNames
191
195
  #extensions
192
196
  #extensionsWantHealthMetrics
197
+ #extensionReadinessChecks
198
+ #extensionLivenessChecks
199
+ #extensionHealthRoutes
193
200
  #lastOverloadProfiles
194
201
  #restartingApplications
195
202
  #restartingWorkers
@@ -267,6 +274,9 @@ export class Runtime extends EventEmitter {
267
274
  this.#reservedITCHandlerNames.add('profiling:started')
268
275
  this.#extensions = []
269
276
  this.#extensionsWantHealthMetrics = false
277
+ this.#extensionReadinessChecks = new Map()
278
+ this.#extensionLivenessChecks = new Map()
279
+ this.#extensionHealthRoutes = []
270
280
  this.#lastOverloadProfiles = new Map()
271
281
  this.#sharedContext = {}
272
282
 
@@ -298,12 +308,6 @@ export class Runtime extends EventEmitter {
298
308
  this.#metricsLabelName = 'applicationId'
299
309
  }
300
310
 
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
311
  // Initialize process-level metrics registry in the main thread if metrics or management API is enabled
308
312
  // These metrics are the same across all workers and only need to be collected once
309
313
  // We need this for management API as it can request metrics even without explicit metrics config
@@ -312,7 +316,7 @@ export class Runtime extends EventEmitter {
312
316
  collectProcessMetrics(this.#processMetricsRegistry)
313
317
  }
314
318
 
315
- // Create the logger
319
+ // Create the logger before extensions and health/metrics servers so that both can use it.
316
320
  const [logger, destination, context] = await createLogger(config)
317
321
  this.logger = logger
318
322
  this.#loggerDestination = destination
@@ -333,14 +337,23 @@ export class Runtime extends EventEmitter {
333
337
  }
334
338
 
335
339
  // Load extensions before creating any worker so that custom ITC handlers
336
- // registered by the extensions are available to all workers.
340
+ // registered by the extensions are available to all workers. Also load them
341
+ // before starting the health/metrics servers so extensions can register
342
+ // readiness/liveness checks and probe routes before Fastify starts listening.
337
343
  await this.#loadExtensions()
338
344
 
345
+ if (config.metrics || (typeof config.healthProbes === 'object' && config.healthProbes !== null)) {
346
+ this.#prometheusServer = await startPrometheusServer(this, config.metrics ?? false, config.healthProbes)
347
+ }
348
+
349
+ this.#healthProbesServer = await startHealthProbesServer(this, config.metrics, config.healthProbes)
350
+ this.#assertExtensionHealthRoutesApplied()
351
+
339
352
  await this.addApplications(this.#config.applications)
340
353
  await this.#setDispatcher(config.undici)
341
354
 
342
- if (!this.#context.build) {
343
- this.#scheduler = startScheduler(config.scheduler ?? [], this.#dispatcher, logger)
355
+ if (config.scheduler && !this.#context.build) {
356
+ this.#scheduler = startScheduler(config.scheduler, this.#dispatcher, logger)
344
357
  }
345
358
 
346
359
  this.#updateStatus('init')
@@ -355,10 +368,16 @@ export class Runtime extends EventEmitter {
355
368
  this.#createWorkersBroadcastChannel()
356
369
 
357
370
  try {
358
- const applications = this.getApplicationsIds()
359
- await this.startApplications(applications, silent)
371
+ // Snapshot originally configured application IDs before extension start hooks.
372
+ // Dynamic applications started by an extension are excluded from the normal startup pass.
373
+ const configuredApplications = this.getApplicationsIds()
374
+
375
+ await this.#startExtensions()
360
376
 
361
- if (applications.length === 0) {
377
+ const applicationsToStart = configuredApplications.filter(id => !this.#isApplicationStarted(id))
378
+ await this.startApplications(applicationsToStart, silent)
379
+
380
+ if (this.getApplicationsIds().length === 0) {
362
381
  this.#updateStatus('started')
363
382
  await this.close(silent)
364
383
  return
@@ -419,6 +438,15 @@ export class Runtime extends EventEmitter {
419
438
  await once(this, 'started')
420
439
  }
421
440
 
441
+ if (this.#status === 'stopping') {
442
+ await once(this, 'stopped')
443
+ return
444
+ }
445
+
446
+ if (this.#status === 'stopped' || this.#status === 'closing' || this.#status === 'closed') {
447
+ return
448
+ }
449
+
422
450
  this.#updateStatus('stopping')
423
451
 
424
452
  if (this.#scheduler) {
@@ -436,6 +464,10 @@ export class Runtime extends EventEmitter {
436
464
  await this.stopApplication(this.#entrypointId, silent)
437
465
  }
438
466
 
467
+ // Await extension stop hooks before stopping remaining applications so that
468
+ // control-plane extensions can settle work and hand off state first.
469
+ await this.#stopExtensions()
470
+
439
471
  await this.stopApplications(this.getApplicationsIds(), silent)
440
472
 
441
473
  await this.#meshInterceptor.close()
@@ -461,6 +493,15 @@ export class Runtime extends EventEmitter {
461
493
  }
462
494
 
463
495
  async close (silent = false) {
496
+ if (this.#status === 'closing') {
497
+ await once(this, 'closed')
498
+ return
499
+ }
500
+
501
+ if (this.#status === 'closed') {
502
+ return
503
+ }
504
+
464
505
  clearTimeout(this.#healthMetricsTimer)
465
506
  this.#healthMetricsCollectionActive = false
466
507
  this.#lastOverloadProfiles.clear()
@@ -652,7 +693,13 @@ export class Runtime extends EventEmitter {
652
693
 
653
694
  const removed = []
654
695
  for (const application of applications) {
655
- const details = await this.getApplicationDetails(application)
696
+ if (!this.#applications.has(application)) {
697
+ throw new ApplicationNotFoundError(application, this.getApplicationsIds().join(', '))
698
+ }
699
+
700
+ // Use allowUnloaded so that applications without a live worker
701
+ // (stopped or crashed with restartOnError: 0) can still be removed.
702
+ const details = await this.getApplicationDetails(application, true)
656
703
  details.status = 'removed'
657
704
  removed.push(details)
658
705
  }
@@ -661,7 +708,6 @@ export class Runtime extends EventEmitter {
661
708
 
662
709
  for (const application of applications) {
663
710
  this.#dynamicWorkersScaler?.remove(application)
664
- await this.#scheduler?.removeApplicationJobs(application)
665
711
  this.#applications.delete(application)
666
712
  this.#applicationRestartCounts.delete(application)
667
713
  }
@@ -770,9 +816,8 @@ export class Runtime extends EventEmitter {
770
816
  await this.#startWorker(config, applicationConfig, workers, id, i, silent)
771
817
  }
772
818
 
773
- await this.#registerApplicationSchedulerJobs(id)
774
-
775
819
  this.emitAndNotify('application:started', id)
820
+ await this.#dynamicWorkersScaler?.applyPendingUpdate(id)
776
821
  }
777
822
 
778
823
  async stopApplication (id, silent = false, dependents = []) {
@@ -784,7 +829,6 @@ export class Runtime extends EventEmitter {
784
829
  const workersCount = workersIds.length
785
830
 
786
831
  this.emitAndNotify('application:stopping', id)
787
- await this.#scheduler?.stopApplicationJobs(id)
788
832
 
789
833
  if (typeof workersCount === 'number') {
790
834
  const stopInvocations = []
@@ -838,7 +882,6 @@ export class Runtime extends EventEmitter {
838
882
  await this.#replaceWorker(config, applicationConfig, workersCount, id, workerIndex, worker, true)
839
883
  }
840
884
 
841
- await this.#registerApplicationSchedulerJobs(id)
842
885
  this.#incrementApplicationRestartCount(id)
843
886
  this.emitAndNotify('application:restarted', id)
844
887
  } finally {
@@ -1053,9 +1096,15 @@ export class Runtime extends EventEmitter {
1053
1096
  this.#config.metrics = metricsConfig
1054
1097
  this.#metricsLabelName = metricsConfig?.applicationLabel || 'applicationId'
1055
1098
 
1099
+ // Allow extension health routes to be re-applied on the restarted servers.
1100
+ for (const entry of this.#extensionHealthRoutes) {
1101
+ entry.applied = false
1102
+ }
1103
+
1056
1104
  this.#prometheusServer = await startPrometheusServer(this, metricsConfig, this.#config.healthProbes)
1057
1105
 
1058
1106
  this.#healthProbesServer = await startHealthProbesServer(this, metricsConfig, this.#config.healthProbes)
1107
+ this.#assertExtensionHealthRoutesApplied()
1059
1108
 
1060
1109
  await this.#startOpenTelemetryMetricsForwarder(metricsConfig?.opentelemetry)
1061
1110
 
@@ -1362,7 +1411,7 @@ export class Runtime extends EventEmitter {
1362
1411
  undefined,
1363
1412
  [],
1364
1413
  this.#concurrency,
1365
- this.#config.metrics.healthChecksTimeout,
1414
+ this.#getHealthChecksTimeout(),
1366
1415
  {}
1367
1416
  )
1368
1417
  }
@@ -1383,11 +1432,23 @@ export class Runtime extends EventEmitter {
1383
1432
  undefined,
1384
1433
  [],
1385
1434
  this.#concurrency,
1386
- this.#config.metrics.healthChecksTimeout,
1435
+ this.#getHealthChecksTimeout(),
1387
1436
  {}
1388
1437
  )
1389
1438
  }
1390
1439
 
1440
+ getExtensionHealthRoutes () {
1441
+ return this.#extensionHealthRoutes
1442
+ }
1443
+
1444
+ async runExtensionReadinessChecks () {
1445
+ return this.#runExtensionHealthChecks(this.#extensionReadinessChecks, 'readiness')
1446
+ }
1447
+
1448
+ async runExtensionLivenessChecks () {
1449
+ return this.#runExtensionHealthChecks(this.#extensionLivenessChecks, 'liveness')
1450
+ }
1451
+
1391
1452
  async getMetrics (format = 'json') {
1392
1453
  if (this.#config.metrics === false || this.#config.metrics?.enabled === false) {
1393
1454
  throw new Error('Metrics are disabled')
@@ -1403,6 +1464,15 @@ export class Runtime extends EventEmitter {
1403
1464
  processMetricsJson = await this.#processMetricsRegistry.getMetricsAsJSON()
1404
1465
  }
1405
1466
 
1467
+ // Collect main-thread extension metrics once. Each extension has its own
1468
+ // registry so metric registration and cleanup stay isolated. Collisions
1469
+ // with other extensions, process metrics, restart metrics, or worker
1470
+ // metrics fail with a coded error identifying the extension and family.
1471
+ const extensionMetrics = await this.#getExtensionMetricsJson({
1472
+ processMetricsJson,
1473
+ applicationRestartMetrics
1474
+ })
1475
+
1406
1476
  for (const worker of this.#workers.values()) {
1407
1477
  try {
1408
1478
  // The application might be temporarily unavailable
@@ -1421,20 +1491,6 @@ export class Runtime extends EventEmitter {
1421
1491
  if (applicationMetrics && applicationMetrics !== kTimeout) {
1422
1492
  metrics ??= []
1423
1493
 
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
1494
  // Add worker's thread-specific metrics
1439
1495
  for (let i = 0; i < applicationMetrics.length; i++) {
1440
1496
  metrics.push(applicationMetrics[i])
@@ -1454,6 +1510,49 @@ export class Runtime extends EventEmitter {
1454
1510
  }
1455
1511
  }
1456
1512
 
1513
+ // Extension metrics must not share a family name with any worker metric.
1514
+ if (metrics !== null && extensionMetrics.length > 0) {
1515
+ const workerMetricNames = new Set()
1516
+ for (let i = 0; i < metrics.length; i++) {
1517
+ workerMetricNames.add(metrics[i].name)
1518
+ }
1519
+
1520
+ for (const { path, metricNames } of extensionMetrics) {
1521
+ for (const name of metricNames) {
1522
+ if (workerMetricNames.has(name)) {
1523
+ throw new MetricFamilyCollisionError(path, name, 'application worker metrics')
1524
+ }
1525
+ }
1526
+ }
1527
+ }
1528
+
1529
+ if (extensionMetrics.length > 0) {
1530
+ metrics ??= []
1531
+ const extensionMetricsJson = []
1532
+ for (const { metrics: extensionMetricList } of extensionMetrics) {
1533
+ extensionMetricsJson.push(...extensionMetricList)
1534
+ }
1535
+ metrics = [...extensionMetricsJson, ...metrics]
1536
+ }
1537
+
1538
+ // Report process-level metrics (e.g. process_resident_memory_bytes) only once:
1539
+ // they describe the whole runtime process, so replicating them for each
1540
+ // application running in a worker thread would just duplicate the same value.
1541
+ // Applications running as separate OS processes report their own process-level
1542
+ // metrics, with their own labels, as part of their thread metrics above.
1543
+ // See https://github.com/platformatic/platformatic/issues/3332.
1544
+ if (metrics !== null && processMetricsJson) {
1545
+ const processMetrics = []
1546
+ // Drop any configured custom label that shares the name of the application
1547
+ // label (a config can set both `applicationLabel: 'serviceId'` and a static
1548
+ // `serviceId` label): keeping it would make these runtime-wide metrics look
1549
+ // like they belong to an application, both here and in getFormattedMetrics().
1550
+ const processLabels = { ...this.#config.metrics?.labels }
1551
+ delete processLabels[this.#metricsLabelName]
1552
+ this.#applyLabelsToMetrics(processMetricsJson, processLabels, processMetrics)
1553
+ metrics = [...processMetrics, ...metrics]
1554
+ }
1555
+
1457
1556
  if (metrics !== null && applicationRestartMetrics.length > 0) {
1458
1557
  metrics = [...applicationRestartMetrics, ...metrics]
1459
1558
  }
@@ -1465,6 +1564,57 @@ export class Runtime extends EventEmitter {
1465
1564
  return { metrics }
1466
1565
  }
1467
1566
 
1567
+ async #getExtensionMetricsJson ({ processMetricsJson, applicationRestartMetrics }) {
1568
+ const results = []
1569
+ const metricSources = new Map()
1570
+
1571
+ // Static labels from metrics config apply, but Runtime never invents a
1572
+ // worker ID or application ID for main-thread extension metrics.
1573
+ const extensionLabels = typeof this.#config.metrics === 'object' && this.#config.metrics
1574
+ ? { ...this.#config.metrics.labels }
1575
+ : {}
1576
+ delete extensionLabels[this.#metricsLabelName]
1577
+
1578
+ const processMetricNames = new Set((processMetricsJson ?? []).map(metric => metric.name))
1579
+ const restartMetricNames = new Set((applicationRestartMetrics ?? []).map(metric => metric.name))
1580
+
1581
+ for (const { path, registry } of this.#extensions) {
1582
+ if (!registry) {
1583
+ continue
1584
+ }
1585
+
1586
+ const registryMetrics = await registry.getMetricsAsJSON()
1587
+ if (!registryMetrics || registryMetrics.length === 0) {
1588
+ continue
1589
+ }
1590
+
1591
+ const metricNames = []
1592
+ for (const metric of registryMetrics) {
1593
+ const existing = metricSources.get(metric.name)
1594
+ if (existing) {
1595
+ throw new MetricFamilyCollisionError(path, metric.name, `extension "${existing}"`)
1596
+ }
1597
+
1598
+ if (processMetricNames.has(metric.name)) {
1599
+ throw new MetricFamilyCollisionError(path, metric.name, 'runtime process metrics')
1600
+ }
1601
+
1602
+ if (restartMetricNames.has(metric.name)) {
1603
+ throw new MetricFamilyCollisionError(path, metric.name, 'runtime restart metrics')
1604
+ }
1605
+
1606
+ metricSources.set(metric.name, path)
1607
+ metricNames.push(metric.name)
1608
+ }
1609
+
1610
+ const labeledMetrics = []
1611
+ this.#applyLabelsToMetrics(registryMetrics, extensionLabels, labeledMetrics)
1612
+ results.push({ path, metrics: labeledMetrics, metricNames })
1613
+ }
1614
+
1615
+ return results
1616
+ }
1617
+
1468
1618
  #incrementApplicationRestartCount (applicationId) {
1469
1619
  this.#applicationRestartCounts.set(applicationId, (this.#applicationRestartCounts.get(applicationId) ?? 0) + 1)
1470
1620
  }
@@ -1581,6 +1731,13 @@ export class Runtime extends EventEmitter {
1581
1731
 
1582
1732
  const applicationsMetrics = {}
1583
1733
 
1734
+ // Process-level metrics are reported only once for the whole runtime, without
1735
+ // an application label, since they are shared by all the applications running
1736
+ // in worker threads (see issue #3332). Applications running as separate OS
1737
+ // processes report their own labeled values, which take precedence below.
1738
+ const runtimeProcessMetrics = {}
1739
+ const applicationProcessMetrics = new Set()
1740
+
1584
1741
  for (const metric of metrics) {
1585
1742
  const { name, values } = metric
1586
1743
 
@@ -1592,9 +1749,22 @@ export class Runtime extends EventEmitter {
1592
1749
  const applicationId = labels?.[this.#metricsLabelName]
1593
1750
 
1594
1751
  if (!applicationId) {
1752
+ if (name === 'process_cpu_percent_usage') {
1753
+ runtimeProcessMetrics.cpu = values[0].value
1754
+ continue
1755
+ }
1756
+ if (name === 'process_resident_memory_bytes') {
1757
+ runtimeProcessMetrics.rss = values[0].value
1758
+ continue
1759
+ }
1760
+
1595
1761
  throw new Error(`Missing ${this.#metricsLabelName} label in metrics`)
1596
1762
  }
1597
1763
 
1764
+ if (name === 'process_cpu_percent_usage' || name === 'process_resident_memory_bytes') {
1765
+ applicationProcessMetrics.add(`${applicationId}:${name}`)
1766
+ }
1767
+
1598
1768
  let applicationMetrics = applicationsMetrics[applicationId]
1599
1769
  if (!applicationMetrics) {
1600
1770
  applicationMetrics = {
@@ -1618,6 +1788,24 @@ export class Runtime extends EventEmitter {
1618
1788
  parsePromMetric(applicationMetrics, metric)
1619
1789
  }
1620
1790
 
1791
+ // Apply the runtime-wide process-level values to every application that did
1792
+ // not report its own (i.e. every application running in a worker thread).
1793
+ for (const [applicationId, applicationMetrics] of Object.entries(applicationsMetrics)) {
1794
+ if (
1795
+ runtimeProcessMetrics.cpu !== undefined &&
1796
+ !applicationProcessMetrics.has(`${applicationId}:process_cpu_percent_usage`)
1797
+ ) {
1798
+ applicationMetrics.cpu = runtimeProcessMetrics.cpu
1799
+ }
1800
+
1801
+ if (
1802
+ runtimeProcessMetrics.rss !== undefined &&
1803
+ !applicationProcessMetrics.has(`${applicationId}:process_resident_memory_bytes`)
1804
+ ) {
1805
+ applicationMetrics.rss = runtimeProcessMetrics.rss
1806
+ }
1807
+ }
1808
+
1621
1809
  function parsePromMetric (applicationMetrics, promMetric) {
1622
1810
  const { name } = promMetric
1623
1811
 
@@ -1785,79 +1973,6 @@ export class Runtime extends EventEmitter {
1785
1973
  return sendViaITC(application, 'getApplicationGraphQLSchema')
1786
1974
  }
1787
1975
 
1788
- async getApplicationScheduledTasks (id) {
1789
- const application = await this.#getApplicationById(id, true)
1790
-
1791
- return sendViaITC(application, 'getApplicationScheduledTasks')
1792
- }
1793
-
1794
- async runApplicationScheduledTasks (id, scheduleId, scheduledTime) {
1795
- const application = await this.#getApplicationById(id, true)
1796
-
1797
- return sendViaITC(application, 'runApplicationScheduledTasks', { scheduleId, scheduledTime })
1798
- }
1799
-
1800
- getScheduler () {
1801
- return this.#scheduler?.getJobs() ?? []
1802
- }
1803
-
1804
- pauseSchedulerJob (name) {
1805
- return this.#schedulerOrThrow().pauseJob(name)
1806
- }
1807
-
1808
- resumeSchedulerJob (name) {
1809
- return this.#schedulerOrThrow().resumeJob(name)
1810
- }
1811
-
1812
- runSchedulerJob (name) {
1813
- return this.#schedulerOrThrow().runJob(name)
1814
- }
1815
-
1816
- #schedulerOrThrow () {
1817
- if (!this.#scheduler) {
1818
- throw new Error('The scheduler is not configured')
1819
- }
1820
-
1821
- return this.#scheduler
1822
- }
1823
-
1824
- async #registerApplicationSchedulerJobs (id) {
1825
- if (!this.#scheduler) {
1826
- return
1827
- }
1828
-
1829
- const pausedJobs = new Set(
1830
- this.#scheduler
1831
- .getJobs()
1832
- .filter(job => job.applicationId === id && job.paused)
1833
- .map(job => job.name)
1834
- )
1835
-
1836
- await this.#scheduler.removeApplicationJobs(id)
1837
-
1838
- const workerId = this.#workers.getKeys(id)[0]
1839
- const schedules = this.#workers.get(workerId)?.[kWorkerScheduledTasks] ?? []
1840
- for (const schedule of schedules) {
1841
- const name = `${id}:${schedule.id}`
1842
- this.#scheduler.addJob(
1843
- {
1844
- name,
1845
- cron: schedule.cron,
1846
- source: 'application',
1847
- applicationId: id,
1848
- scheduleId: schedule.id,
1849
- tasks: schedule.tasks,
1850
- maxRetries: 3
1851
- },
1852
- ({ scheduledTime }) => this.runApplicationScheduledTasks(id, schedule.id, scheduledTime)
1853
- )
1854
-
1855
- if (pausedJobs.has(name)) {
1856
- await this.#scheduler.pauseJob(name)
1857
- }
1858
- }
1859
- }
1860
-
1861
1976
  async getWorkers (includeRaw = false) {
1862
1977
  const status = {}
1863
1978
 
@@ -2885,24 +3000,22 @@ export class Runtime extends EventEmitter {
2885
3000
  this.emitAndNotify('application:worker:starting', eventPayload)
2886
3001
 
2887
3002
  try {
2888
- let workerStartResult
3003
+ let workerUrl
2889
3004
  if (config.startTimeout > 0) {
2890
- workerStartResult = await executeWithTimeout(sendViaITC(worker, 'start'), config.startTimeout)
3005
+ workerUrl = await executeWithTimeout(sendViaITC(worker, 'start'), config.startTimeout)
2891
3006
 
2892
- if (workerStartResult === kTimeout) {
3007
+ if (workerUrl === kTimeout) {
2893
3008
  this.emitAndNotify('application:worker:startTimeout', eventPayload)
2894
3009
  this.logger.error(`The ${label} failed to start in ${config.startTimeout}ms. Forcefully killing the thread.`)
2895
3010
  worker.terminate()
2896
3011
  throw new ApplicationStartTimeoutError(id, config.startTimeout)
2897
3012
  }
2898
3013
  } else {
2899
- workerStartResult = await sendViaITC(worker, 'start')
3014
+ workerUrl = await sendViaITC(worker, 'start')
2900
3015
  }
2901
3016
 
2902
3017
  await this.#avoidOutOfOrderThreadLogs()
2903
3018
 
2904
- const { url: workerUrl, scheduledTasks } = workerStartResult
2905
- worker[kWorkerScheduledTasks] = scheduledTasks
2906
3019
  if (workerUrl) {
2907
3020
  this.#url = workerUrl
2908
3021
 
@@ -3033,10 +3146,15 @@ export class Runtime extends EventEmitter {
3033
3146
 
3034
3147
  // Always send the stop message, it will shut down workers that only had ITC and interceptors setup
3035
3148
  try {
3036
- await executeWithTimeout(sendViaITC(worker, 'stop', { force: !!this.error, dependents }), exitTimeout)
3149
+ const res = await executeWithTimeout(sendViaITC(worker, 'stop', { force: !!this.error, dependents }), exitTimeout)
3150
+
3151
+ if (res === kTimeout) {
3152
+ this.emitAndNotify('application:worker:stop:timeout', eventPayload)
3153
+ this.logger.error(`Timeout while stopping ${label}. Killing a worker thread.`)
3154
+ }
3037
3155
  } catch (error) {
3038
3156
  this.emitAndNotify('application:worker:stop:error', eventPayload)
3039
- this.logger.info({ error: ensureLoggableError(error) }, `Failed to stop ${label}. Killing a worker thread.`)
3157
+ this.logger.error({ err: ensureLoggableError(error) }, `Failed to stop ${label}. Killing a worker thread.`)
3040
3158
  } finally {
3041
3159
  worker[kITC].notify('application:worker:stop:processed')
3042
3160
  // Wait for the processed message to be received
@@ -3055,6 +3173,7 @@ export class Runtime extends EventEmitter {
3055
3173
  // If the worker didn't exit in time, kill it
3056
3174
  if (res === kTimeout) {
3057
3175
  this.emitAndNotify('application:worker:exit:timeout', eventPayload)
3176
+ this.logger.error(`Timeout while waiting for ${label} to exit. Killing a worker thread.`)
3058
3177
  await worker.terminate()
3059
3178
  }
3060
3179
 
@@ -3219,17 +3338,7 @@ export class Runtime extends EventEmitter {
3219
3338
  this.#workers.set(newWorkerId, newWorker)
3220
3339
 
3221
3340
  // Add the worker to the mesh
3222
- await this.#startWorker(
3223
- configForNewWorker,
3224
- applicationConfig,
3225
- workersCount,
3226
- applicationId,
3227
- newIndex,
3228
- false,
3229
- 0,
3230
- newWorker,
3231
- true
3232
- )
3341
+ await this.#startWorker(configForNewWorker, applicationConfig, workersCount, applicationId, newIndex, false, 0, newWorker, true)
3233
3342
 
3234
3343
  // Make sure the runtime hasn't been stopped in the meanwhile
3235
3344
  if (this.#status !== 'started') {
@@ -3976,18 +4085,30 @@ export class Runtime extends EventEmitter {
3976
4085
  }
3977
4086
 
3978
4087
  const logger = this.logger.child({ name: `extension:${basename(path)}` })
4088
+ const health = this.#createExtensionHealth(path, logger)
4089
+
4090
+ // One registry per extension so metric conflicts and cleanup are isolated.
4091
+ // Extension metrics are main-thread only: Runtime never invents a worker ID.
4092
+ const registry = new metricsClient.Registry()
4093
+ const metrics = { client: metricsClient, registry }
3979
4094
 
3980
4095
  try {
3981
4096
  const instance = await setup({
3982
4097
  runtime: this,
3983
4098
  itc: this.#createExtensionITC(),
4099
+ sharedContext: this.#createExtensionSharedContext(),
3984
4100
  logger,
3985
4101
  options: options ?? {},
3986
- root: this.#root
4102
+ root: this.#root,
4103
+ metrics,
4104
+ health
3987
4105
  })
3988
4106
 
3989
- this.#extensions.push({ path, instance })
4107
+ this.#extensions.push({ path, instance, registry, health, started: false, stopped: false, closed: false })
3990
4108
  } catch (e) {
4109
+ registry.clear()
4110
+ // Drop any health contributions from a failed extension setup.
4111
+ health.cleanup()
3991
4112
  throw new FailedToLoadExtensionError(path, e.message, { cause: e })
3992
4113
  }
3993
4114
  }
@@ -3999,18 +4120,301 @@ export class Runtime extends EventEmitter {
3999
4120
  this.#extensionsWantHealthMetrics = this.listenerCount('application:worker:health:metrics') > 0
4000
4121
  }
4001
4122
 
4123
+ #isApplicationStarted (id) {
4124
+ const applicationConfig = this.#applications.get(id)
4125
+ if (!applicationConfig) {
4126
+ return false
4127
+ }
4128
+
4129
+ const workers = applicationConfig.workers.static
4130
+ for (let i = 0; i < workers; i++) {
4131
+ const worker = this.#workers.get(`${id}:${i}`)
4132
+ const status = worker?.[kWorkerStatus]
4133
+
4134
+ // Match startApplication(): anything past boot/init means already started.
4135
+ if (status && status !== 'boot' && status !== 'init') {
4136
+ return true
4137
+ }
4138
+ }
4139
+
4140
+ return false
4141
+ }
4142
+
4143
+ async #startExtensions () {
4144
+ for (const extension of this.#extensions) {
4145
+ if (extension.started) {
4146
+ continue
4147
+ }
4148
+
4149
+ try {
4150
+ await extension.instance?.start?.()
4151
+ extension.started = true
4152
+ } catch (e) {
4153
+ throw new FailedToStartExtensionError(extension.path, e.message, { cause: e })
4154
+ }
4155
+ }
4156
+ }
4157
+
4158
+ async #stopExtensions () {
4159
+ // Stop in reverse order, so that extensions loaded later, which might depend
4160
+ // on earlier ones, are stopped first. Only extensions that completed start
4161
+ // (including those without a start hook) are stopped, and at most once.
4162
+ for (const extension of [...this.#extensions].reverse()) {
4163
+ if (!extension.started || extension.stopped) {
4164
+ continue
4165
+ }
4166
+
4167
+ // Mark before invoking so repeated stop is idempotent even if stop throws.
4168
+ extension.stopped = true
4169
+
4170
+ try {
4171
+ await extension.instance?.stop?.()
4172
+ } catch (e) {
4173
+ const err = new FailedToStopExtensionError(extension.path, e.message, { cause: e })
4174
+ this.logger.error({ err: ensureLoggableError(err) }, `Failed to stop the extension "${extension.path}".`)
4175
+ }
4176
+ }
4177
+ }
4178
+
4002
4179
  async #closeExtensions () {
4003
4180
  // Close in reverse order, so that extensions loaded later, which might depend
4004
- // on earlier ones, are closed first.
4181
+ // on earlier ones, are closed first. Close is invoked at most once per extension.
4005
4182
  const extensions = this.#extensions.splice(0).reverse()
4006
4183
 
4007
- for (const { path, instance } of extensions) {
4184
+ for (const extension of extensions) {
4185
+ if (extension.closed) {
4186
+ continue
4187
+ }
4188
+
4189
+ extension.closed = true
4190
+
4008
4191
  try {
4009
- await instance?.close?.()
4192
+ await extension.instance?.close?.()
4010
4193
  } catch (e) {
4011
- this.logger.error({ err: ensureLoggableError(e) }, `Failed to close the extension "${path}".`)
4194
+ this.logger.error(
4195
+ { err: ensureLoggableError(e) },
4196
+ `Failed to close the extension "${extension.path}".`
4197
+ )
4198
+ } finally {
4199
+ // Always drop health contributions with the extension, even if close fails.
4200
+ extension.health?.cleanup?.()
4201
+ }
4202
+
4203
+ // Drop the extension registry after close so its metrics stop appearing in
4204
+ // getMetrics()/exporters and collectors cannot keep running in the background.
4205
+ try {
4206
+ extension.registry?.clear()
4207
+ } catch (e) {
4208
+ this.logger.error(
4209
+ { err: ensureLoggableError(e) },
4210
+ `Failed to clear metrics registry for extension "${extension.path}".`
4211
+ )
4212
+ }
4213
+ }
4214
+ }
4215
+
4216
+ #createExtensionSharedContext () {
4217
+ return {
4218
+ // Synchronous on the main thread. Return an isolated snapshot so an
4219
+ // extension cannot mutate the store without going through update(),
4220
+ // which broadcasts changes to workers.
4221
+ get: () => structuredClone(this.getSharedContext()),
4222
+ update: async (update, options = {}) => {
4223
+ // Keep the positional update authoritative, matching the worker API.
4224
+ await this.updateSharedContext({ ...options, context: update })
4225
+ }
4226
+ }
4227
+ }
4228
+
4229
+ #getHealthChecksTimeout () {
4230
+ const metrics = this.#config.metrics
4231
+ if (typeof metrics !== 'object' || metrics === null) {
4232
+ return 5000
4233
+ }
4234
+
4235
+ // Prefer the schema property; keep the historical misspelled key as a fallback.
4236
+ return metrics.healthChecksTimeouts ?? metrics.healthChecksTimeout ?? 5000
4237
+ }
4238
+
4239
+ #assertExtensionHealthRoutesApplied () {
4240
+ const pending = this.#extensionHealthRoutes.filter(entry => entry.active && !entry.applied)
4241
+ if (pending.length > 0) {
4242
+ throw new ExtensionHealthRoutesUnavailableError()
4243
+ }
4244
+ }
4245
+
4246
+ async #runExtensionHealthChecks (checks, kind) {
4247
+ if (checks.size === 0) {
4248
+ return { status: true }
4249
+ }
4250
+
4251
+ const timeout = this.#getHealthChecksTimeout()
4252
+ let response
4253
+
4254
+ for (const [name, entry] of checks) {
4255
+ const result = await this.#runExtensionHealthCheck(name, entry, kind, timeout)
4256
+
4257
+ if (typeof result === 'object' && result !== null) {
4258
+ response = result
4259
+ }
4260
+
4261
+ if (!this.#isExtensionHealthCheckSuccessful(result)) {
4262
+ this.logger.error(
4263
+ { extension: entry.extensionPath, check: name, kind },
4264
+ `Extension ${kind} check "${name}" failed.`
4265
+ )
4266
+ return { status: false, response }
4267
+ }
4268
+ }
4269
+
4270
+ return { status: true, response }
4271
+ }
4272
+
4273
+ async #runExtensionHealthCheck (name, entry, kind, timeout) {
4274
+ try {
4275
+ const result = await executeWithTimeout(
4276
+ Promise.resolve().then(() => entry.check()),
4277
+ timeout,
4278
+ kTimeout
4279
+ )
4280
+
4281
+ if (result === kTimeout) {
4282
+ this.logger.error(
4283
+ { extension: entry.extensionPath, check: name, kind, timeout },
4284
+ `Extension ${kind} check "${name}" timed out.`
4285
+ )
4286
+ return false
4287
+ }
4288
+
4289
+ if (typeof result === 'boolean') {
4290
+ return result
4291
+ }
4292
+
4293
+ if (typeof result === 'object' && result !== null && typeof result.status === 'boolean') {
4294
+ return result
4295
+ }
4296
+
4297
+ this.logger.error(
4298
+ { extension: entry.extensionPath, check: name, kind, result },
4299
+ `Extension ${kind} check "${name}" returned a malformed result.`
4300
+ )
4301
+ return false
4302
+ } catch (err) {
4303
+ this.logger.error(
4304
+ { err: ensureLoggableError(err), extension: entry.extensionPath, check: name, kind },
4305
+ `Extension ${kind} check "${name}" rejected.`
4306
+ )
4307
+ return false
4308
+ }
4309
+ }
4310
+
4311
+ #isExtensionHealthCheckSuccessful (result) {
4312
+ if (typeof result === 'boolean') {
4313
+ return result
4314
+ }
4315
+
4316
+ if (typeof result === 'object' && result !== null) {
4317
+ return !!result.status
4318
+ }
4319
+
4320
+ return false
4321
+ }
4322
+
4323
+ #createExtensionHealth (extensionPath, logger) {
4324
+ const readinessNames = new Set()
4325
+ const livenessNames = new Set()
4326
+ const routeEntries = []
4327
+
4328
+ const registerCheck = (map, names, kind, name, check) => {
4329
+ if (typeof name !== 'string' || name.length === 0) {
4330
+ throw new InvalidArgumentError(`${kind} check name must be a non-empty string`)
4331
+ }
4332
+
4333
+ if (typeof check !== 'function') {
4334
+ throw new InvalidArgumentError(`${kind} check "${name}" must be a function`)
4335
+ }
4336
+
4337
+ const existing = map.get(name)
4338
+ if (existing) {
4339
+ throw new DuplicateExtensionHealthCheckError(kind, name, existing.extensionPath)
4340
+ }
4341
+
4342
+ const entry = { extensionPath, check }
4343
+ map.set(name, entry)
4344
+ names.add(name)
4345
+
4346
+ logger.debug({ check: name, kind }, `Registered extension ${kind} check "${name}"`)
4347
+
4348
+ return () => {
4349
+ const current = map.get(name)
4350
+ if (current === entry) {
4351
+ map.delete(name)
4352
+ }
4353
+ names.delete(name)
4012
4354
  }
4013
4355
  }
4356
+
4357
+ const api = {
4358
+ registerReadinessCheck: (name, check) => {
4359
+ return registerCheck(this.#extensionReadinessChecks, readinessNames, 'readiness', name, check)
4360
+ },
4361
+ registerLivenessCheck: (name, check) => {
4362
+ return registerCheck(this.#extensionLivenessChecks, livenessNames, 'liveness', name, check)
4363
+ },
4364
+ registerRoutes: plugin => {
4365
+ if (typeof plugin !== 'function') {
4366
+ throw new InvalidArgumentError('health route plugin must be a function')
4367
+ }
4368
+
4369
+ const entry = {
4370
+ extensionPath,
4371
+ plugin,
4372
+ active: true,
4373
+ applied: false
4374
+ }
4375
+
4376
+ this.#extensionHealthRoutes.push(entry)
4377
+ routeEntries.push(entry)
4378
+
4379
+ logger.debug('Registered extension health routes plugin')
4380
+
4381
+ return () => {
4382
+ entry.active = false
4383
+ const index = this.#extensionHealthRoutes.indexOf(entry)
4384
+ if (index !== -1) {
4385
+ this.#extensionHealthRoutes.splice(index, 1)
4386
+ }
4387
+ }
4388
+ },
4389
+ cleanup: () => {
4390
+ for (const name of readinessNames) {
4391
+ const current = this.#extensionReadinessChecks.get(name)
4392
+ if (current?.extensionPath === extensionPath) {
4393
+ this.#extensionReadinessChecks.delete(name)
4394
+ }
4395
+ }
4396
+ readinessNames.clear()
4397
+
4398
+ for (const name of livenessNames) {
4399
+ const current = this.#extensionLivenessChecks.get(name)
4400
+ if (current?.extensionPath === extensionPath) {
4401
+ this.#extensionLivenessChecks.delete(name)
4402
+ }
4403
+ }
4404
+ livenessNames.clear()
4405
+
4406
+ for (const entry of routeEntries) {
4407
+ entry.active = false
4408
+ const index = this.#extensionHealthRoutes.indexOf(entry)
4409
+ if (index !== -1) {
4410
+ this.#extensionHealthRoutes.splice(index, 1)
4411
+ }
4412
+ }
4413
+ routeEntries.length = 0
4414
+ }
4415
+ }
4416
+
4417
+ return api
4014
4418
  }
4015
4419
 
4016
4420
  #createExtensionITC () {