@platformatic/runtime 3.63.0-alpha.3 → 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/config.d.ts +4 -0
- package/index.d.ts +138 -45
- package/lib/errors.js +30 -11
- package/lib/management-api.js +0 -17
- package/lib/management-handlers.js +0 -20
- package/lib/prom-server.js +81 -8
- package/lib/runtime.js +538 -134
- package/lib/scheduler.js +60 -192
- package/lib/worker/controller.js +4 -2
- package/lib/worker/itc.js +38 -44
- package/lib/worker/main.js +14 -4
- package/lib/worker/management.js +0 -16
- package/lib/worker/messaging.js +20 -7
- package/lib/worker-scaler.js +20 -9
- package/package.json +17 -17
- package/schema.json +13 -2
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
|
|
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
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
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
|
|
3003
|
+
let workerUrl
|
|
2889
3004
|
if (config.startTimeout > 0) {
|
|
2890
|
-
|
|
3005
|
+
workerUrl = await executeWithTimeout(sendViaITC(worker, 'start'), config.startTimeout)
|
|
2891
3006
|
|
|
2892
|
-
if (
|
|
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
|
-
|
|
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.
|
|
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
|
|
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(
|
|
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 () {
|