@platformatic/runtime 3.7.0 → 3.8.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 CHANGED
@@ -332,6 +332,24 @@ export type PlatformaticRuntimeConfig = {
332
332
  [k: string]: unknown;
333
333
  };
334
334
  };
335
+ verticalScaler?: {
336
+ enabled?: boolean;
337
+ maxTotalWorkers?: number;
338
+ minWorkers?: number;
339
+ maxWorkers?: number;
340
+ scaleUpELU?: number;
341
+ scaleDownELU?: number;
342
+ minELUDiff?: number;
343
+ timeWindowSec?: number;
344
+ cooldownSec?: number;
345
+ scaleIntervalSec?: number;
346
+ applications?: {
347
+ [k: string]: {
348
+ minWorkers?: number;
349
+ maxWorkers?: number;
350
+ };
351
+ };
352
+ };
335
353
  inspectorOptions?: {
336
354
  host?: string;
337
355
  port?: number;
package/lib/runtime.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  kTimeout,
10
10
  parseMemorySize
11
11
  } from '@platformatic/foundation'
12
+ import os from 'node:os'
12
13
  import { ITC } from '@platformatic/itc'
13
14
  import fastify from 'fastify'
14
15
  import { EventEmitter, once } from 'node:events'
@@ -44,6 +45,7 @@ import { createSharedStore } from './shared-http-cache.js'
44
45
  import { version } from './version.js'
45
46
  import { sendViaITC, waitEventFromITC } from './worker/itc.js'
46
47
  import { RoundRobinMap } from './worker/round-robin-map.js'
