@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 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
- async getWorkerHealth (worker, options = {}) {
1519
- // Use ITC to get health from the worker. This correctly forwards to child
1520
- // processes when the service uses startWithCommand (subprocess mode),
1521
- // instead of reading the coordinator thread's metrics.
1522
- const { currentELU, heapUsed, heapTotal } = await sendViaITC(worker, 'getHealth')
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
- // Iterate through all workers and collect health metrics
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
- const id = worker[kApplicationId]
1976
- const index = worker[kWorkerId]
1977
- const errorLabel = this.#workerExtendedLabel(id, index, worker[kConfig].workers)
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
- let health = null
1980
- try {
1981
- health = await this.getWorkerHealth(worker, {
1982
- previousELU: worker[kLastHealthCheckELU]
1983
- })
1984
- } catch (err) {
1985
- this.logger.error({ err }, `Failed to get health for ${errorLabel}.`)
1986
- } finally {
1987
- worker[kLastHealthCheckELU] = health?.currentELU ?? null
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
- // We use emit instead of emitAndNotify to avoid sending a postMessages
1993
- // to each workers even if they are not interested in health metrics.
1994
- // No one of the known capabilities use this event yet.
1995
- this.emit('application:worker:health:metrics', {
1996
- id: worker[kId],
1997
- application: id,
1998
- worker: index,
1999
- currentHealth: health,
2000
- healthSignals
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
- if (lastHealthMetrics) {
2056
- const health = lastHealthMetrics.currentHealth
2057
- const memoryUsage = health.heapUsed / maxHeapTotal
2058
- const unhealthy = health.elu > maxELU || memoryUsage > maxHeapUsed
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: health,
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
- { elu: health.elu, maxELU, memoryUsage: health.heapUsed, maxMemoryUsage: maxHeapUsed },
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
  }
@@ -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.52.2",
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.52.2",
40
- "@platformatic/gateway": "3.52.2",
41
- "@platformatic/db": "3.52.2",
42
- "@platformatic/node": "3.52.2",
43
- "@platformatic/service": "3.52.2",
44
- "@platformatic/sql-graphql": "3.52.2",
45
- "@platformatic/sql-mapper": "3.52.2",
46
- "@platformatic/wattpm-pprof-capture": "3.52.2"
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/basic": "3.52.2",
77
- "@platformatic/generators": "3.52.2",
78
- "@platformatic/foundation": "3.52.2",
79
- "@platformatic/metrics": "3.52.2",
80
- "@platformatic/itc": "3.52.2",
81
- "@platformatic/telemetry": "3.52.2"
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.52.2.json",
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",