@platformatic/runtime 2.69.0 → 2.70.1

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
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { ITC } = require('@platformatic/itc')
4
- const { features, ensureLoggableError, executeWithTimeout, deepmerge } = require('@platformatic/utils')
4
+ const { features, ensureLoggableError, executeWithTimeout, deepmerge, parseMemorySize, kTimeout } = require('@platformatic/utils')
5
5
  const { once, EventEmitter } = require('node:events')
6
6
  const { createReadStream, watch, existsSync } = require('node:fs')
7
7
  const { readdir, readFile, stat, access } = require('node:fs/promises')
@@ -33,7 +33,9 @@ const {
33
33
  kHealthCheckTimer,
34
34
  kConfig,
35
35
  kWorkerStatus,
36
- kStderrMarker
36
+ kStderrMarker,
37
+ kLastELU,
38
+ kWorkersBroadcast
37
39
  } = require('./worker/symbols')
38
40
 
39
41
  const fastify = require('fastify')
@@ -74,6 +76,8 @@ class Runtime extends EventEmitter {
74
76
  #prometheusServer
75
77
  #inspectorServer
76
78
  #workers
79
+ #workersBroadcastChannel
80
+ #workerITCHandlers
77
81
  #restartingWorkers
78
82
  #sharedHttpCache
79
83
  servicesConfigsPatches
@@ -106,6 +110,18 @@ class Runtime extends EventEmitter {
106
110
  stderr: new SonicBoom({ fd: process.stderr.fd })
107
111
  }
108
112
  }
113
+
114
+ this.#workerITCHandlers = {
115
+ getServiceMeta: this.getServiceMeta.bind(this),
116
+ listServices: () => this.#servicesIds,
117
+ getServices: this.getServices.bind(this),
118
+ getWorkers: this.getWorkers.bind(this),
119
+ getWorkerMessagingChannel: this.#getWorkerMessagingChannel.bind(this),
120
+ getHttpCacheValue: this.#getHttpCacheValue.bind(this),
121
+ setHttpCacheValue: this.#setHttpCacheValue.bind(this),
122
+ deleteHttpCacheValue: this.#deleteHttpCacheValue.bind(this),
123
+ invalidateHttpCache: this.invalidateHttpCache.bind(this)
124
+ }
109
125
  }
110
126
 
