@platformatic/runtime 3.72.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.
@@ -0,0 +1,378 @@
1
+ import { readMemory } from './memory.js'
2
+ import { PredictiveApplicationScaler } from './predictive-scaling.js'
3
+ import { kWorkerStartTime, kWorkerStatus } from './worker/symbols.js'
4
+
5
+ // Bump on any change to the shape returned by getDiagnostics().
6
+ export const DIAGNOSTICS_VERSION = 1
7
+
8
+ export class PredictiveWorkersScaler {
9
+ #runtime
10
+ #config
11
+ #appConfigs = new Map()
12
+ #appScalers = new Map()
13
+ #processTimer
14
+ #started = false
15
+ #isProcessing = false
16
+ #maxTotalWorkers
17
+ #maxTotalMemory
18
+ #onHealthMetrics
19
+ #onWorkerStarted
20
+ #onWorkerExited
21
+ #onWorkersUpdated
22
+
23
+ constructor (runtime, config) {
24
+ this.#runtime = runtime
25
+ this.#config = config
26
+ this.#processTimer = null
27
+ this.#maxTotalWorkers = config.total
28
+ this.#maxTotalMemory = config.maxMemory
29
+
30
+ this.#onHealthMetrics = this.#handleHealthMetrics.bind(this)
31
+ this.#onWorkerStarted = this.#handleWorkerStarted.bind(this)
32
+ this.#onWorkerExited = this.#handleWorkerExited.bind(this)
33
+ this.#onWorkersUpdated = this.#handleWorkersUpdated.bind(this)
34
+ }
35
+
36
+ async start () {
37
+ const { total } = await readMemory()
38
+ this.#maxTotalMemory ??= total * 0.9
39
+
40
+ this.#runtime.on('application:worker:health:metrics', this.#onHealthMetrics)
41
+ this.#runtime.on('application:worker:started', this.#onWorkerStarted)
42
+ this.#runtime.on('application:worker:exited', this.#onWorkerExited)
43
+ this.#runtime.on('application:resources:workers:updated', this.#onWorkersUpdated)
44
+
45
+ // Initial workers start before the scaler subscribes. Read their lifetimes
46
+ // from the runtime instead of inferring them from the first metric event.
47
+ const workers = await this.#getLiveWorkers()
48
+ for (const [id, { application, raw }] of workers) {
49
+ if (
50
+ raw[kWorkerStatus] !== 'started' &&
51
+ raw[kWorkerStatus] !== 'stopping'
52
+ ) continue
53
+
54
+ const startTime = raw[kWorkerStartTime]
55
+ // A worker stopped before completing startup has no running lifetime.
56
+ if (startTime === undefined) continue
57
+
58
+ const applicationScaler = this.#appScalers.get(application)
59
+ applicationScaler?.addWorker(id, startTime)
60
+ }
61
+
62
+ this.#started = true
63
+
64
+ this.#processTimer = setInterval(
65
+ () => this.#process(),
66
+ this.#config.processIntervalMs
67
+ ).unref()
68
+ }
69
+
70
+ stop () {
71
+ this.#started = false
72
+ clearInterval(this.#processTimer)
73
+
74
+ this.#runtime.off('application:worker:health:metrics', this.#onHealthMetrics)
75
+ this.#runtime.off('application:worker:started', this.#onWorkerStarted)
76
+ this.#runtime.off('application:worker:exited', this.#onWorkerExited)
77
+ this.#runtime.off('application:resources:workers:updated', this.#onWorkersUpdated)
78
+ }
79
+
80
+ async add (application) {
81
+ const appId = application.id
82
+
83
+ if (application.workers.dynamic === false) {
84
+ this.#runtime.logger.warn(
85
+ `The "${appId}" application cannot be scaled because it has a fixed number of workers (${application.workers.static}).`
86
+ )
87
+ this.remove(appId)
88
+ return
89
+ }
90
+
91
+ const { minimum, maximum } = application.workers
92
+ const scalerConfig = this.#buildApplicationScalerConfig(application.workers)
93
+ const applicationScaler = new PredictiveApplicationScaler(scalerConfig)
94
+ this.#appConfigs.set(appId, { minimum, maximum })
95
+ this.#appScalers.set(appId, applicationScaler)
96
+ }
97
+
98
+ #buildApplicationScalerConfig (workersConfig) {
99
+ const config = this.#config
100
+ const holtConfig = {
101
+ alphaUp: config.alphaUp,
102
+ alphaDown: config.alphaDown,
103
+ betaUp: config.betaUp,
104
+ betaDown: config.betaDown
105
+ }
106
+
107
+ const metrics = {
108
+ elu: {
109
+ threshold: workersConfig.eluThreshold,
110
+ redistributionMs: config.redistributionMs,
111
+ maxValue: 1,
112
+ saturationZone: 0.02,
113
+ ...holtConfig
114
+ }
115
+ }
116
+
117
+ let heapThreshold = null
118
+ if (workersConfig.heapThresholdMb != null) {
119
+ heapThreshold = workersConfig.heapThresholdMb * 1024 * 1024
120
+ }
121
+
122
+ metrics.heap = {
123
+ threshold: heapThreshold,
124
+ redistributionMs: config.redistributionMs,
125
+ ...holtConfig
126
+ }
127
+
128
+ return {
129
+ minimum: workersConfig.minimum,
130
+ maximum: workersConfig.maximum,
131
+ cooldowns: config.cooldowns,
132
+ metrics
133
+ }
134
+ }
135
+
136
+ remove (appId) {
137
+ this.#appConfigs.delete(appId)
138
+ this.#appScalers.delete(appId)
139
+ }
140
+
141
+ // Read-only views for diagnostics, built on request.
142
+ getDiagnostics () {
143
+ return {
144
+ version: DIAGNOSTICS_VERSION,
145
+ now: Date.now(),
146
+ processIntervalMs: this.#config.processIntervalMs,
147
+ maxTotalWorkers: this.#maxTotalWorkers,
148
+ memoryLimit: this.#maxTotalMemory,
149
+ applications: [...this.#appScalers].map(([id, appScaler]) => ({ id, ...appScaler.getDiagnostics() }))
150
+ }
151
+ }
152
+
153
+ #handleWorkersUpdated ({ application, workers }) {
154
+ this.#appScalers.get(application)?.syncWorkersCount(workers)
155
+ }
156
+
157
+ #handleHealthMetrics ({ id, application, currentHealth }) {
158
+ try {
159
+ const applicationScaler = this.#appScalers.get(application)
160
+ if (!applicationScaler || !currentHealth) return
161
+
162
+ const now = Date.now()
163
+ applicationScaler.addSample('elu', id, now, currentHealth.elu)
164
+
165
+ if (currentHealth.heapUsed !== undefined) {
166
+ applicationScaler.addSample('heap', id, now, currentHealth.heapUsed)
167
+ }
168
+ } catch (err) {
169
+ this.#runtime.logger.error({ err }, 'Failed to handle health metrics')
170
+ }
171
+ }
172
+
173
+ #handleWorkerStarted ({ application, worker }) {
174
+ try {
175
+ const applicationScaler = this.#appScalers.get(application)
176
+ if (!applicationScaler) return
177
+
178
+ const workerId = `${application}:${worker}`
179
+ applicationScaler.addWorker(workerId, Date.now())
180
+ } catch (err) {
181
+ this.#runtime.logger.error({ err }, 'Failed to handle worker started')
182
+ }
183
+ }
184
+
185
+ #handleWorkerExited ({ application, worker }) {
186
+ try {
187
+ const applicationScaler = this.#appScalers.get(application)
188
+ if (!applicationScaler) return
189
+
190
+ const workerId = `${application}:${worker}`
191
+ applicationScaler.removeWorker(workerId, Date.now())
192
+ } catch (err) {
193
+ this.#runtime.logger.error({ err }, 'Failed to handle worker exited')
194
+ }
195
+ }
196
+
197
+ async #process () {
198
+ if (this.#isProcessing) return
199
+ this.#isProcessing = true
200
+
201
+ try {
202
+ await this.#processApplications()
203
+ } catch (err) {
204
+ this.#runtime.logger.error({ err }, 'Failed to process dynamic workers scaling')
205
+ } finally {
206
+ this.#isProcessing = false
207
+ }
208
+ }
209
+
210
+ async #processApplications () {
211
+ const workersCountByAppId = await this.#getWorkerCounts()
212
+ if (!this.#started) return
213
+
214
+ const appWorkersTargets = new Map()
215
+ const now = Date.now()
216
+ for (const [appId, appScaler] of this.#appScalers) {
217
+ const target = appScaler.process(now)
218
+ appWorkersTargets.set(appId, target)
219
+ }
220
+
221
+ const syncAndScaleDownUpdates = []
222
+ const scaleUpCandidates = []
223
+
224
+ let totalWorkersCount = 0
225
+ for (const appId in workersCountByAppId) {
226
+ let workersCount = workersCountByAppId[appId]
227
+ const appConfig = this.#appConfigs.get(appId)
228
+ const appScaler = this.#appScalers.get(appId)
229
+
230
+ // Unmanaged workers still occupy capacity.
231
+ if (!appScaler) {
232
+ totalWorkersCount += workersCount
233
+ continue
234
+ }
235
+
236
+ const minimum = appConfig.minimum
237
+ const maximum = appConfig.maximum
238
+ let target = appWorkersTargets.get(appId)
239
+
240
+ if (workersCount < minimum || workersCount > maximum) {
241
+ // Sync workers if they are outside min/max bounds
242
+ if (workersCount < minimum) {
243
+ workersCount = minimum
244
+ }
245
+ if (workersCount > maximum) {
246
+ workersCount = Math.min(maximum, target ?? maximum)
247
+ }
248
+ }
249
+
250
+ if (target === null) {
251
+ target = workersCount
252
+ appWorkersTargets.set(appId, target)
253
+ }
254
+
255
+ if (target < workersCount) {
256
+ // Handle scale-downs first, so we have enough resources to scale up
257
+ workersCount = target
258
+ appScaler.setTargetCount(workersCount)
259
+ }
260
+
261
+ if (target > workersCount) {
262
+ const ratio = (target - workersCount) / workersCount
263
+ scaleUpCandidates.push({ appId, target, ratio })
264
+ }
265
+
266
+ if (workersCount !== workersCountByAppId[appId]) {
267
+ syncAndScaleDownUpdates.push({
268
+ application: appId,
269
+ workers: workersCount
270
+ })
271
+ workersCountByAppId[appId] = workersCount
272
+ }
273
+ totalWorkersCount += workersCount
274
+ }
275
+
276
+ // Apply syncs and scale-downs first to free up resources for scale-ups.
277
+ if (syncAndScaleDownUpdates.length > 0) {
278
+ try {
279
+ await this.#runtime.updateApplicationsResources(syncAndScaleDownUpdates)
280
+ } catch (err) {
281
+ this.#runtime.logger.error({ err }, 'Failed to apply worker corrections and scale-downs')
282
+ }
283
+ if (!this.#started) return
284
+ }
285
+
286
+ if (scaleUpCandidates.length === 0) return
287
+
288
+ scaleUpCandidates.sort((a, b) => b.ratio - a.ratio)
289
+
290
+ if (totalWorkersCount >= this.#maxTotalWorkers) {
291
+ let totalDesiredWorkers = totalWorkersCount
292
+ const scaleUpRequests = []
293
+ for (const { appId, target } of scaleUpCandidates) {
294
+ const workersCount = workersCountByAppId[appId]
295
+ totalDesiredWorkers += target - workersCount
296
+ scaleUpRequests.push(`"${appId}": ${workersCount} -> ${target}`)
297
+ }
298
+ this.#runtime.logger.warn(
299
+ `The maximum number of workers "${this.#maxTotalWorkers}" has been reached. ` +
300
+ `Applications requesting scale-up: ${scaleUpRequests.join(', ')}. Total desired workers: ${totalDesiredWorkers}.`
301
+ )
302
+ return
303
+ }
304
+
305
+ const availableMemory = await this.#getAvailableMemory()
306
+ if (!this.#started) return
307
+
308
+ for (const { appId, target } of scaleUpCandidates) {
309
+ const appScaler = this.#appScalers.get(appId)
310
+ if (!appScaler) continue
311
+
312
+ const workersCount = workersCountByAppId[appId]
313
+ if (target <= workersCount) continue
314
+
315
+ const heapPerWorker = appScaler.getHeapPerWorker()
316
+ if (heapPerWorker === null) {
317
+ this.#runtime.logger.warn(`Cannot scale up the "${appId}" app until heap measurements are available.`)
318
+ continue
319
+ }
320
+
321
+ const workersWithinMemory = Math.floor(availableMemory / heapPerWorker)
322
+ if (workersWithinMemory === 0) {
323
+ this.#runtime.logger.warn(`Not enough available memory to scale up the "${appId}" app.`)
324
+ continue
325
+ }
326
+
327
+ const scaleUpCount = Math.min(
328
+ target - workersCount,
329
+ this.#config.maxScaleUpStep,
330
+ this.#maxTotalWorkers - totalWorkersCount,
331
+ workersWithinMemory
332
+ )
333
+
334
+ const constrainedTarget = workersCount + scaleUpCount
335
+ this.#runtime.logger.info(
336
+ `Scaling up the "${appId}" app to ${constrainedTarget} workers`
337
+ )
338
+ appScaler.setTargetCount(constrainedTarget)
339
+
340
+ try {
341
+ await this.#runtime.updateApplicationsResources([{ application: appId, workers: constrainedTarget }])
342
+ } catch (err) {
343
+ this.#runtime.logger.error({ err }, 'Failed to scale up application workers')
344
+ }
345
+ break
346
+ }
347
+ }
348
+
349
+ async #getLiveWorkers () {
350
+ const workers = await this.#runtime.getWorkers(true)
351
+ // The copied status can be stale after awaiting the snapshot. Prefer the
352
+ // live worker status, and retain starting/stopping workers for budgeting.
353
+ return Object.entries(workers).filter(([, { raw, status }]) => {
354
+ return (raw?.[kWorkerStatus] ?? status) !== 'exited'
355
+ })
356
+ }
357
+
358
+ async #getWorkerCounts () {
359
+ const workers = await this.#getLiveWorkers()
360
+
361
+ const actualCountsByAppId = Object.create(null)
362
+ for (const [, { application }] of workers) {
363
+ actualCountsByAppId[application] ??= 0
364
+ actualCountsByAppId[application] += 1
365
+ }
366
+
367
+ for (const appId of this.#appScalers.keys()) {
368
+ actualCountsByAppId[appId] ??= 0
369
+ }
370
+
371
+ return actualCountsByAppId
372
+ }
373
+
374
+ async #getAvailableMemory () {
375
+ const { used } = await readMemory()
376
+ return Math.max(0, this.#maxTotalMemory - used)
377
+ }
378
+ }