48
+ import ScalingAlgorithm from './scaling-algorithm.js'
47
49
  import {
48
50
  kApplicationId,
49
51
  kConfig,
@@ -179,7 +181,7 @@ export class Runtime extends EventEmitter {
179
181
 
180
182
  const workersConfig = []
181
183
  for (const application of config.applications) {
182
- const count = application.workers ?? this.#config.workers
184
+ const count = application.workers ?? this.#config.workers ?? 1
183
185
  if (count > 1 && application.entrypoint && !features.node.reusePort) {
184
186
  this.logger.warn(
185
187
  `"${application.id}" is set as the entrypoint, but reusePort is not available in your OS; setting workers to 1 instead of ${count}`
@@ -274,6 +276,10 @@ export class Runtime extends EventEmitter {
274
276
  this.startCollectingMetrics()
275
277
  }
276
278
 
279
+ if (this.#config.verticalScaler?.enabled) {
280
+ this.#setupVerticalScaler()
281
+ }
282
+
277
283
  this.#showUrl()
278
284
  return this.#url
279
285
  }
@@ -2434,4 +2440,138 @@ export class Runtime extends EventEmitter {
2434
2440
  throw new MissingPprofCapture()
2435
2441
  }
2436
2442
  }
2443
+
2444
+ #setupVerticalScaler () {
2445
+ const isWorkersFixed = this.#config.workers !== undefined
2446
+ if (isWorkersFixed) return
2447
+
2448
+ const scalerConfig = this.#config.verticalScaler
2449
+
2450
+ scalerConfig.maxTotalWorkers ??= os.availableParallelism()
2451
+ scalerConfig.maxWorkers ??= scalerConfig.maxTotalWorkers
2452
+ scalerConfig.minWorkers ??= 1
2453
+ scalerConfig.cooldownSec ??= 60
2454
+ scalerConfig.scaleUpELU ??= 0.8
2455
+ scalerConfig.scaleDownELU ??= 0.2
2456
+ scalerConfig.minELUDiff ??= 0.2
2457
+ scalerConfig.scaleIntervalSec ??= 60
2458
+ scalerConfig.timeWindowSec ??= 60
2459
+ scalerConfig.applications ??= {}
2460
+
2461
+ const maxTotalWorkers = scalerConfig.maxTotalWorkers
2462
+ const maxWorkers = scalerConfig.maxWorkers
2463
+ const minWorkers = scalerConfig.minWorkers
2464
+ const cooldown = scalerConfig.cooldownSec
2465
+ const scaleUpELU = scalerConfig.scaleUpELU
2466
+ const scaleDownELU = scalerConfig.scaleDownELU
2467
+ const minELUDiff = scalerConfig.minELUDiff
2468
+ const scaleIntervalSec = scalerConfig.scaleIntervalSec
2469
+ const timeWindowSec = scalerConfig.timeWindowSec
2470
+ const applicationsConfigs = scalerConfig.applications
2471
+
2472
+ for (const application of this.#config.applications) {
2473
+ if (application.entrypoint && !features.node.reusePort) {
2474
+ applicationsConfigs[application.id] = {
2475
+ minWorkers: 1,
2476
+ maxWorkers: 1
2477
+ }
2478
+ continue
2479
+ }
2480
+ if (application.workers !== undefined) {
2481
+ applicationsConfigs[application.id] = {
2482
+ minWorkers: application.workers,
2483
+ maxWorkers: application.workers
2484
+ }
2485
+ continue
2486
+ }
2487
+
2488
+ applicationsConfigs[application.id] ??= {}
2489
+ applicationsConfigs[application.id].minWorkers ??= minWorkers
2490
+ applicationsConfigs[application.id].maxWorkers ??= maxWorkers
2491
+ }
2492
+
2493
+ for (const applicationId in applicationsConfigs) {
2494
+ const application = this.#config.applications.find(
2495
+ app => app.id === applicationId
2496
+ )
2497
+ if (!application) {
2498
+ delete applicationsConfigs[applicationId]
2499
+
2500
+ this.logger.warn(
2501
+ `Vertical scaler configuration has a configuration for non-existing application "${applicationId}"`
2502
+ )
2503
+ }
2504
+ }
2505
+
2506
+ const scalingAlgorithm = new ScalingAlgorithm({
2507
+ maxTotalWorkers,
2508
+ scaleUpELU,
2509
+ scaleDownELU,
2510
+ minELUDiff,
2511
+ timeWindowSec,
2512
+ applications: applicationsConfigs
2513
+ })
2514
+
2515
+ this.on('application:worker:health', async (healthInfo) => {
2516
+ if (!healthInfo) {
2517
+ this.logger.error('No health info received')
2518
+ return
2519
+ }
2520
+
2521
+ scalingAlgorithm.addWorkerHealthInfo(healthInfo)
2522
+
2523
+ if (healthInfo.currentHealth.elu > scaleUpELU) {
2524
+ await checkForScaling()
2525
+ }
2526
+ })
2527
+
2528
+ let isScaling = false
2529
+ let lastScaling = 0
2530
+
2531
+ const checkForScaling = async () => {
2532
+ const isInCooldown = Date.now() < lastScaling + cooldown * 1000
2533
+ if (isScaling || isInCooldown) return
2534
+ isScaling = true
2535
+
2536
+ try {
2537
+ const workersInfo = await this.getWorkers()
2538
+
2539
+ const appsWorkersInfo = {}
2540
+ for (const worker of Object.values(workersInfo)) {
2541
+ if (worker.status === 'exited') continue
2542
+
2543
+ const applicationId = worker.application
2544
+ appsWorkersInfo[applicationId] ??= 0
2545
+ appsWorkersInfo[applicationId]++
2546
+ }
2547
+
2548
+ const recommendations = scalingAlgorithm.getRecommendations(appsWorkersInfo)
2549
+ if (recommendations.length > 0) {
2550
+ await applyRecommendations(recommendations)
2551
+ }
2552
+ } catch (err) {
2553
+ this.logger.error({ err }, 'Failed to scale applications')
2554
+ } finally {
2555
+ isScaling = false
2556
+ lastScaling = Date.now()
2557
+ }
2558
+ }
2559
+
2560
+ const applyRecommendations = async (recommendations) => {
2561
+ const resourcesUpdates = []
2562
+ for (const recommendation of recommendations) {
2563
+ const { applicationId, workersCount, direction } = recommendation
2564
+ this.logger.info(`Scaling ${direction} the "${applicationId}" app to ${workersCount} workers`)
2565
+
2566
+ resourcesUpdates.push({
2567
+ application: applicationId,
2568
+ workers: workersCount
2569
+ })
2570
+ }
2571
+ await this.updateApplicationsResources(resourcesUpdates)
2572
+ }
2573
+
2574
+ // Interval for periodic scaling checks
2575
+ setInterval(checkForScaling, scaleIntervalSec * 1000).unref()
2576
+ }
2437
2577
  }