111
127
  async init () {
@@ -130,12 +146,15 @@ class Runtime extends EventEmitter {
130
146
 
131
147
  this.#isProduction = this.#configManager.args?.production ?? false
132
148
  this.#servicesIds = config.services.map(service => service.id)
149
+ this.#createWorkersBroadcastChannel()
133
150
 
134
151
  const workersConfig = []
135
152
  for (const service of config.services) {
136
153
  const count = service.workers ?? this.#configManager.current.workers
137
154
  if (count > 1 && service.entrypoint && !features.node.reusePort) {
138
- this.logger.warn(`"${service.id}" is set as the entrypoint, but reusePort is not available in your OS; setting workers to 1 instead of ${count}`)
155
+ this.logger.warn(
156
+ `"${service.id}" is set as the entrypoint, but reusePort is not available in your OS; setting workers to 1 instead of ${count}`
157
+ )
139
158
  workersConfig.push({ id: service.id, workers: 1 })
140
159
  } else {
141
160
  workersConfig.push({ id: service.id, workers: count })
@@ -225,6 +244,7 @@ class Runtime extends EventEmitter {
225
244
  throw new errors.MissingEntrypointError()
226
245
  }
227
246
  this.#updateStatus('starting')
247
+ this.#createWorkersBroadcastChannel()
228
248
 
229
249
  // Important: do not use Promise.all here since it won't properly manage dependencies
230
250
  try {
@@ -311,6 +331,7 @@ class Runtime extends EventEmitter {
311
331
  }
312
332
 
313
333
  await this.#meshInterceptor.close()
334
+ this.#workersBroadcastChannel?.close()
314
335
 
315
336
  this.#updateStatus('stopped')
316
337
  }
@@ -1148,10 +1169,13 @@ class Runtime extends EventEmitter {
1148
1169
  workerEnv['NODE_OPTIONS'] = `${originalNodeOptions} ${serviceConfig.nodeOptions}`.trim()
1149
1170
  }
1150
1171
 
1172
+ const maxHeapTotal = typeof health.maxHeapTotal === 'string' ? parseMemorySize(health.maxHeapTotal) : health.maxHeapTotal
1173
+ const maxYoungGeneration = typeof health.maxYoungGeneration === 'string' ? parseMemorySize(health.maxYoungGeneration) : health.maxYoungGeneration
1174
+
1151
1175
  const maxOldGenerationSizeMb = Math.floor(
1152
- (health.maxYoungGeneration > 0 ? health.maxHeapTotal - health.maxYoungGeneration : health.maxHeapTotal) / (1024 * 1024)
1176
+ (maxYoungGeneration > 0 ? maxHeapTotal - maxYoungGeneration : maxHeapTotal) / (1024 * 1024)
1153
1177
  )
1154
- const maxYoungGenerationSizeMb = health.maxYoungGeneration ? Math.floor(health.maxYoungGeneration / (1024 * 1024)) : undefined
1178
+ const maxYoungGenerationSizeMb = maxYoungGeneration ? Math.floor(maxYoungGeneration / (1024 * 1024)) : undefined
1155
1179
 
1156
1180
  const worker = new Worker(kWorkerFile, {
1157
1181
  workerData: {
@@ -1207,6 +1231,8 @@ class Runtime extends EventEmitter {
1207
1231
  setImmediate(() => {
1208
1232
  if (started && (!config.watch || code !== 0)) {
1209
1233
  this.emit('service:worker:error', { ...eventPayload, code })
1234
+ this.#broadcastWorkers()
1235
+
1210
1236
  this.logger.warn(`The ${errorLabel} unexpectedly exited with code ${code}.`)
1211
1237
  }
1212
1238
 
@@ -1250,14 +1276,7 @@ class Runtime extends EventEmitter {
1250
1276
  name: workerId + '-runtime',
1251
1277
  port: worker,
1252
1278
  handlers: {
1253
- getServiceMeta: this.getServiceMeta.bind(this),
1254
- listServices: () => this.#servicesIds,
1255
- getServices: this.getServices.bind(this),
1256
- getWorkers: this.getWorkers.bind(this),
1257
- getHttpCacheValue: this.#getHttpCacheValue.bind(this),
1258
- setHttpCacheValue: this.#setHttpCacheValue.bind(this),
1259
- deleteHttpCacheValue: this.#deleteHttpCacheValue.bind(this),
1260
- invalidateHttpCache: this.invalidateHttpCache.bind(this),
1279
+ ...this.#workerITCHandlers,
1261
1280
  setEventsForwarding (value) {
1262
1281
  worker[kForwardEvents] = value
1263
1282
  }
@@ -1282,7 +1301,7 @@ class Runtime extends EventEmitter {
1282
1301
  await this.startService(serviceId)
1283
1302
  }
1284
1303
 
1285
- this.logger?.info(`Service "${serviceId}" has been successfully reloaded ...`)
1304
+ this.logger?.info(`The service "${serviceId}" has been successfully reloaded ...`)
1286
1305
 
1287
1306
  if (serviceConfig.entrypoint) {
1288
1307
  this.#showUrl()
@@ -1323,18 +1342,46 @@ class Runtime extends EventEmitter {
1323
1342
  return worker
1324
1343
  }
1325
1344
 
1326
- #setupHealthCheck (config, serviceConfig, workersCount, id, index, workerId, worker, errorLabel) {
1345
+ async #getHealth (worker) {
1346
+ if (features.node.worker.getHeapStatistics) {
1347
+ const { used_heap_size: heapUsed, total_heap_size: heapTotal } = await worker.getHeapStatistics()
1348
+ const currentELU = worker.performance.eventLoopUtilization()
1349
+ const elu = worker[kLastELU]
1350
+ ? worker.performance.eventLoopUtilization(currentELU, worker[kLastELU])
1351
+ : currentELU
1352
+ worker[kLastELU] = currentELU
1353
+ return { elu: elu.utilization, heapUsed, heapTotal }
1354
+ }
1355
+
1356
+ const health = await worker[kITC].send('getHealth')
1357
+ return health
1358
+ }
1359
+
1360
+ #setupHealthCheck (config, serviceConfig, workersCount, id, index, worker, errorLabel, timeout) {
1327
1361
  // Clear the timeout when exiting
1328
1362
  worker.on('exit', () => clearTimeout(worker[kHealthCheckTimer]))
1329
1363
 
1330
1364
  const { maxELU, maxHeapUsed, maxHeapTotal, maxUnhealthyChecks, interval } = worker[kConfig].health
1365
+ const maxHeapTotalNumber = typeof maxHeapTotal === 'string' ? parseMemorySize(maxHeapTotal) : maxHeapTotal
1366
+
1331
1367
  let unhealthyChecks = 0
1332
1368
 
1333
1369
  worker[kHealthCheckTimer] = setTimeout(async () => {
1334
- const health = await worker[kITC].send('getHealth')
1335
- const { elu, heapUsed } = health
1336
- const memoryUsage = heapUsed / maxHeapTotal
1337
- const unhealthy = elu > maxELU || memoryUsage > maxHeapUsed
1370
+ if (worker[kWorkerStatus] !== 'started') {
1371
+ return
1372
+ }
1373
+
1374
+ let health, unhealthy, memoryUsage
1375
+ try {
1376
+ health = await this.#getHealth(worker)
1377
+ memoryUsage = health.heapUsed / maxHeapTotalNumber
1378
+ unhealthy = health.elu > maxELU || memoryUsage > maxHeapUsed
1379
+ } catch (err) {
1380
+ this.logger.error({ err }, `Failed to get health for ${errorLabel}.`)
1381
+ unhealthy = true
1382
+ memoryUsage = -1
1383
+ health = { elu: -1, heapUsed: -1, heapTotal: -1 }
1384
+ }
1338
1385
 
1339
1386
  const serviceId = worker[kServiceId]
1340
1387
  this.emit('health', {
@@ -1347,9 +1394,9 @@ class Runtime extends EventEmitter {
1347
1394
  })
1348
1395
 
1349
1396
  if (unhealthy) {
1350
- if (elu > maxELU) {
1397
+ if (health.elu > maxELU) {
1351
1398
  this.logger.error(
1352
- `The ${errorLabel} has an ELU of ${(elu * 100).toFixed(2)} %, above the maximum allowed usage of ${(maxELU * 100).toFixed(2)} %.`
1399
+ `The ${errorLabel} has an ELU of ${(health.elu * 100).toFixed(2)} %, above the maximum allowed usage of ${(maxELU * 100).toFixed(2)} %.`
1353
1400
  )
1354
1401
  }
1355
1402
 
@@ -1367,14 +1414,14 @@ class Runtime extends EventEmitter {
1367
1414
  if (unhealthyChecks === maxUnhealthyChecks) {
1368
1415
  try {
1369
1416
  this.logger.error(
1370
- { elu, maxELU, memoryUsage, maxMemoryUsage: maxHeapUsed },
1417
+ { elu: health.elu, maxELU, memoryUsage: health.heapUsed, maxMemoryUsage: maxHeapUsed },
1371
1418
  `The ${errorLabel} is unhealthy. Replacing it ...`
1372
1419
  )
1373
1420
 
1374
- await this.#replaceWorker(config, serviceConfig, workersCount, id, index, workerId, worker)
1421
+ await this.#replaceWorker(config, serviceConfig, workersCount, id, index, worker)
1375
1422
  } catch (e) {
1376
1423
  this.logger.error(
1377
- { elu, maxELU, memoryUsage, maxMemoryUsage: maxHeapUsed },
1424
+ { elu: health.elu, maxELU, memoryUsage: health.heapUsed, maxMemoryUsage: maxHeapUsed },
1378
1425
  `Cannot replace the ${errorLabel}. Forcefully terminating it ...`
1379
1426
  )
1380
1427
 
@@ -1383,7 +1430,7 @@ class Runtime extends EventEmitter {
1383
1430
  } else {
1384
1431
  worker[kHealthCheckTimer].refresh()
1385
1432
  }
1386
- }, interval)
1433
+ }, timeout ?? interval)
1387
1434
  }
1388
1435
 
1389
1436
  async #startWorker (
@@ -1397,7 +1444,6 @@ class Runtime extends EventEmitter {
1397
1444
  worker = undefined,
1398
1445
  disableRestartAttempts = false
1399
1446
  ) {
1400
- const workerId = `${id}:${index}`
1401
1447
  const label = this.#workerExtendedLabel(id, index, workersCount)
1402
1448
 
1403
1449
  if (!silent) {
@@ -1424,7 +1470,7 @@ class Runtime extends EventEmitter {
1424
1470
  if (config.startTimeout > 0) {
1425
1471
  workerUrl = await executeWithTimeout(sendViaITC(worker, 'start'), config.startTimeout)
1426
1472
 
1427
- if (workerUrl === 'timeout') {
1473
+ if (workerUrl === kTimeout) {
1428
1474
  this.emit('service:worker:startTimeout', eventPayload)
1429
1475
  this.logger.info(`The ${label} failed to start in ${config.startTimeout}ms. Forcefully killing the thread.`)
1430
1476
  worker.terminate()
@@ -1442,6 +1488,7 @@ class Runtime extends EventEmitter {
1442
1488
 
1443
1489
  worker[kWorkerStatus] = 'started'
1444
1490
  this.emit('service:worker:started', eventPayload)
1491
+ this.#broadcastWorkers()
1445
1492
 
1446
1493
  if (!silent) {
1447
1494
  this.logger?.info(`Started the ${label}...`)
@@ -1449,12 +1496,9 @@ class Runtime extends EventEmitter {
1449
1496
 
1450
1497
  const { enabled, gracePeriod } = worker[kConfig].health
1451
1498
  if (enabled && config.restartOnError > 0) {
1452
- worker[kHealthCheckTimer] = setTimeout(
1453
- () => {
1454
- this.#setupHealthCheck(config, serviceConfig, workersCount, id, index, workerId, worker, label)
1455
- },
1456
- gracePeriod > 0 ? gracePeriod : 1
1457
- )
1499
+ // if gracePeriod is 0, it will be set to 1 to start health checks immediately
1500
+ // however, the health event will start when the worker is started
1501
+ this.#setupHealthCheck(config, serviceConfig, workersCount, id, index, worker, label, gracePeriod > 0 ? gracePeriod : 1)
1458
1502
  }
1459
1503
  } catch (error) {
1460
1504
  // TODO: handle port allocation error here
@@ -1545,7 +1589,7 @@ class Runtime extends EventEmitter {
1545
1589
  const res = await executeWithTimeout(exitPromise, exitTimeout)
1546
1590
 
1547
1591
  // If the worker didn't exit in time, kill it
1548
- if (res === 'timeout') {
1592
+ if (res === kTimeout) {
1549
1593
  this.emit('service:worker:exit:timeout', eventPayload)
1550
1594
  await worker.terminate()
1551
1595
  }
@@ -1554,6 +1598,7 @@ class Runtime extends EventEmitter {
1554
1598
 
1555
1599
  worker[kWorkerStatus] = 'stopped'
1556
1600
  this.emit('service:worker:stopped', eventPayload)
1601
+ this.#broadcastWorkers()
1557
1602
  }
1558
1603
 
1559
1604
  #cleanupWorker (worker) {
@@ -1603,6 +1648,9 @@ class Runtime extends EventEmitter {
1603
1648
  await this.#setupWorker(config, serviceConfig, workersCount, id, index)
1604
1649
  await this.#startWorker(config, serviceConfig, workersCount, id, index, silent, bootstrapAttempt)
1605
1650
 
1651
+ this.logger.info(
1652
+ `The ${this.#workerExtendedLabel(id, index, workersCount)} has been successfully restarted ...`
1653
+ )
1606
1654
  resolve()
1607
1655
  } catch (err) {
1608
1656
  // The runtime was stopped while the restart was happening, ignore any error.
@@ -1625,7 +1673,8 @@ class Runtime extends EventEmitter {
1625
1673
  await restartPromise
1626
1674
  }
1627
1675
 
1628
- async #replaceWorker (config, serviceConfig, workersCount, serviceId, index, workerId, worker) {
1676
+ async #replaceWorker (config, serviceConfig, workersCount, serviceId, index, worker) {
1677
+ const workerId = `${serviceId}:${index}`
1629
1678
  let newWorker
1630
1679
 
1631
1680
  try {
@@ -1685,7 +1734,15 @@ class Runtime extends EventEmitter {
1685
1734
  return null
1686
1735
  }
1687
1736
 
1688
- throw new errors.ServiceNotFoundError(serviceId, Array.from(this.#servicesIds).join(', '))
1737
+ if (this.#servicesIds.includes(serviceId)) {
1738
+ const availableWorkers = Array.from(this.#workers.keys())
1739
+ .filter(key => key.startsWith(serviceId + ':'))
1740
+ .map(key => key.split(':')[1])
1741
+ .join(', ')
1742
+ throw new errors.WorkerNotFoundError(workerId, serviceId, availableWorkers)
1743
+ } else {
1744
+ throw new errors.ServiceNotFoundError(serviceId, Array.from(this.#servicesIds).join(', '))
1745
+ }
1689
1746
  }
1690
1747
 
1691
1748
  if (ensureStarted) {
@@ -1699,6 +1756,59 @@ class Runtime extends EventEmitter {
1699
1756
  return worker
1700
1757
  }
1701
1758
 
1759
+ async #createWorkersBroadcastChannel () {
1760
+ this.#workersBroadcastChannel?.close()
1761
+ this.#workersBroadcastChannel = new BroadcastChannel(kWorkersBroadcast)
1762
+ }
1763
+
1764
+ async #broadcastWorkers () {
1765
+ const workers = new Map()
1766
+
1767
+ // Create the list of workers
1768
+ for (const worker of this.#workers.values()) {
1769
+ if (worker[kWorkerStatus] !== 'started') {
1770
+ continue
1771
+ }
1772
+
1773
+ const service = worker[kServiceId]
1774
+ let serviceWorkers = workers.get(service)
1775
+
1776
+ if (!serviceWorkers) {
1777
+ serviceWorkers = []
1778
+ workers.set(service, serviceWorkers)
1779
+ }
1780
+
1781
+ serviceWorkers.push({
1782
+ id: worker[kId],
1783
+ service: worker[kServiceId],
1784
+ worker: worker[kWorkerId],
1785
+ thread: worker.threadId
1786
+ })
1787
+ }
1788
+
1789
+ this.#workersBroadcastChannel.postMessage(workers)
1790
+ }
1791
+
1792
+ async #getWorkerMessagingChannel ({ service, worker }, context) {
1793
+ const target = await this.#getWorkerById(service, worker, true, true)
1794
+
1795
+ const { port1, port2 } = new MessageChannel()
1796
+
1797
+ // Send the first port to the target
1798
+ const response = await executeWithTimeout(
1799
+ sendViaITC(target, 'saveMessagingChannel', port1, [port1]),
1800
+ this.#configManager.current.messagingTimeout
1801
+ )
1802
+
1803
+ if (response === kTimeout) {
1804
+ throw new errors.MessagingError(service, 'Timeout while establishing a communication channel.')
1805
+ }
1806
+
1807
+ context.transferList = [port2]
1808
+ this.emit('service:worker:messagingChannel', { service, worker })
1809
+ return port2
1810
+ }
1811
+
1702
1812
  async #getRuntimePackageJson () {
1703
1813
  const runtimeDir = this.#configManager.dirname
1704
1814
  const packageJsonPath = join(runtimeDir, 'package.json')
@@ -1804,7 +1914,8 @@ class Runtime extends EventEmitter {
1804
1914
  }
1805
1915
  }
1806
1916
 
1807
- const pinoLog = typeof message?.level === 'number' && typeof message?.time === 'number' && typeof message?.msg === 'string'
1917
+ const pinoLog =
1918
+ typeof message?.level === 'number' && typeof message?.time === 'number' && typeof message?.msg === 'string'
1808
1919
 
1809
1920
  // Directly write to the Pino destination
1810
1921
  if (pinoLog) {
@@ -1849,10 +1960,16 @@ class Runtime extends EventEmitter {
1849
1960
 
1850
1961
  async getServiceResourcesInfo (id) {
1851
1962
  const workers = this.#workers.getCount(id)
1852
- return { workers }
1963
+
1964
+ const worker = await this.#getWorkerById(id, 0, false, false)
1965
+ const health = worker[kConfig].health
1966
+
1967
+ return { workers, health }
1853
1968
  }
1854
1969
 
1855
- async #updateWorkerCount (serviceId, workers) {
1970
+ async #updateServiceConfigWorkers (serviceId, workers) {
1971
+ this.logger.info(`Updating service "${serviceId}" config workers to ${workers}`)
1972
+
1856
1973
  this.#configManager.current.services.find(s => s.id === serviceId).workers = workers
1857
1974
  const service = await this.#getServiceById(serviceId)
1858
1975
  this.#workers.setCount(serviceId, workers)
@@ -1868,14 +1985,109 @@ class Runtime extends EventEmitter {
1868
1985
  const results = await Promise.allSettled(promises)
1869
1986
  for (const result of results) {
1870
1987
  if (result.status === 'rejected') {
1988
+ this.logger.error({ err: result.reason }, `Cannot update service "${serviceId}" workers`)
1871
1989
  throw result.reason
1872
1990
  }
1873
1991
  }
1874
1992
  }
1875
1993
 
1994
+ async #updateServiceConfigHealth (serviceId, health) {
1995
+ this.logger.info(`Updating service "${serviceId}" config health heap to ${JSON.stringify(health)}`)
1996
+ const { maxHeapTotal, maxYoungGeneration } = health
1997
+
1998
+ const service = this.#configManager.current.services.find(s => s.id === serviceId)
1999
+ if (maxHeapTotal) {
2000
+ service.health.maxHeapTotal = maxHeapTotal
2001
+ }
2002
+ if (maxYoungGeneration) {
2003
+ service.health.maxYoungGeneration = maxYoungGeneration
2004
+ }
2005
+ }
2006
+
2007
+ /**
2008
+ * Updates the resources of the services, such as the number of workers and health configurations (e.g., heap memory settings).
2009
+ *
2010
+ * This function handles three update scenarios for each service:
2011
+ * 1. **Updating workers only**: Adjusts the number of workers for the service.
2012
+ * 2. **Updating health configurations only**: Updates health parameters like `maxHeapTotal` or `maxYoungGeneration`.
2013
+ * 3. **Updating both workers and health configurations**: Scales the workers and also applies health settings.
2014
+ *
2015
+ * When updating both workers and health:
2016
+ * - **Scaling down workers**: Stops extra workers, then restarts the remaining workers with the previous settings.
2017
+ * - **Scaling up workers**: Starts new workers with the updated heap settings, then restarts the old workers with the updated settings.
2018
+ *
2019
+ * Scaling up new resources (workers and/or heap memory) may fails due to insufficient memory, in this case the operation may fail partially or entirely.
2020
+ * Scaling down is expected to succeed without issues.
2021
+ *
2022
+ * @param {Array<Object>} updates - An array of objects that define the updates for each service.
2023
+ * @param {string} updates[].service - The ID of the service to update.
2024
+ * @param {number} [updates[].workers] - The desired number of workers for the service. If omitted, workers will not be updated.
2025
+ * @param {Object} [updates[].health] - The health configuration to update for the service, which may include:
2026
+ * @param {string|number} [updates[].health.maxHeapTotal] - The maximum heap memory for the service. Can be a valid memory string (e.g., '1G', '512MB') or a number representing bytes.
2027
+ * @param {string|number} [updates[].health.maxYoungGeneration] - The maximum young generation memory for the service. Can be a valid memory string (e.g., '128MB') or a number representing bytes.
2028
+ *
2029
+ * @returns {Promise<Array<Object>>} - A promise that resolves to an array of reports for each service, detailing the success or failure of the operations:
2030
+ * - `service`: The service ID.
2031
+ * - `workers`: The workers' update report, including the current, new number of workers, started workers, and success status.
2032
+ * - `health`: The health update report, showing the current and new heap settings, updated workers, and success status.
2033
+ *
2034
+ * @example
2035
+ * await runtime.updateServicesResources([
2036
+ * { service: 'service-1', workers: 2, health: { maxHeapTotal: '1G', maxYoungGeneration: '128 MB' } },
2037
+ * { service: 'service-2', health: { maxHeapTotal: '1G' } },
2038
+ * { service: 'service-3', workers: 2 },
2039
+ * ])
2040
+ *
2041
+ * In this example:
2042
+ * - `service-1` will have 2 workers and updated heap memory configurations.
2043
+ * - `service-2` will have updated heap memory settings (without changing workers).
2044
+ * - `service-3` will have its workers set to 2 but no change in memory settings.
2045
+ *
2046
+ * @throws {InvalidArgumentError} - Throws if any update parameter is invalid, such as:
2047
+ * - Missing service ID.
2048
+ * - Invalid worker count (not a positive integer).
2049
+ * - Invalid memory size format for `maxHeapTotal` or `maxYoungGeneration`.
2050
+ * @throws {ServiceNotFoundError} - Throws if the specified service ID does not exist in the current service configuration.
2051
+ */
1876
2052
  async updateServicesResources (updates) {
1877
- if (this.#status === 'stopping' || this.#status === 'closed') return
2053
+ if (this.#status === 'stopping' || this.#status === 'closed') {
2054
+ this.logger.warn('Cannot update service resources when the runtime is stopping or closed')
2055
+ return
2056
+ }
2057
+
2058
+ const ups = await this.#validateUpdateServiceResources(updates)
2059
+ const config = this.#configManager.current
2060
+
2061
+ const report = []
2062
+ for (const update of ups) {
2063
+ const { serviceId, config: serviceConfig, workers, health, currentWorkers, currentHealth } = update
2064
+
2065
+ if (workers && health) {
2066
+ const r = await this.#updateServiceWorkersAndHealth(serviceId, config, serviceConfig, workers, health, currentWorkers, currentHealth)
2067
+ report.push({
2068
+ service: serviceId,
2069
+ workers: r.workers,
2070
+ health: r.health
2071
+ })
2072
+ } else if (health) {
2073
+ const r = await this.#updateServiceHealth(serviceId, config, serviceConfig, currentWorkers, currentHealth, health)
2074
+ report.push({
2075
+ service: serviceId,
2076
+ health: r.health
2077
+ })
2078
+ } else if (workers) {
2079
+ const r = await this.#updateServiceWorkers(serviceId, config, serviceConfig, workers, currentWorkers)
2080
+ report.push({
2081
+ service: serviceId,
2082
+ workers: r.workers
2083
+ })
2084
+ }
2085
+ }
2086
+
2087
+ return report
2088
+ }
1878
2089
 
2090
+ async #validateUpdateServiceResources (updates) {
1879
2091
  if (!Array.isArray(updates)) {
1880
2092
  throw new errors.InvalidArgumentError('updates', 'must be an array')
1881
2093
  }
@@ -1884,47 +2096,207 @@ class Runtime extends EventEmitter {
1884
2096
  }
1885
2097
 
1886
2098
  const config = this.#configManager.current
1887
-
2099
+ const validatedUpdates = []
1888
2100
  for (const update of updates) {
1889
- const { service: serviceId, workers } = update
2101
+ const { service: serviceId } = update
1890
2102
 
1891
- if (typeof workers !== 'number') {
1892
- throw new errors.InvalidArgumentError('workers', 'must be a number')
1893
- }
1894
2103
  if (!serviceId) {
1895
2104
  throw new errors.InvalidArgumentError('service', 'must be a string')
1896
2105
  }
1897
- if (workers <= 0) {
1898
- throw new errors.InvalidArgumentError('workers', 'must be greater than 0')
1899
- }
1900
- if (workers > MAX_WORKERS) {
1901
- throw new errors.InvalidArgumentError('workers', `must be less than ${MAX_WORKERS}`)
1902
- }
1903
2106
  const serviceConfig = config.services.find(s => s.id === serviceId)
1904
2107
  if (!serviceConfig) {
1905
2108
  throw new errors.ServiceNotFoundError(serviceId, Array.from(this.#servicesIds).join(', '))
1906
2109
  }
1907
2110
 
1908
- const { workers: currentWorkers } = await this.getServiceResourcesInfo(serviceId)
1909
- if (currentWorkers === workers) {
1910
- this.logger.warn({ serviceId, workers }, 'No change in the number of workers for service')
1911
- continue
2111
+ const { workers: currentWorkers, health: currentHealth } = await this.getServiceResourcesInfo(serviceId)
2112
+
2113
+ let workers
2114
+ if (update.workers !== undefined) {
2115
+ if (typeof update.workers !== 'number') {
2116
+ throw new errors.InvalidArgumentError('workers', 'must be a number')
2117
+ }
2118
+ if (update.workers <= 0) {
2119
+ throw new errors.InvalidArgumentError('workers', 'must be greater than 0')
2120
+ }
2121
+ if (update.workers > MAX_WORKERS) {
2122
+ throw new errors.InvalidArgumentError('workers', `must be less than ${MAX_WORKERS}`)
2123
+ }
2124
+
2125
+ if (currentWorkers === update.workers) {
2126
+ this.logger.warn({ serviceId, workers: update.workers }, 'No change in the number of workers for service')
2127
+ } else {
2128
+ workers = update.workers
2129
+ }
1912
2130
  }
1913
2131
 
1914
- if (currentWorkers < workers) {
1915
- await this.#updateWorkerCount(serviceId, workers)
2132
+ let maxHeapTotal, maxYoungGeneration
2133
+ if (update.health) {
2134
+ if (update.health.maxHeapTotal !== undefined) {
2135
+ if (typeof update.health.maxHeapTotal === 'string') {
2136
+ try {
2137
+ maxHeapTotal = parseMemorySize(update.health.maxHeapTotal)
2138
+ } catch {
2139
+ throw new errors.InvalidArgumentError('maxHeapTotal', 'must be a valid memory size')
2140
+ }
2141
+ } else if (typeof update.health.maxHeapTotal === 'number') {
2142
+ maxHeapTotal = update.health.maxHeapTotal
2143
+ if (update.health.maxHeapTotal <= 0) {
2144
+ throw new errors.InvalidArgumentError('maxHeapTotal', 'must be greater than 0')
2145
+ }
2146
+ } else {
2147
+ throw new errors.InvalidArgumentError('maxHeapTotal', 'must be a number or a string representing a memory size')
2148
+ }
2149
+
2150
+ if (currentHealth.maxHeapTotal === maxHeapTotal) {
2151
+ this.logger.warn({ serviceId, maxHeapTotal }, 'No change in the max heap total for service')
2152
+ maxHeapTotal = undefined
2153
+ }
2154
+ }
2155
+
2156
+ if (update.health.maxYoungGeneration !== undefined) {
2157
+ if (typeof update.health.maxYoungGeneration === 'string') {
2158
+ try {
2159
+ maxYoungGeneration = parseMemorySize(update.health.maxYoungGeneration)
2160
+ } catch {
2161
+ throw new errors.InvalidArgumentError('maxYoungGeneration', 'must be a valid memory size')
2162
+ }
2163
+ } else if (typeof update.health.maxYoungGeneration === 'number') {
2164
+ maxYoungGeneration = update.health.maxYoungGeneration
2165
+ if (update.health.maxYoungGeneration <= 0) {
2166
+ throw new errors.InvalidArgumentError('maxYoungGeneration', 'must be greater than 0')
2167
+ }
2168
+ } else {
2169
+ throw new errors.InvalidArgumentError('maxYoungGeneration', 'must be a number or a string representing a memory size')
2170
+ }
2171
+
2172
+ if (currentHealth.maxYoungGeneration && currentHealth.maxYoungGeneration === maxYoungGeneration) {
2173
+ this.logger.warn({ serviceId, maxYoungGeneration }, 'No change in the max young generation for service')
2174
+ maxYoungGeneration = undefined
2175
+ }
2176
+ }
2177
+ }
2178
+
2179
+ if (workers || maxHeapTotal || maxYoungGeneration) {
2180
+ let health
2181
+ if (maxHeapTotal || maxYoungGeneration) {
2182
+ health = { }
2183
+ if (maxHeapTotal) {
2184
+ health.maxHeapTotal = maxHeapTotal
2185
+ }
2186
+ if (maxYoungGeneration) {
2187
+ health.maxYoungGeneration = maxYoungGeneration
2188
+ }
2189
+ }
2190
+ validatedUpdates.push({ serviceId, config: serviceConfig, workers, health, currentWorkers, currentHealth })
2191
+ }
2192
+ }
2193
+
2194
+ return validatedUpdates
2195
+ }
2196
+
2197
+ async #updateServiceWorkersAndHealth (serviceId, config, serviceConfig, workers, health, currentWorkers, currentHealth) {
2198
+ if (currentWorkers > workers) {
2199
+ // stop workers
2200
+ const reportWorkers = await this.#updateServiceWorkers(serviceId, config, serviceConfig, workers, currentWorkers)
2201
+ // update heap for current workers
2202
+ const reportHealth = await this.#updateServiceHealth(serviceId, config, serviceConfig, workers, currentHealth, health)
2203
+
2204
+ return { workers: reportWorkers, health: reportHealth }
2205
+ } else {
2206
+ // update service heap
2207
+ await this.#updateServiceConfigHealth(serviceId, health)
2208
+ // start new workers with new heap
2209
+ const reportWorkers = await this.#updateServiceWorkers(serviceId, config, serviceConfig, workers, currentWorkers)
2210
+ // update heap for current workers
2211
+ const reportHealth = await this.#updateServiceHealth(serviceId, config, serviceConfig, currentWorkers, currentHealth, health, false)
2212
+
2213
+ return { workers: reportWorkers, health: reportHealth }
2214
+ }
2215
+ }
2216
+
2217
+ async #updateServiceHealth (serviceId, config, serviceConfig, currentWorkers, currentHealth, health, updateConfig = true) {
2218
+ const report = {
2219
+ current: currentHealth,
2220
+ new: health,
2221
+ updated: []
2222
+ }
2223
+ try {
2224
+ if (updateConfig) {
2225
+ await this.#updateServiceConfigHealth(serviceId, health)
2226
+ }
2227
+
2228
+ for (let i = 0; i < currentWorkers; i++) {
2229
+ this.logger.info({ health: { current: currentHealth, new: health } }, `Restarting service "${serviceId}" worker ${i} to update config health heap...`)
2230
+
2231
+ const worker = await this.#getWorkerById(serviceId, i)
2232
+ if (health.maxHeapTotal) { worker[kConfig].health.maxHeapTotal = health.maxHeapTotal }
2233
+ if (health.maxYoungGeneration) { worker[kConfig].health.maxYoungGeneration = health.maxYoungGeneration }
2234
+
2235
+ await this.#replaceWorker(config, serviceConfig, currentWorkers, serviceId, i, worker)
2236
+ report.updated.push(i)
2237
+ this.logger.info({ health: { current: currentHealth, new: health } }, `Restarted service "${serviceId}" worker ${i}`)
2238
+ }
2239
+ report.success = true
2240
+ } catch (err) {
2241
+ if (report.updated.length < 1) {
2242
+ this.logger.error({ err }, 'Cannot update service health heap, no worker updated')
2243
+ await this.#updateServiceConfigHealth(serviceId, currentHealth)
2244
+ } else {
2245
+ this.logger.error({ err }, `Cannot update service health heap, updated workers: ${report.updated.length} out of ${currentWorkers}`)
2246
+ }
2247
+ report.success = false
2248
+ }
2249
+ return report
2250
+ }
2251
+
2252
+ async #updateServiceWorkers (serviceId, config, serviceConfig, workers, currentWorkers) {
2253
+ const report = {
2254
+ current: currentWorkers,
2255
+ new: workers
2256
+ }
2257
+ if (currentWorkers < workers) {
2258
+ report.started = []
2259
+ try {
2260
+ await this.#updateServiceConfigWorkers(serviceId, workers)
1916
2261
  for (let i = currentWorkers; i < workers; i++) {
1917
2262
  await this.#setupWorker(config, serviceConfig, workers, serviceId, i)
1918
2263
  await this.#startWorker(config, serviceConfig, workers, serviceId, i, false, 0)
2264
+ report.started.push(i)
1919
2265
  }
1920
- } else {
2266
+ report.success = true
2267
+ } catch (err) {
2268
+ if (report.started.length < 1) {
2269
+ this.logger.error({ err }, 'Cannot start service workers, no worker started')
2270
+ await this.#updateServiceConfigWorkers(serviceId, currentWorkers)
2271
+ } else {
2272
+ this.logger.error({ err }, `Cannot start service workers, started workers: ${report.started.length} out of ${workers}`)
2273
+ await this.#updateServiceConfigWorkers(serviceId, currentWorkers + report.started.length)
2274
+ }
2275
+ report.success = false
2276
+ }
2277
+ } else {
2278
+ // keep the current workers count until all the service workers are all stopped
2279
+ report.stopped = []
2280
+ try {
1921
2281
  for (let i = currentWorkers - 1; i >= workers; i--) {
1922
- // keep the current workers count until the workers are stopped
1923
- await this.#stopWorker(currentWorkers, serviceId, i, false)
2282
+ const worker = await this.#getWorkerById(serviceId, i, false, false)
2283
+ await sendViaITC(worker, 'removeFromMesh')
2284
+ await this.#stopWorker(currentWorkers, serviceId, i, false, worker)
2285
+ report.stopped.push(i)
2286
+ }
2287
+ await this.#updateServiceConfigWorkers(serviceId, workers)
2288
+ report.success = true
2289
+ } catch (err) {
2290
+ if (report.stopped.length < 1) {
2291
+ this.logger.error({ err }, 'Cannot stop service workers, no worker stopped')
2292
+ } else {
2293
+ this.logger.error({ err }, `Cannot stop service workers, stopped workers: ${report.stopped.length} out of ${workers}`)
2294
+ await this.#updateServiceConfigWorkers(serviceId, currentWorkers - report.stopped)
1924
2295
  }
1925
- await this.#updateWorkerCount(serviceId, workers)
2296
+ report.success = false
1926
2297
  }
1927
2298
  }
2299
+ return report
1928
2300
  }
1929
2301
  }
1930
2302