@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/config.d.ts +4 -0
- package/index.d.ts +179 -8
- package/lib/errors.js +30 -0
- package/lib/management-api.js +16 -3
- package/lib/management-handlers.js +3 -3
- package/lib/prom-server.js +81 -8
- package/lib/runtime.js +664 -57
- package/lib/scaling-algorithm.js +3 -3
- package/lib/worker/controller.js +4 -2
- package/lib/worker/itc.js +37 -18
- package/lib/worker/main.js +14 -4
- package/lib/worker/management.js +2 -2
- 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,
|
|
@@ -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
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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', {
|
|
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
|
|
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 {
|
|
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.#
|
|
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.#
|
|
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
|
|
1963
|
+
const hasWorkerId = /^.+:\d+$/.test(id)
|
|
1964
|
+
const attempts = hasWorkerId ? 1 : Math.max(1, this.#workers.getKeys(id).length)
|
|
1704
1965
|
|
|
1705
|
-
|
|
1706
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}`, {
|
|
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.
|
|
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
|
|
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
|
-
|
|
4409
|
+
extension.registry?.clear()
|
|
4010
4410
|
} catch (e) {
|
|
4011
|
-
this.logger.error(
|
|
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 () {
|