@platformatic/runtime 3.71.1-alpha.0 → 4.0.0-alpha.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.
@@ -1,256 +0,0 @@
1
- export const scaleUpTimeWindow = 10_000
2
- export const scaleDownTimeWindow = 60_000
3
-
4
- export class ScalingAlgorithm {
5
- #maxTotalWorkers
6
- #appsMetrics
7
- #appsConfigs
8
- #scaleUpELU
9
- #scaleDownELU
10
-
11
- constructor (options = {}) {
12
- this.#maxTotalWorkers = options.maxTotalWorkers ?? Infinity
13
- this.#appsConfigs = options.applications ?? {}
14
- this.#appsMetrics = {}
15
- this.#scaleUpELU = options.scaleUpELU ?? 0.8
16
- this.#scaleDownELU = options.scaleDownELU ?? 0.2
17
- }
18
-
19
- addApplication (id, config = {}) {
20
- config.scaleUpELU ??= this.#scaleUpELU
21
- config.scaleDownELU ??= this.#scaleDownELU
22
-
23
- this.#appsConfigs[id] = config
24
- this.#appsMetrics[id] ??= {}
25
- }
26
-
27
- removeApplication (id) {
28
- delete this.#appsConfigs[id]
29
- delete this.#appsMetrics[id]
30
- }
31
-
32
- addWorkerHealthInfo (healthInfo) {
33
- const { workerId, applicationId, elu, heapUsed } = healthInfo
34
- const timestamp = Date.now()
35
-
36
- if (!this.#appsMetrics[applicationId]) {
37
- throw new Error(`Missing application "${applicationId}" in the scaling algorithm.`)
38
- }
39
-
40
- if (!this.#appsMetrics[applicationId][workerId]) {
41
- this.#appsMetrics[applicationId][workerId] = []
42
- }
43
- this.#appsMetrics[applicationId][workerId].push({
44
- elu,
45
- timestamp,
46
- heapUsed
47
- })
48
- this.#removeOutdatedAppELUs(applicationId)
49
- }
50
-
51
- getRecommendations (appsWorkersInfo, options = {}) {
52
- let totalWorkersCount = 0
53
- let totalAvailableMemory = options.availableMemory ?? Infinity
54
-
55
- const appsInfo = []
56
-
57
- for (const applicationId in appsWorkersInfo) {
58
- const workersCount = appsWorkersInfo[applicationId]
59
-
60
- const { heapUsed } = this.#calculateAppAvgMetrics(applicationId)
61
-
62
- appsInfo.push({
63
- applicationId,
64
- workersCount,
65
- avgHeapUsed: heapUsed
66
- })
67
-
68
- totalWorkersCount += workersCount
69
- }
70
-
71
- const recommendations = []
72
-
73
- for (const { applicationId, workersCount, avgHeapUsed } of appsInfo) {
74
- const appMinWorkers = this.#appsConfigs[applicationId]?.minWorkers ?? 1
75
- const appMaxWorkers = this.#appsConfigs[applicationId]?.maxWorkers ?? this.#maxTotalWorkers
76
-
77
- if (workersCount < appMinWorkers) {
78
- recommendations.push({
79
- applicationId,
80
- workersCount: appMinWorkers,
81
- direction: 'up'
82
- })
83
-
84
- const newWorkersCount = appMinWorkers - workersCount
85
- totalWorkersCount += newWorkersCount
86
- totalAvailableMemory -= newWorkersCount * avgHeapUsed
87
- continue
88
- }
89
-
90
- if (workersCount > appMaxWorkers) {
91
- recommendations.push({
92
- applicationId,
93
- workersCount: appMaxWorkers,
94
- direction: 'down'
95
- })
96
-
97
- const removedWorkersCount = workersCount - appMaxWorkers
98
- totalWorkersCount -= removedWorkersCount
99
- totalAvailableMemory += removedWorkersCount * avgHeapUsed
100
- continue
101
- }
102
-
103
- if (workersCount > appMinWorkers) {
104
- const recommendation = this.#getApplicationScaleRecommendation(applicationId)
105
- if (recommendation.recommendation === 'scaleDown') {
106
- recommendations.push({
107
- applicationId,
108
- workersCount: workersCount - 1,
109
- direction: 'down'
110
- })
111
-
112
- const removedWorkersCount = 1
113
- totalWorkersCount -= removedWorkersCount
114
- totalAvailableMemory += removedWorkersCount * avgHeapUsed
115
- }
116
- }
117
- }
118
-
119
- if (totalWorkersCount < this.#maxTotalWorkers) {
120
- let scaleUpCandidate = null
121
-
122
- for (const { applicationId, workersCount, avgHeapUsed } of appsInfo) {
123
- const appMaxWorkers = this.#appsConfigs[applicationId]?.maxWorkers ?? this.#maxTotalWorkers
124
- if (workersCount >= appMaxWorkers) continue
125
- if (avgHeapUsed >= totalAvailableMemory) continue
126
-
127
- const isScaled = recommendations.some(r => r.applicationId === applicationId)
128
- if (isScaled) continue
129
-
130
- const recommendation = this.#getApplicationScaleRecommendation(applicationId)
131
- if (recommendation.recommendation !== 'scaleUp') continue
132
-
133
- if (
134
- !scaleUpCandidate ||
135
- recommendation.scaleUpELU > scaleUpCandidate.scaleUpELU ||
136
- (recommendation.scaleUpELU === scaleUpCandidate.scaleUpELU && workersCount < scaleUpCandidate.workersCount)
137
- ) {
138
- scaleUpCandidate = {
139
- applicationId,
140
- workersCount,
141
- heapUsed: recommendation.avgHeapUsage,
142
- elu: recommendation.scaleUpELU
143
- }
144
- }
145
- }
146
-
147
- if (scaleUpCandidate) {
148
- recommendations.push({
149
- applicationId: scaleUpCandidate.applicationId,
150
- workersCount: scaleUpCandidate.workersCount + 1,
151
- direction: 'up'
152
- })
153
- totalWorkersCount++
154
- totalAvailableMemory -= scaleUpCandidate.heapUsed
155
- }
156
- }
157
-
158
- return recommendations
159
- }
160
-
161
- #calculateAppAvgMetrics (applicationId, options = {}) {
162
- this.#removeOutdatedAppELUs(applicationId)
163
-
164
- const appMetrics = this.#appsMetrics[applicationId]
165
- if (!appMetrics) return { elu: 0, heapUsed: 0 }
166
-
167
- const defaultTimeWindow = this.#getMetricsTimeWindow()
168
- const timeWindow = options.timeWindow ?? defaultTimeWindow
169
-
170
- let eluSum = 0
171
- let heapUsedSum = 0
172
- let count = 0
173
-
174
- const now = Date.now()
175
-
176
- for (const workerId in appMetrics) {
177
- const workerMetrics = appMetrics[workerId]
178
-
179
- let workerELUSum = 0
180
- let workerHeapUsedSum = 0
181
- let metricCount = 0
182
-
183
- for (const metric of workerMetrics) {
184
- if (metric.timestamp < now - timeWindow) continue
185
- workerELUSum += metric.elu
186
- workerHeapUsedSum += metric.heapUsed
187
- metricCount++
188
- }
189
-
190
- if (metricCount === 0) continue
191
-
192
- eluSum += workerELUSum / metricCount
193
- heapUsedSum += workerHeapUsedSum / metricCount
194
- count++
195
- }
196
-
197
- const elu = Math.round((eluSum / count) * 100) / 100
198
- const heapUsed = Math.round((heapUsedSum / count) * 100) / 100
199
- return { elu, heapUsed }
200
- }
201
-
202
- #removeOutdatedAppELUs (applicationId) {
203
- const appELUs = this.#appsMetrics[applicationId]
204
- if (!appELUs) return
205
-
206
- const now = Date.now()
207
- const timeWindow = this.#getMetricsTimeWindow()
208
-
209
- for (const workerId in appELUs) {
210
- const workerELUs = appELUs[workerId]
211
-
212
- let firstValidIndex = -1
213
- for (let i = 0; i < workerELUs.length; i++) {
214
- const timestamp = workerELUs[i].timestamp
215
- if (timestamp >= now - timeWindow) {
216
- firstValidIndex = i
217
- break
218
- }
219
- }
220
-
221
- if (firstValidIndex > 0) {
222
- // Remove all outdated entries before the first valid one
223
- workerELUs.splice(0, firstValidIndex)
224
- } else if (firstValidIndex === -1) {
225
- // All entries are outdated, clear the array
226
- workerELUs.length = 0
227
- }
228
-
229
- // If there are no more workerELUs, remove the workerId
230
- if (workerELUs.length === 0) {
231
- delete appELUs[workerId]
232
- }
233
- }
234
- }
235
-
236
- #getMetricsTimeWindow () {
237
- return Math.max(scaleUpTimeWindow, scaleDownTimeWindow) * 1000
238
- }
239
-
240
- #getApplicationScaleRecommendation (applicationId) {
241
- const { elu: scaleUpELU } = this.#calculateAppAvgMetrics(applicationId, { timeWindow: scaleUpTimeWindow })
242
- const { elu: scaleDownELU } = this.#calculateAppAvgMetrics(applicationId, { timeWindow: scaleDownTimeWindow })
243
- const { heapUsed: avgHeapUsage } = this.#calculateAppAvgMetrics(applicationId)
244
- const config = this.#appsConfigs[applicationId]
245
-
246
- let recommendation = null
247
- if (scaleUpELU > config.scaleUpELU) {
248
- recommendation = 'scaleUp'
249
- }
250
- if (scaleDownELU < config.scaleDownELU) {
251
- recommendation = 'scaleDown'
252
- }
253
-
254
- return { recommendation, scaleUpELU, scaleDownELU, avgHeapUsage }
255
- }
256
- }
@@ -1,249 +0,0 @@
1
- import { features } from '@platformatic/foundation'
2
- import { availableParallelism } from 'node:os'
3
- import { getMemoryInfo } from './metrics.js'
4
- import { ScalingAlgorithm } from './scaling-algorithm.js'
5
- import { kApplicationId, kId, kLastWorkerScalerELU, kWorkerStartTime, kWorkerStatus } from './worker/symbols.js'
6
-
7
- const healthCheckInterval = 1000
8
- export const kOriginalWorkers = Symbol('plt.runtime.application.dynamicWorkersScalerOriginalWorkers')
9
-
10
- const defaultCooldown = 20_000
11
- const defaultGracePeriod = 30_000
12
- const scaleIntervalPeriod = 60_000
13
-
14
- export class DynamicWorkersScaler {
15
- #status
16
- #runtime
17
- #algorithm
18
-
19
- #maxTotalMemory
20
- #maxTotalWorkers
21
- #maxWorkers
22
- #minWorkers
23
- #scaleUpELU
24
- #scaleDownELU
25
- #cooldown
26
- #gracePeriod
27
-
28
- #initialUpdates
29
- #memoryInfo
30
- #healthCheckTimeout
31
- #checkScalingInterval
32
- #isScaling
33
- #lastScaling
34
- #appsConfigs
35
-
36
- constructor (runtime, config) {
37
- this.#runtime = runtime
38
-
39
- this.#maxTotalMemory = config.maxMemory // This is defaulted in start()
40
- this.#maxTotalWorkers = config.total ?? availableParallelism()
41
- this.#maxWorkers = config.maximum ?? this.#maxTotalWorkers
42
- this.#minWorkers = config.minimum ?? 1
43
- this.#scaleUpELU = config.scaleUpELU ?? 0.8
44
- this.#scaleDownELU = config.scaleDownELU ?? 0.2
45
- this.#cooldown = config.cooldown ?? defaultCooldown
46
- this.#gracePeriod = config.gracePeriod ?? defaultGracePeriod
47
-
48
- this.#algorithm = new ScalingAlgorithm({
49
- maxTotalWorkers: this.#maxTotalWorkers,
50
- scaleUpELU: this.#scaleUpELU,
51
- scaleDownELU: this.#scaleDownELU
52
- })
53
-
54
- this.#isScaling = false
55
- this.#lastScaling = 0
56
- this.#initialUpdates = []
57
- this.#status = 'init'
58
- this.#appsConfigs = {}
59
- }
60
-
61
- getConfig () {
62
- return {
63
- maxTotalMemory: this.#maxTotalMemory,
64
- maxTotalWorkers: this.#maxTotalWorkers,
65
- maxWorkers: this.#maxWorkers,
66
- minWorkers: this.#minWorkers,
67
- cooldown: this.#cooldown,
68
- gracePeriod: this.#gracePeriod
69
- }
70
- }
71
-
72
- async start () {
73
- this.#memoryInfo = await getMemoryInfo()
74
- this.#maxTotalMemory ??= this.#memoryInfo.total * 0.9
75
-
76
- this.#checkScalingInterval = setInterval(this.#checkScaling.bind(this), scaleIntervalPeriod)
77
- this.#healthCheckTimeout = setTimeout(this.#chechHealth.bind(this), healthCheckInterval)
78
-
79
- if (this.#initialUpdates.length > 0) {
80
- await this.#runtime.updateApplicationsResources(this.#initialUpdates)
81
- this.#initialUpdates = []
82
- }
83
-
84
- this.#status = 'started'
85
- }
86
-
87
- stop () {
88
- clearTimeout(this.#healthCheckTimeout)
89
- clearInterval(this.#checkScalingInterval)
90
- this.#status = 'stopped'
91
- }
92
-
93
- async add (application) {
94
- const config = {}
95
-
96
- if (application.entrypoint && !features.node.reusePort) {
97
- this.#runtime.logger.warn(
98
- `The "${application.id}" application cannot be scaled because it is an entrypoint and the "reusePort" feature is not available in your OS.`
99
- )
100
-
101
- config.minWorkers = 1
102
- config.maxWorkers = 1
103
- } else if (application.workers.dynamic === false) {
104
- this.#runtime.logger.warn(
105
- `The "${application.id}" application cannot be scaled because it has a fixed number of workers (${application.workers.static}).`
106
- )
107
-
108
- config.minWorkers = application.workers.static
109
- config.maxWorkers = application.workers.static
110
- } else {
111
- config.minWorkers = application.workers.minimum
112
- config.maxWorkers = application.workers.maximum
113
- config.scaleUpELU = application.workers.scaleUpELU
114
- config.scaleDownELU = application.workers.scaleDownELU
115
- }
116
-
117
- config.minWorkers ??= this.#minWorkers
118
- config.maxWorkers ??= this.#maxWorkers
119
- config.scaleUpELU ??= this.#scaleUpELU
120
- config.scaleDownELU ??= this.#scaleDownELU
121
-
122
- if (config.minWorkers > 1) {
123
- const update = { application: application.id, workers: config.minWorkers }
124
- this.#initialUpdates.push(update)
125
- }
126
-
127
- this.#appsConfigs[application.id] = config
128
- this.#algorithm.addApplication(application.id, config)
129
- }
130
-
131
- async applyPendingUpdate (applicationId) {
132
- if (this.#status !== 'started') {
133
- return
134
- }
135
-
136
- const update = this.#initialUpdates.find(update => update.application === applicationId)
137
- if (!update) {
138
- return
139
- }
140
-
141
- await this.#runtime.updateApplicationsResources([update])
142
- this.#initialUpdates = this.#initialUpdates.filter(pending => pending !== update)
143
- }
144
-
145
- remove (applicationId) {
146
- delete this.#appsConfigs[applicationId]
147
- this.#initialUpdates = this.#initialUpdates.filter(update => update.application !== applicationId)
148
- this.#algorithm.removeApplication(applicationId)
149
- }
150
-
151
- async #chechHealth () {
152
- let shouldCheckForScaling = false
153
-
154
- const now = Date.now()
155
-
156
- const workers = await this.#runtime.getWorkers(true)
157
-
158
- for (const { raw: worker } of Object.values(workers)) {
159
- if (worker[kWorkerStatus] !== 'started' || worker[kWorkerStartTime] + this.#gracePeriod > now) {
160
- continue
161
- }
162
-
163
- try {
164
- const health = await this.#runtime.getWorkerHealth(worker, { previousELU: worker[kLastWorkerScalerELU] })
165
-
166
- if (!health) {
167
- continue
168
- }
169
-
170
- worker[kLastWorkerScalerELU] = health.currentELU
171
-
172
- const workerId = worker[kId]
173
- const applicationId = worker[kApplicationId]
174
- const scaleConfig = this.#appsConfigs[applicationId]
175
- if (!scaleConfig) continue
176
-
177
- this.#algorithm.addWorkerHealthInfo({
178
- workerId,
179
- applicationId,
180
- elu: health.elu,
181
- heapUsed: health.heapUsed,
182
- heapTotal: health.heapTotal
183
- })
184
-
185
- if (health.elu > scaleConfig.scaleUpELU) {
186
- shouldCheckForScaling = true
187
- }
188
- } catch (err) {
189
- this.#runtime.logger.error({ err }, 'Failed to get health for worker')
190
- }
191
- }
192
-
193
- if (shouldCheckForScaling) {
194
- await this.#checkScaling()
195
- }
196
-
197
- this.#healthCheckTimeout.refresh()
198
- }
199
-
200
- async #checkScaling () {
201
- const isInCooldown = Date.now() < this.#lastScaling + this.#cooldown
202
- if (this.#isScaling || isInCooldown) {
203
- return
204
- }
205
-
206
- this.#isScaling = true
207
-
208
- try {
209
- const workersInfo = await this.#runtime.getWorkers()
210
- const mem = await getMemoryInfo({ scope: this.#memoryInfo.scope })
211
-
212
- const appsWorkersInfo = {}
213
- for (const worker of Object.values(workersInfo)) {
214
- if (worker.status === 'exited') {
215
- continue
216
- }
217
-
218
- const applicationId = worker.application
219
- appsWorkersInfo[applicationId] ??= 0
220
- appsWorkersInfo[applicationId]++
221
- }
222
-
223
- const availableMemory = this.#maxTotalMemory - mem.used
224
- const recommendations = this.#algorithm.getRecommendations(appsWorkersInfo, { availableMemory })
225
-
226
- if (recommendations.length > 0) {
227
- await this.#applyRecommendations(recommendations)
228
- this.#lastScaling = Date.now()
229
- }
230
- } catch (err) {
231
- this.#runtime.logger.error({ err }, 'Failed to scale applications')
232
- } finally {
233
- this.#isScaling = false
234
- }
235
- }
236
-
237
- async #applyRecommendations (recommendations) {
238
- const resourcesUpdates = []
239
-
240
- for (const recommendation of recommendations) {
241
- const { applicationId, workersCount, direction } = recommendation
242
- this.#runtime.logger.info(`Scaling ${direction} the "${applicationId}" app to ${workersCount} workers`)
243
-
244
- resourcesUpdates.push({ application: applicationId, workers: workersCount })
245
- }
246
-
247
- return this.#runtime.updateApplicationsResources(resourcesUpdates)
248
- }
249
- }