@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.
- package/config.d.ts +602 -673
- package/index.d.ts +22 -27
- package/index.js +290 -30
- package/lib/config.js +270 -367
- package/lib/errors.js +27 -13
- package/lib/generator.js +249 -127
- package/lib/logger.js +3 -3
- package/lib/management-api.js +22 -65
- package/lib/management-handlers.js +3 -5
- package/lib/memory.js +29 -0
- package/lib/policies.js +27 -0
- package/lib/predictive-scaling.js +910 -0
- package/lib/predictive-worker-scaler.js +378 -0
- package/lib/runtime.js +1081 -572
- package/lib/scheduler.js +6 -41
- package/lib/schema.js +77 -15
- package/lib/upgrade.js +16 -1
- package/lib/versions/v4.0.0.js +31 -0
- package/lib/worker/controller.js +89 -109
- package/lib/worker/interceptors.js +34 -34
- package/lib/worker/itc.js +248 -66
- package/lib/worker/main.js +50 -98
- package/lib/worker/messaging.js +42 -1
- package/lib/worker/round-robin-map.js +10 -3
- package/lib/worker/symbols.js +6 -2
- package/package.json +29 -20
- package/schema.json +252 -433
- package/lib/scaling-algorithm.js +0 -256
- package/lib/worker-scaler.js +0 -249
|
@@ -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
|
+
}
|