@@ -0,0 +1,179 @@
1
+ class ScalingAlgorithm {
2
+ #scaleUpELU
3
+ #scaleDownELU
4
+ #maxTotalWorkers
5
+ #timeWindowSec
6
+ #appsELUs
7
+ #minELUDiff
8
+ #appsConfigs
9
+
10
+ constructor (options = {}) {
11
+ this.#scaleUpELU = options.scaleUpELU ?? 0.8
12
+ this.#scaleDownELU = options.scaleDownELU ?? 0.2
13
+ this.#maxTotalWorkers = options.maxTotalWorkers
14
+ this.#minELUDiff = options.minELUDiff ?? 0.2
15
+ this.#timeWindowSec = options.timeWindowSec ?? 60
16
+ this.#appsConfigs = options.applications ?? {}
17
+
18
+ this.#appsELUs = {}
19
+ }
20
+
21
+ addWorkerHealthInfo (healthInfo) {
22
+ const workerId = healthInfo.id
23
+ const applicationId = healthInfo.application
24
+ const elu = healthInfo.currentHealth.elu
25
+ const timestamp = Date.now()
26
+
27
+ if (!this.#appsELUs[applicationId]) {
28
+ this.#appsELUs[applicationId] = {}
29
+ }
30
+ if (!this.#appsELUs[applicationId][workerId]) {
31
+ this.#appsELUs[applicationId][workerId] = []
32
+ }
33
+ this.#appsELUs[applicationId][workerId].push({ elu, timestamp })
34
+ this.#removeOutdatedAppELUs(applicationId)
35
+ }
36
+
37
+ getRecommendations (appsWorkersInfo) {
38
+ let totalWorkersCount = 0
39
+ let appsInfo = []
40
+
41
+ for (const applicationId in appsWorkersInfo) {
42
+ const workersCount = appsWorkersInfo[applicationId]
43
+ const elu = this.#calculateAppAvgELU(applicationId)
44
+ appsInfo.push({ applicationId, workersCount, elu })
45
+ totalWorkersCount += workersCount
46
+ }
47
+
48
+ appsInfo = appsInfo.sort(
49
+ (app1, app2) => {
50
+ if (app1.elu > app2.elu) return 1
51
+ if (app1.elu < app2.elu) return -1
52
+ if (app1.workersCount < app2.workersCount) return 1
53
+ if (app1.workersCount > app2.workersCount) return -1
54
+ return 0
55
+ }
56
+ )
57
+
58
+ const recommendations = []
59
+
60
+ for (const { applicationId, elu, workersCount } of appsInfo) {
61
+ const appMinWorkers = this.#appsConfigs[applicationId]?.minWorkers ?? 1
62
+
63
+ if (elu < this.#scaleDownELU && workersCount > appMinWorkers) {
64
+ recommendations.push({
65
+ applicationId,
66
+ workersCount: workersCount - 1,
67
+ direction: 'down'
68
+ })
69
+ totalWorkersCount--
70
+ }
71
+ }
72
+
73
+ for (const scaleUpCandidate of appsInfo.toReversed()) {
74
+ if (scaleUpCandidate.elu < this.#scaleUpELU) break
75
+
76
+ const { applicationId, workersCount } = scaleUpCandidate
77
+
78
+ const appMaxWorkers = this.#appsConfigs[applicationId]?.maxWorkers ?? this.#maxTotalWorkers
79
+ if (workersCount >= appMaxWorkers) continue
80
+
81
+ if (totalWorkersCount >= this.#maxTotalWorkers) {
82
+ let scaleDownCandidate = null
83
+ for (const app of appsInfo) {
84
+ const appMinWorkers = this.#appsConfigs[app.applicationId]?.minWorkers ?? 1
85
+ if (app.workersCount > appMinWorkers) {
86
+ scaleDownCandidate = app
87
+ break
88
+ }
89
+ }
90
+
91
+ if (scaleDownCandidate) {
92
+ const eluDiff = scaleUpCandidate.elu - scaleDownCandidate.elu
93
+ const workersDiff = scaleDownCandidate.workersCount - scaleUpCandidate.workersCount
94
+
95
+ if (eluDiff >= this.#minELUDiff || workersDiff >= 2) {
96
+ recommendations.push({
97
+ applicationId: scaleDownCandidate.applicationId,
98
+ workersCount: scaleDownCandidate.workersCount - 1,
99
+ direction: 'down'
100
+ })
101
+ recommendations.push({
102
+ applicationId,
103
+ workersCount: workersCount + 1,
104
+ direction: 'up'
105
+ })
106
+ }
107
+ }
108
+ } else {
109
+ recommendations.push({
110
+ applicationId,
111
+ workersCount: workersCount + 1,
112
+ direction: 'up'
113
+ })
114
+ totalWorkersCount++
115
+ }
116
+ break
117
+ }
118
+
119
+ return recommendations
120
+ }
121
+
122
+ #calculateAppAvgELU (applicationId) {
123
+ this.#removeOutdatedAppELUs(applicationId)
124
+
125
+ const appELUs = this.#appsELUs[applicationId]
126
+ if (!appELUs) return 0
127
+
128
+ let eluSum = 0
129
+ let eluCount = 0
130
+
131
+ for (const workerId in appELUs) {
132
+ const workerELUs = appELUs[workerId]
133
+ const workerELUSum = workerELUs.reduce(
134
+ (sum, workerELU) => sum + workerELU.elu, 0
135
+ )
136
+ eluSum += workerELUSum / workerELUs.length
137
+ eluCount++
138
+ }
139
+
140
+ if (eluCount === 0) return 0
141
+
142
+ return Math.round(eluSum / eluCount * 100) / 100
143
+ }
144
+
145
+ #removeOutdatedAppELUs (applicationId) {
146
+ const appELUs = this.#appsELUs[applicationId]
147
+ if (!appELUs) return
148
+
149
+ const now = Date.now()
150
+
151
+ for (const workerId in appELUs) {
152
+ const workerELUs = appELUs[workerId]
153
+
154
+ let firstValidIndex = -1
155
+ for (let i = 0; i < workerELUs.length; i++) {
156
+ const timestamp = workerELUs[i].timestamp
157
+ if (timestamp >= now - this.#timeWindowSec * 1000) {
158
+ firstValidIndex = i
159
+ break
160
+ }
161
+ }
162
+
163
+ if (firstValidIndex > 0) {
164
+ // Remove all outdated entries before the first valid one
165
+ workerELUs.splice(0, firstValidIndex)
166
+ } else if (firstValidIndex === -1) {
167
+ // All entries are outdated, clear the array
168
+ workerELUs.length = 0
169
+ }
170
+
171
+ // If there are no more workerELUs, remove the workerId
172
+ if (workerELUs.length === 0) {
173
+ delete appELUs[workerId]
174
+ }
175
+ }
176
+ }
177
+ }
178
+
179
+ export default ScalingAlgorithm
package/lib/schema.js CHANGED
@@ -16,6 +16,18 @@ const runtimeLogger = {
16
16
 
17
17
  schemaComponents.runtimeProperties.logger = runtimeLogger
18
18
 
19
+ schemaComponents.runtimeProperties.verticalScaler.properties.applications = {
20
+ type: 'object',
21
+ additionalProperties: {
22
+ type: 'object',
23
+ properties: {
24
+ minWorkers: { type: 'number', minimum: 1 },
25
+ maxWorkers: { type: 'number', minimum: 1 }
26
+ },
27
+ additionalProperties: false
28
+ }
29
+ }
30
+
19
31
  const platformaticRuntimeSchema = {
20
32
  $id: `https://schemas.platformatic.dev/@platformatic/runtime/${version}.json`,
21
33
  $schema: 'http://json-schema.org/draft-07/schema#',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/runtime",
3
- "version": "3.7.0",
3
+ "version": "3.8.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -34,14 +34,14 @@
34
34
  "typescript": "^5.5.4",
35
35
  "undici-oidc-interceptor": "^0.5.0",
36
36
  "why-is-node-running": "^2.2.2",
37
- "@platformatic/composer": "3.7.0",
38
- "@platformatic/db": "3.7.0",
39
- "@platformatic/gateway": "3.7.0",
40
- "@platformatic/node": "3.7.0",
41
- "@platformatic/sql-graphql": "3.7.0",
42
- "@platformatic/sql-mapper": "3.7.0",
43
- "@platformatic/service": "3.7.0",
44
- "@platformatic/wattpm-pprof-capture": "3.7.0"
37
+ "@platformatic/composer": "3.8.0",
38
+ "@platformatic/db": "3.8.0",
39
+ "@platformatic/gateway": "3.8.0",
40
+ "@platformatic/node": "3.8.0",
41
+ "@platformatic/sql-graphql": "3.8.0",
42
+ "@platformatic/service": "3.8.0",
43
+ "@platformatic/sql-mapper": "3.8.0",
44
+ "@platformatic/wattpm-pprof-capture": "3.8.0"
45
45
  },
46
46
  "dependencies": {
47
47
  "@fastify/accepts": "^5.0.0",
@@ -71,12 +71,12 @@
71
71
  "undici": "^7.0.0",
72
72
  "undici-thread-interceptor": "^0.14.0",
73
73
  "ws": "^8.16.0",
74
- "@platformatic/foundation": "3.7.0",
75
- "@platformatic/basic": "3.7.0",
76
- "@platformatic/itc": "3.7.0",
77
- "@platformatic/metrics": "3.7.0",
78
- "@platformatic/generators": "3.7.0",
79
- "@platformatic/telemetry": "3.7.0"
74
+ "@platformatic/basic": "3.8.0",
75
+ "@platformatic/foundation": "3.8.0",
76
+ "@platformatic/itc": "3.8.0",
77
+ "@platformatic/metrics": "3.8.0",
78
+ "@platformatic/generators": "3.8.0",
79
+ "@platformatic/telemetry": "3.8.0"
80
80
  },
81
81
  "engines": {
82
82
  "node": ">=22.19.0"
package/schema.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.7.0.json",
2
+ "$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.8.0.json",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "title": "Platformatic Runtime Config",
5
5
  "type": "object",
@@ -923,8 +923,7 @@
923
923
  {
924
924
  "type": "string"
925
925
  }
926
- ],
927
- "default": 1
926
+ ]
928
927
  },
