@platformatic/runtime 3.52.2 → 3.53.0-alpha.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 +170 -60
- package/lib/worker/symbols.js +1 -0
- package/package.json +15 -15
- package/schema.json +1 -1
package/lib/runtime.js
CHANGED
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
kHealthCheckTimer,
|
|
62
62
|
kId,
|
|
63
63
|
kInterceptorReadyPromise,
|
|
64
|
+
kIsSubprocessHost,
|
|
64
65
|
kITC,
|
|
65
66
|
kLastHealthCheckELU,
|
|
66
67
|
kStderrMarker,
|
|
@@ -73,6 +74,9 @@ import {
|
|
|
73
74
|
|
|
74
75
|
const kWorkerFile = join(import.meta.dirname, 'worker/main.js')
|
|
75
76
|
const kInspectorOptions = Symbol('plt.runtime.worker.inspectorOptions')
|
|
77
|
+
const kHeapCheckCounter = Symbol('plt.runtime.worker.heapCheckCounter')
|
|
78
|
+
const kLastHeapStats = Symbol('plt.runtime.worker.lastHeapStats')
|
|
79
|
+
const kHealthITCTimeoutMs = 5000
|
|
76
80
|
|
|
77
81
|
const MAX_LISTENERS_COUNT = 100
|
|
78
82
|
|
|
@@ -1515,12 +1519,57 @@ export class Runtime extends EventEmitter {
|
|
|
1515
1519
|
return status
|
|
1516
1520
|
}
|
|
1517
1521
|
|
|
1518
|
-
|
|
1519
|
-
//
|
|
1520
|
-
//
|
|
1521
|
-
//
|
|
1522
|
-
|
|
1522
|
+
getWorkerHealth (worker, options = {}) {
|
|
1523
|
+
// For subprocess workers we must round-trip through ITC to reach the child;
|
|
1524
|
+
// for pure worker-thread workers we can read ELU/heap directly from the
|
|
1525
|
+
// worker handle, which is served by Node's C++ layer and does not depend
|
|
1526
|
+
// on the worker's event loop being responsive. Going through ITC for
|
|
1527
|
+
// thread workers means a CPU-bound or stuck worker can freeze the whole
|
|
1528
|
+
// health-collection loop, which in turn blocks the management API.
|
|
1529
|
+
if (worker[kIsSubprocessHost]) {
|
|
1530
|
+
return this.#getSubprocessWorkerHealth(worker, options)
|
|
1531
|
+
}
|
|
1523
1532
|
|
|
1533
|
+
const currentELU = worker.performance.eventLoopUtilization()
|
|
1534
|
+
const previousELU = options.previousELU
|
|
1535
|
+
|
|
1536
|
+
let elu = currentELU
|
|
1537
|
+
if (previousELU) {
|
|
1538
|
+
elu = worker.performance.eventLoopUtilization(elu, previousELU)
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
if (!features.node.worker.getHeapStatistics) {
|
|
1542
|
+
return { elu: elu.utilization, currentELU }
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// Only refresh heap statistics every 60 health checks (once per minute).
|
|
1546
|
+
// This keeps the common path fully synchronous — no promise allocation.
|
|
1547
|
+
const counter = (worker[kHeapCheckCounter] ?? 0) + 1
|
|
1548
|
+
worker[kHeapCheckCounter] = counter >= 60 ? 0 : counter
|
|
1549
|
+
|
|
1550
|
+
if (counter >= 60 || !worker[kLastHeapStats]) {
|
|
1551
|
+
return worker.getHeapStatistics().then(({ used_heap_size: heapUsed, total_heap_size: heapTotal }) => {
|
|
1552
|
+
worker[kLastHeapStats] = { heapUsed, heapTotal }
|
|
1553
|
+
return { elu: elu.utilization, heapUsed, heapTotal, currentELU }
|
|
1554
|
+
})
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
const { heapUsed, heapTotal } = worker[kLastHeapStats]
|
|
1558
|
+
return { elu: elu.utilization, heapUsed, heapTotal, currentELU }
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
async #getSubprocessWorkerHealth (worker, options) {
|
|
1562
|
+
// Bound the ITC call so a hung child cannot freeze the health loop.
|
|
1563
|
+
// On timeout we fall back to last-known ELU with a null heap reading so
|
|
1564
|
+
// that the loop keeps rescheduling and signals remain observable.
|
|
1565
|
+
const result = await executeWithTimeout(sendViaITC(worker, 'getHealth'), kHealthITCTimeoutMs, kTimeout)
|
|
1566
|
+
|
|
1567
|
+
if (result === kTimeout) {
|
|
1568
|
+
const previousELU = options.previousELU ?? worker.performance.eventLoopUtilization()
|
|
1569
|
+
return { elu: 1, heapUsed: null, heapTotal: null, currentELU: previousELU }
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
const { currentELU, heapUsed, heapTotal } = result
|
|
1524
1573
|
const previousELU = options.previousELU
|
|
1525
1574
|
let elu = currentELU
|
|
1526
1575
|
if (previousELU) {
|
|
@@ -1853,6 +1902,14 @@ export class Runtime extends EventEmitter {
|
|
|
1853
1902
|
this.logger.trace({ event, payload, id: workerId, application: applicationId, worker: index }, 'Runtime event')
|
|
1854
1903
|
})
|
|
1855
1904
|
|
|
1905
|
+
// The worker notifies us when its capability has spawned a child process
|
|
1906
|
+
// (e.g. Next.js in dev mode). From that point on health metrics must come
|
|
1907
|
+
// from the child via ITC; for thread-only workers we keep reading the
|
|
1908
|
+
// handle directly in getWorkerHealth().
|
|
1909
|
+
worker[kITC].on('subprocess:started', () => {
|
|
1910
|
+
worker[kIsSubprocessHost] = true
|
|
1911
|
+
})
|
|
1912
|
+
|
|
1856
1913
|
worker[kITC].on('request:restart', async () => {
|
|
1857
1914
|
// Do not restart applications that are not fully started yet or when the runtime is still starting.
|
|
1858
1915
|
// The gateway sends request:restart when it receives application:added events,
|
|
@@ -1966,41 +2023,47 @@ export class Runtime extends EventEmitter {
|
|
|
1966
2023
|
return
|
|
1967
2024
|
}
|
|
1968
2025
|
|
|
1969
|
-
//
|
|
2026
|
+
// Collect health from all workers in parallel so that a slow ITC
|
|
2027
|
+
// round-trip (e.g. subprocess timeout) does not block every other worker.
|
|
2028
|
+
const pending = []
|
|
1970
2029
|
for (const worker of this.#workers.values()) {
|
|
1971
2030
|
if (worker[kWorkerStatus] !== 'started') {
|
|
1972
2031
|
continue
|
|
1973
2032
|
}
|
|
1974
2033
|
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
2034
|
+
pending.push((async () => {
|
|
2035
|
+
const id = worker[kApplicationId]
|
|
2036
|
+
const index = worker[kWorkerId]
|
|
2037
|
+
const errorLabel = this.#workerExtendedLabel(id, index, worker[kConfig].workers)
|
|
1978
2038
|
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
const healthSignals = worker[kWorkerHealthSignals]?.getAll() ?? []
|
|
2039
|
+
let health = null
|
|
2040
|
+
try {
|
|
2041
|
+
health = await this.getWorkerHealth(worker, {
|
|
2042
|
+
previousELU: worker[kLastHealthCheckELU]
|
|
2043
|
+
})
|
|
2044
|
+
} catch (err) {
|
|
2045
|
+
this.logger.error({ err }, `Failed to get health for ${errorLabel}.`)
|
|
2046
|
+
} finally {
|
|
2047
|
+
worker[kLastHealthCheckELU] = health?.currentELU ?? null
|
|
2048
|
+
}
|
|
1991
2049
|
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
application:
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2050
|
+
const healthSignals = worker[kWorkerHealthSignals]?.getAll() ?? []
|
|
2051
|
+
|
|
2052
|
+
// We use emit instead of emitAndNotify to avoid sending a postMessages
|
|
2053
|
+
// to each workers even if they are not interested in health metrics.
|
|
2054
|
+
// No one of the known capabilities use this event yet.
|
|
2055
|
+
this.emit('application:worker:health:metrics', {
|
|
2056
|
+
id: worker[kId],
|
|
2057
|
+
application: id,
|
|
2058
|
+
worker: index,
|
|
2059
|
+
currentHealth: health,
|
|
2060
|
+
healthSignals
|
|
2061
|
+
})
|
|
2062
|
+
})())
|
|
2002
2063
|
}
|
|
2003
2064
|
|
|
2065
|
+
await Promise.allSettled(pending)
|
|
2066
|
+
|
|
2004
2067
|
// Reschedule the next check. We are not using .refresh() because it's more
|
|
2005
2068
|
// expensive (weird).
|
|
2006
2069
|
this.#healthMetricsTimer = setTimeout(collectHealthMetrics, 1000).unref()
|
|
@@ -2052,53 +2115,45 @@ export class Runtime extends EventEmitter {
|
|
|
2052
2115
|
worker[kHealthCheckTimer] = setTimeout(async () => {
|
|
2053
2116
|
if (worker[kWorkerStatus] !== 'started') return
|
|
2054
2117
|
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2118
|
+
// No health data received yet — reschedule and wait.
|
|
2119
|
+
if (!lastHealthMetrics) {
|
|
2120
|
+
worker[kHealthCheckTimer].refresh()
|
|
2121
|
+
return
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
const health = lastHealthMetrics.currentHealth
|
|
2125
|
+
|
|
2126
|
+
// When health collection failed (threw) or timed out, currentHealth is
|
|
2127
|
+
// null. Treat this as an unhealthy check so that a stuck worker that
|
|
2128
|
+
// cannot even report its own health is eventually replaced.
|
|
2129
|
+
if (!health) {
|
|
2130
|
+
unhealthyChecks++
|
|
2131
|
+
|
|
2132
|
+
this.logger.error(
|
|
2133
|
+
`Health collection failed for the ${errorLabel}. ` +
|
|
2134
|
+
`Unhealthy check ${unhealthyChecks}/${maxUnhealthyChecks}.`
|
|
2135
|
+
)
|
|
2059
2136
|
|
|
2060
2137
|
this.emit('application:worker:health', {
|
|
2061
2138
|
id: worker[kId],
|
|
2062
2139
|
application: id,
|
|
2063
2140
|
worker: index,
|
|
2064
|
-
currentHealth:
|
|
2065
|
-
unhealthy,
|
|
2141
|
+
currentHealth: null,
|
|
2142
|
+
unhealthy: true,
|
|
2066
2143
|
healthConfig
|
|
2067
2144
|
})
|
|
2068
2145
|
|
|
2069
|
-
if (health.elu > maxELU) {
|
|
2070
|
-
this.logger.error(
|
|
2071
|
-
`The ${errorLabel} has an ELU of ${(health.elu * 100).toFixed(2)} %, ` +
|
|
2072
|
-
`above the maximum allowed usage of ${(maxELU * 100).toFixed(2)} %.`
|
|
2073
|
-
)
|
|
2074
|
-
}
|
|
2075
|
-
|
|
2076
|
-
if (memoryUsage > maxHeapUsed) {
|
|
2077
|
-
this.logger.error(
|
|
2078
|
-
`The ${errorLabel} is using ${(memoryUsage * 100).toFixed(2)} % of the memory, ` +
|
|
2079
|
-
`above the maximum allowed usage of ${(maxHeapUsed * 100).toFixed(2)} %.`
|
|
2080
|
-
)
|
|
2081
|
-
}
|
|
2082
|
-
|
|
2083
|
-
if (unhealthy) {
|
|
2084
|
-
unhealthyChecks++
|
|
2085
|
-
} else {
|
|
2086
|
-
unhealthyChecks = 0
|
|
2087
|
-
}
|
|
2088
|
-
|
|
2089
2146
|
if (unhealthyChecks === maxUnhealthyChecks) {
|
|
2090
2147
|
try {
|
|
2091
2148
|
this.emitAndNotify('application:worker:unhealthy', { application: id, worker: index })
|
|
2092
2149
|
|
|
2093
2150
|
this.logger.error(
|
|
2094
|
-
{
|
|
2095
|
-
`The ${errorLabel} is unhealthy. Replacing it ...`
|
|
2151
|
+
`The ${errorLabel} is unhealthy (health collection failed). Replacing it ...`
|
|
2096
2152
|
)
|
|
2097
2153
|
|
|
2098
2154
|
await this.#replaceWorker(config, applicationConfig, workersCount, id, index, worker)
|
|
2099
2155
|
} catch (e) {
|
|
2100
2156
|
this.logger.error(
|
|
2101
|
-
{ elu: health.elu, maxELU, memoryUsage: health.heapUsed, maxMemoryUsage: maxHeapUsed },
|
|
2102
2157
|
`Cannot replace the ${errorLabel}. Forcefully terminating it ...`
|
|
2103
2158
|
)
|
|
2104
2159
|
|
|
@@ -2107,6 +2162,61 @@ export class Runtime extends EventEmitter {
|
|
|
2107
2162
|
} else {
|
|
2108
2163
|
worker[kHealthCheckTimer].refresh()
|
|
2109
2164
|
}
|
|
2165
|
+
return
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
const memoryUsage = health.heapUsed != null ? health.heapUsed / maxHeapTotal : 0
|
|
2169
|
+
const unhealthy = health.elu > maxELU || memoryUsage > maxHeapUsed
|
|
2170
|
+
|
|
2171
|
+
this.emit('application:worker:health', {
|
|
2172
|
+
id: worker[kId],
|
|
2173
|
+
application: id,
|
|
2174
|
+
worker: index,
|
|
2175
|
+
currentHealth: health,
|
|
2176
|
+
unhealthy,
|
|
2177
|
+
healthConfig
|
|
2178
|
+
})
|
|
2179
|
+
|
|
2180
|
+
if (health.elu > maxELU) {
|
|
2181
|
+
this.logger.error(
|
|
2182
|
+
`The ${errorLabel} has an ELU of ${(health.elu * 100).toFixed(2)} %, ` +
|
|
2183
|
+
`above the maximum allowed usage of ${(maxELU * 100).toFixed(2)} %.`
|
|
2184
|
+
)
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
if (memoryUsage > maxHeapUsed) {
|
|
2188
|
+
this.logger.error(
|
|
2189
|
+
`The ${errorLabel} is using ${(memoryUsage * 100).toFixed(2)} % of the memory, ` +
|
|
2190
|
+
`above the maximum allowed usage of ${(maxHeapUsed * 100).toFixed(2)} %.`
|
|
2191
|
+
)
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
if (unhealthy) {
|
|
2195
|
+
unhealthyChecks++
|
|
2196
|
+
} else {
|
|
2197
|
+
unhealthyChecks = 0
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
if (unhealthyChecks === maxUnhealthyChecks) {
|
|
2201
|
+
try {
|
|
2202
|
+
this.emitAndNotify('application:worker:unhealthy', { application: id, worker: index })
|
|
2203
|
+
|
|
2204
|
+
this.logger.error(
|
|
2205
|
+
{ elu: health.elu, maxELU, memoryUsage: health.heapUsed, maxMemoryUsage: maxHeapUsed },
|
|
2206
|
+
`The ${errorLabel} is unhealthy. Replacing it ...`
|
|
2207
|
+
)
|
|
2208
|
+
|
|
2209
|
+
await this.#replaceWorker(config, applicationConfig, workersCount, id, index, worker)
|
|
2210
|
+
} catch (e) {
|
|
2211
|
+
this.logger.error(
|
|
2212
|
+
{ elu: health.elu, maxELU, memoryUsage: health.heapUsed, maxMemoryUsage: maxHeapUsed },
|
|
2213
|
+
`Cannot replace the ${errorLabel}. Forcefully terminating it ...`
|
|
2214
|
+
)
|
|
2215
|
+
|
|
2216
|
+
worker.terminate()
|
|
2217
|
+
}
|
|
2218
|
+
} else {
|
|
2219
|
+
worker[kHealthCheckTimer].refresh()
|
|
2110
2220
|
}
|
|
2111
2221
|
}, interval).unref()
|
|
2112
2222
|
}
|
package/lib/worker/symbols.js
CHANGED
|
@@ -12,6 +12,7 @@ export const kInterceptors = Symbol.for('plt.runtime.worker.interceptors')
|
|
|
12
12
|
export const kLastHealthCheckELU = Symbol.for('plt.runtime.worker.lastHealthCheckELU')
|
|
13
13
|
export const kLastWorkerScalerELU = Symbol.for('plt.runtime.worker.lastWorkerScalerELU')
|
|
14
14
|
export const kInterceptorReadyPromise = Symbol.for('plt.runtime.worker.interceptorReadyPromise')
|
|
15
|
+
export const kIsSubprocessHost = Symbol.for('plt.runtime.worker.isSubprocessHost')
|
|
15
16
|
|
|
16
17
|
// This string marker should be safe to use since it belongs to Unicode private area
|
|
17
18
|
export const kStderrMarker = '\ue002'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@platformatic/runtime",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.53.0-alpha.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -36,14 +36,14 @@
|
|
|
36
36
|
"typescript": "^5.5.4",
|
|
37
37
|
"undici-oidc-interceptor": "^0.5.0",
|
|
38
38
|
"why-is-node-running": "^2.2.2",
|
|
39
|
-
"@platformatic/composer": "3.
|
|
40
|
-
"@platformatic/gateway": "3.
|
|
41
|
-
"@platformatic/db": "3.
|
|
42
|
-
"@platformatic/
|
|
43
|
-
"@platformatic/
|
|
44
|
-
"@platformatic/
|
|
45
|
-
"@platformatic/sql-mapper": "3.
|
|
46
|
-
"@platformatic/wattpm-pprof-capture": "3.
|
|
39
|
+
"@platformatic/composer": "3.53.0-alpha.0",
|
|
40
|
+
"@platformatic/gateway": "3.53.0-alpha.0",
|
|
41
|
+
"@platformatic/db": "3.53.0-alpha.0",
|
|
42
|
+
"@platformatic/service": "3.53.0-alpha.0",
|
|
43
|
+
"@platformatic/sql-graphql": "3.53.0-alpha.0",
|
|
44
|
+
"@platformatic/node": "3.53.0-alpha.0",
|
|
45
|
+
"@platformatic/sql-mapper": "3.53.0-alpha.0",
|
|
46
|
+
"@platformatic/wattpm-pprof-capture": "3.53.0-alpha.0"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@fastify/accepts": "^5.0.0",
|
|
@@ -73,12 +73,12 @@
|
|
|
73
73
|
"undici": "^7.0.0",
|
|
74
74
|
"undici-thread-interceptor": "^1.3.1",
|
|
75
75
|
"ws": "^8.16.0",
|
|
76
|
-
"@platformatic/
|
|
77
|
-
"@platformatic/
|
|
78
|
-
"@platformatic/
|
|
79
|
-
"@platformatic/metrics": "3.
|
|
80
|
-
"@platformatic/itc": "3.
|
|
81
|
-
"@platformatic/telemetry": "3.
|
|
76
|
+
"@platformatic/foundation": "3.53.0-alpha.0",
|
|
77
|
+
"@platformatic/basic": "3.53.0-alpha.0",
|
|
78
|
+
"@platformatic/generators": "3.53.0-alpha.0",
|
|
79
|
+
"@platformatic/metrics": "3.53.0-alpha.0",
|
|
80
|
+
"@platformatic/itc": "3.53.0-alpha.0",
|
|
81
|
+
"@platformatic/telemetry": "3.53.0-alpha.0"
|
|
82
82
|
},
|
|
83
83
|
"engines": {
|
|
84
84
|
"node": ">=22.19.0"
|
package/schema.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.
|
|
2
|
+
"$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.53.0-alpha.0.json",
|
|
3
3
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
4
4
|
"title": "Platformatic Runtime Config",
|
|
5
5
|
"type": "object",
|