929
928
  "workersRestartDelay": {
930
929
  "anyOf": [
@@ -1828,6 +1827,72 @@
1828
1827
  ],
1829
1828
  "additionalProperties": false
1830
1829
  },
1830
+ "verticalScaler": {
1831
+ "type": "object",
1832
+ "properties": {
1833
+ "enabled": {
1834
+ "type": "boolean",
1835
+ "default": true
1836
+ },
1837
+ "maxTotalWorkers": {
1838
+ "type": "number",
1839
+ "minimum": 1
1840
+ },
1841
+ "minWorkers": {
1842
+ "type": "number",
1843
+ "minimum": 1
1844
+ },
1845
+ "maxWorkers": {
1846
+ "type": "number",
1847
+ "minimum": 1
1848
+ },
1849
+ "scaleUpELU": {
1850
+ "type": "number",
1851
+ "minimum": 0,
1852
+ "maximum": 1
1853
+ },
1854
+ "scaleDownELU": {
1855
+ "type": "number",
1856
+ "minimum": 0,
1857
+ "maximum": 1
1858
+ },
1859
+ "minELUDiff": {
1860
+ "type": "number",
1861
+ "minimum": 0,
1862
+ "maximum": 1
1863
+ },
1864
+ "timeWindowSec": {
1865
+ "type": "number",
1866
+ "minimum": 0
1867
+ },
1868
+ "cooldownSec": {
1869
+ "type": "number",
1870
+ "minimum": 0
1871
+ },
1872
+ "scaleIntervalSec": {
1873
+ "type": "number",
1874
+ "minimum": 0
1875
+ },
1876
+ "applications": {
1877
+ "type": "object",
1878
+ "additionalProperties": {
1879
+ "type": "object",
1880
+ "properties": {
1881
+ "minWorkers": {
1882
+ "type": "number",
1883
+ "minimum": 1
1884
+ },
1885
+ "maxWorkers": {
1886
+ "type": "number",
1887
+ "minimum": 1
1888
+ }
1889
+ },
1890
+ "additionalProperties": false
1891
+ }
1892
+ }
1893
+ },
1894
+ "additionalProperties": false
1895
+ },
1831
1896
  "inspectorOptions": {
1832
1897
  "type": "object",
1833
1898
  "properties": {