@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.
- 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 +33 -24
- package/schema.json +252 -433
- package/.gitignore +0 -3
- package/.npmignore +0 -2
- package/lib/scaling-algorithm.js +0 -256
- package/lib/worker-scaler.js +0 -249
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
export const SCALE_UP_MARGIN = 0.1
|
|
2
|
+
export const SCALE_DOWN_MARGIN = 0.3
|
|
3
|
+
export const SCALE_UP_K = 2
|
|
4
|
+
export const PENDING_SCALE_UP_EXPIRY_MS = 30000
|
|
5
|
+
export const SAMPLE_INTERVAL_MS = 1000
|
|
6
|
+
// Stored samples per worker and metric. Holt keeps the history in its state and
|
|
7
|
+
// only needs the ticks since the last run; after a restart its trend settles
|
|
8
|
+
// within ~30 ticks. A larger value adds memory, not accuracy.
|
|
9
|
+
export const MAX_SAMPLES = 30
|
|
10
|
+
export const REDISTRIBUTION_K = 1
|
|
11
|
+
export const HORIZONTAL_TREND_THRESHOLD = 10
|
|
12
|
+
export const HORIZON_MULTIPLIER = 1.2
|
|
13
|
+
export const MIN_HORIZON_MS = 7000
|
|
14
|
+
export const MAX_HORIZON_MS = 30000
|
|
15
|
+
export const INIT_TIMEOUT_MS = 5000
|
|
16
|
+
export const INIT_TIMEOUT_CONFIG = {
|
|
17
|
+
stepRate: 0.1,
|
|
18
|
+
upFactor: 1.5,
|
|
19
|
+
downFactor: 1.0,
|
|
20
|
+
windowSize: 5
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Stateful predictive scaler for one application's workers.
|
|
25
|
+
*
|
|
26
|
+
* Handles multiple metrics (e.g. ELU, heap), each with its own independent
|
|
27
|
+
* alignment → redistribution → Holt smoothing pipeline. The final scaling
|
|
28
|
+
* decision takes the max targetCount across all metrics.
|
|
29
|
+
*
|
|
30
|
+
* Workers are shared across metrics (same startTime). Each metric has its
|
|
31
|
+
* own per-worker sliding window, Holt state, and redistribution state.
|
|
32
|
+
*/
|
|
33
|
+
export class PredictiveApplicationScaler {
|
|
34
|
+
/** @type {WorkerIdMapper} */
|
|
35
|
+
#workerIdMapper
|
|
36
|
+
|
|
37
|
+
/** @type {Map<string, { startTime: number, endTime: number | null, [metricName]: MetricStore }>} instanceId -> instance */
|
|
38
|
+
#instances
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Per-metric state, keyed by metric name.
|
|
42
|
+
* @type {Map<string, {
|
|
43
|
+
* holtState: { level: number, trend: number } | null,
|
|
44
|
+
* redistributionState: {
|
|
45
|
+
* prevSum: number,
|
|
46
|
+
* prevSumOfWeight: number,
|
|
47
|
+
* prevNewAvgValue: number,
|
|
48
|
+
* prevNewCount: number
|
|
49
|
+
* } | null,
|
|
50
|
+
* lastProcessedTs: number,
|
|
51
|
+
* config: { threshold: number | null,
|
|
52
|
+
* redistributionConfig: { redistributionMs: number, k: number },
|
|
53
|
+
* holtConfig: {
|
|
54
|
+
* alphaUp: number,
|
|
55
|
+
* alphaDown: number,
|
|
56
|
+
* betaUp: number,
|
|
57
|
+
* betaDown: number,
|
|
58
|
+
* maxValue: number,
|
|
59
|
+
* saturationZone: number
|
|
60
|
+
* }
|
|
61
|
+
* }
|
|
62
|
+
* }>}
|
|
63
|
+
*/
|
|
64
|
+
#metrics
|
|
65
|
+
|
|
66
|
+
/** @type {number} */
|
|
67
|
+
#targetCount
|
|
68
|
+
|
|
69
|
+
// Shared config
|
|
70
|
+
#horizonMs
|
|
71
|
+
#maxWindowMs
|
|
72
|
+
#minimum
|
|
73
|
+
#maximum
|
|
74
|
+
#cooldowns
|
|
75
|
+
|
|
76
|
+
// Cooldown state
|
|
77
|
+
#lastScaleUpTime
|
|
78
|
+
#lastScaleDownTime
|
|
79
|
+
#lastWorkerStartTime
|
|
80
|
+
#pendingScaleUps
|
|
81
|
+
|
|
82
|
+
// Adaptive init timeout
|
|
83
|
+
#initTimeoutMs
|
|
84
|
+
#initTimeoutWindow
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {object} config
|
|
88
|
+
* @param {number} config.minimum - minimum worker count
|
|
89
|
+
* @param {number} config.maximum - maximum worker count
|
|
90
|
+
* @param {object} config.cooldowns - cooldown timers
|
|
91
|
+
* @param {Object<string, object>} config.metrics - per-metric config keyed by metric name
|
|
92
|
+
*/
|
|
93
|
+
constructor (config) {
|
|
94
|
+
this.#minimum = config.minimum
|
|
95
|
+
this.#maximum = config.maximum
|
|
96
|
+
this.#cooldowns = config.cooldowns
|
|
97
|
+
this.#targetCount = config.minimum
|
|
98
|
+
|
|
99
|
+
this.#lastScaleUpTime = 0
|
|
100
|
+
this.#lastScaleDownTime = 0
|
|
101
|
+
this.#lastWorkerStartTime = 0
|
|
102
|
+
this.#pendingScaleUps = []
|
|
103
|
+
|
|
104
|
+
this.#initTimeoutMs = INIT_TIMEOUT_MS
|
|
105
|
+
this.#initTimeoutWindow = []
|
|
106
|
+
this.#horizonMs = this.#calculateHorizon()
|
|
107
|
+
// The oldest tick a run can read is this far behind the newest one.
|
|
108
|
+
this.#maxWindowMs = (MAX_SAMPLES - 1) * SAMPLE_INTERVAL_MS
|
|
109
|
+
|
|
110
|
+
this.#workerIdMapper = new WorkerIdMapper()
|
|
111
|
+
this.#instances = new Map()
|
|
112
|
+
|
|
113
|
+
this.#metrics = new Map()
|
|
114
|
+
for (const name in config.metrics) {
|
|
115
|
+
const mc = config.metrics[name]
|
|
116
|
+
this.#metrics.set(name, {
|
|
117
|
+
holtState: null,
|
|
118
|
+
redistributionState: null,
|
|
119
|
+
lastProcessedTs: 0,
|
|
120
|
+
config: {
|
|
121
|
+
threshold: mc.threshold ?? null,
|
|
122
|
+
redistributionConfig: {
|
|
123
|
+
redistributionMs: mc.redistributionMs,
|
|
124
|
+
k: REDISTRIBUTION_K
|
|
125
|
+
},
|
|
126
|
+
holtConfig: {
|
|
127
|
+
alphaUp: mc.alphaUp,
|
|
128
|
+
alphaDown: mc.alphaDown,
|
|
129
|
+
betaUp: mc.betaUp,
|
|
130
|
+
betaDown: mc.betaDown,
|
|
131
|
+
maxValue: mc.maxValue,
|
|
132
|
+
saturationZone: mc.saturationZone
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
addWorker (workerId, startTime) {
|
|
140
|
+
if (this.#workerIdMapper.get(workerId)) return
|
|
141
|
+
|
|
142
|
+
const instanceId = this.#workerIdMapper.add(workerId)
|
|
143
|
+
this.#instances.set(instanceId, { startTime, endTime: null })
|
|
144
|
+
this.#lastWorkerStartTime = Math.max(this.#lastWorkerStartTime, startTime)
|
|
145
|
+
this.#resolvePendingScaleUp(startTime)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
removeWorker (workerId, endTime) {
|
|
149
|
+
const instanceId = this.#workerIdMapper.get(workerId)
|
|
150
|
+
if (!instanceId) return
|
|
151
|
+
|
|
152
|
+
this.#instances.get(instanceId).endTime = endTime
|
|
153
|
+
this.#workerIdMapper.remove(workerId)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
addSample (metricName, workerId, timestamp, value) {
|
|
157
|
+
// Invalid readings are missing samples; preserve the last valid value.
|
|
158
|
+
if (!Number.isFinite(value)) return
|
|
159
|
+
|
|
160
|
+
const metric = this.#metrics.get(metricName)
|
|
161
|
+
if (!metric) return
|
|
162
|
+
|
|
163
|
+
const instanceId = this.#workerIdMapper.get(workerId)
|
|
164
|
+
// Only lifecycle events register workers. A late metric must not revive one.
|
|
165
|
+
if (!instanceId) return
|
|
166
|
+
|
|
167
|
+
const instance = this.#instances.get(instanceId)
|
|
168
|
+
if (timestamp < instance.startTime) return
|
|
169
|
+
|
|
170
|
+
let metricSamples = instance[metricName]
|
|
171
|
+
if (!metricSamples) {
|
|
172
|
+
metricSamples = new MetricStore(SAMPLE_INTERVAL_MS, MAX_SAMPLES)
|
|
173
|
+
instance[metricName] = metricSamples
|
|
174
|
+
}
|
|
175
|
+
metricSamples.push(timestamp, value)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
get targetCount () {
|
|
179
|
+
return this.#targetCount
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
getHeapPerWorker () {
|
|
183
|
+
const heap = this.#metrics.get('heap')
|
|
184
|
+
const level = heap?.holtState?.level
|
|
185
|
+
const workersCount = this.#workerIdMapper.size
|
|
186
|
+
if (level == null || workersCount === 0) return null
|
|
187
|
+
|
|
188
|
+
return Math.max(level / workersCount, Number.EPSILON)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Read-only view of the existing state for the diagnostics page. It is built
|
|
192
|
+
// on request and nothing is retained for it.
|
|
193
|
+
getDiagnostics () {
|
|
194
|
+
const metrics = {}
|
|
195
|
+
for (const [name, metric] of this.#metrics) {
|
|
196
|
+
metrics[name] = {
|
|
197
|
+
threshold: metric.config.threshold,
|
|
198
|
+
lastProcessedTs: metric.lastProcessedTs,
|
|
199
|
+
level: metric.holtState?.level ?? null,
|
|
200
|
+
trendPerSecond: (metric.holtState?.trend ?? 0) * 1000 / SAMPLE_INTERVAL_MS
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const workers = []
|
|
205
|
+
for (const [id, instance] of this.#instances) {
|
|
206
|
+
if (instance.endTime !== null) continue
|
|
207
|
+
const workerMetrics = {}
|
|
208
|
+
for (const name of this.#metrics.keys()) {
|
|
209
|
+
if (instance[name]) workerMetrics[name] = instance[name].getDiagnostics()
|
|
210
|
+
}
|
|
211
|
+
workers.push({ id, startTime: instance.startTime, metrics: workerMetrics })
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
targetCount: this.#targetCount,
|
|
216
|
+
liveCount: this.#workerIdMapper.size,
|
|
217
|
+
minimum: this.#minimum,
|
|
218
|
+
maximum: this.#maximum,
|
|
219
|
+
initTimeoutMs: this.#initTimeoutMs,
|
|
220
|
+
horizonMs: this.#horizonMs,
|
|
221
|
+
pending: this.#pendingScaleUps,
|
|
222
|
+
metrics,
|
|
223
|
+
workers
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
syncWorkersCount (workersCount) {
|
|
228
|
+
// Pending starts are ordered by expectedCount.
|
|
229
|
+
while (this.#pendingScaleUps.at(-1)?.expectedCount > workersCount) {
|
|
230
|
+
this.#pendingScaleUps.pop()
|
|
231
|
+
}
|
|
232
|
+
this.#targetCount = workersCount
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
setTargetCount (targetCount) {
|
|
236
|
+
if (targetCount === this.#targetCount) return
|
|
237
|
+
|
|
238
|
+
const now = Date.now()
|
|
239
|
+
if (targetCount > this.#targetCount) {
|
|
240
|
+
const scaleAt = now + this.#initTimeoutMs
|
|
241
|
+
for (let i = this.#targetCount; i < targetCount; i++) {
|
|
242
|
+
this.#pendingScaleUps.push({
|
|
243
|
+
scaleAt,
|
|
244
|
+
decisionAt: now,
|
|
245
|
+
expectedCount: i + 1
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
this.#lastScaleUpTime = now
|
|
249
|
+
} else {
|
|
250
|
+
this.#lastScaleDownTime = now
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
this.#targetCount = targetCount
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Run the pipeline over all unprocessed ticks.
|
|
258
|
+
* Each metric is processed independently; the final targetCount is the max.
|
|
259
|
+
* Cooldowns are enforced after computing the target.
|
|
260
|
+
*
|
|
261
|
+
* @param {number} now - current timestamp
|
|
262
|
+
* @returns {number | null} suggested targetCount, or null if no metrics had new ticks
|
|
263
|
+
*/
|
|
264
|
+
process (now) {
|
|
265
|
+
this.#expirePendingScaleUps(now)
|
|
266
|
+
|
|
267
|
+
let maxTargetCount = 0
|
|
268
|
+
let processed = false
|
|
269
|
+
|
|
270
|
+
for (const metricName of this.#metrics.keys()) {
|
|
271
|
+
const targetCount = this.#processMetric(metricName, now)
|
|
272
|
+
if (targetCount !== null) {
|
|
273
|
+
processed = true
|
|
274
|
+
maxTargetCount = Math.max(maxTargetCount, targetCount)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
this.#removeEndedInstances(now)
|
|
279
|
+
|
|
280
|
+
if (!processed) return null
|
|
281
|
+
|
|
282
|
+
if (maxTargetCount === this.#targetCount) return this.#targetCount
|
|
283
|
+
|
|
284
|
+
if (maxTargetCount > this.#targetCount) {
|
|
285
|
+
return this.#checkScaleUp(now) ? maxTargetCount : this.#targetCount
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return this.#checkScaleDown(now) ? maxTargetCount : this.#targetCount
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
#processMetric (metricName, now) {
|
|
292
|
+
const metric = this.#metrics.get(metricName)
|
|
293
|
+
let startTs = metric.lastProcessedTs + SAMPLE_INTERVAL_MS
|
|
294
|
+
|
|
295
|
+
// Ticks since the last run may no longer be stored. Continuing from the old
|
|
296
|
+
// state would read the gap as a single tick, so start again from the oldest
|
|
297
|
+
// tick every reporting worker still stores.
|
|
298
|
+
const windowStartTs = alignDown(now, SAMPLE_INTERVAL_MS) - this.#maxWindowMs
|
|
299
|
+
if (startTs < windowStartTs) {
|
|
300
|
+
startTs = windowStartTs
|
|
301
|
+
metric.holtState = null
|
|
302
|
+
metric.redistributionState = null
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const stateByTimestamp = this.#getAlignedMetrics(metricName, startTs, now)
|
|
306
|
+
if (stateByTimestamp.length === 0) return null
|
|
307
|
+
|
|
308
|
+
// Stage 1: Redistribution (mutates state entries in-place)
|
|
309
|
+
metric.redistributionState = redistributeValues(
|
|
310
|
+
stateByTimestamp,
|
|
311
|
+
this.#instances,
|
|
312
|
+
metric.config.redistributionConfig,
|
|
313
|
+
metric.redistributionState
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
// Stage 2: Holt smoothing (mutates state entries in-place)
|
|
317
|
+
metric.holtState = holt(
|
|
318
|
+
stateByTimestamp,
|
|
319
|
+
metric.config.holtConfig,
|
|
320
|
+
metric.holtState
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
const lastEntry = stateByTimestamp.at(-1)
|
|
324
|
+
const { count } = lastEntry.redistribution
|
|
325
|
+
let { level, trend } = lastEntry.holt
|
|
326
|
+
|
|
327
|
+
// Convert trend from per-tick to per-second
|
|
328
|
+
trend *= 1000 / SAMPLE_INTERVAL_MS
|
|
329
|
+
|
|
330
|
+
// Adjust level forward to now
|
|
331
|
+
level += trend * (now - lastEntry.timestamp) / 1000
|
|
332
|
+
|
|
333
|
+
metric.lastProcessedTs = lastEntry.timestamp
|
|
334
|
+
|
|
335
|
+
// Metrics without a threshold are still processed for resource checks,
|
|
336
|
+
// but cannot recommend either direction of scaling.
|
|
337
|
+
if (metric.config.threshold === null) return null
|
|
338
|
+
|
|
339
|
+
// Stage 3: Decision
|
|
340
|
+
return makeScalingDecision({
|
|
341
|
+
level,
|
|
342
|
+
trend,
|
|
343
|
+
count,
|
|
344
|
+
threshold: metric.config.threshold,
|
|
345
|
+
targetCount: this.#targetCount,
|
|
346
|
+
horizonMs: this.#horizonMs,
|
|
347
|
+
minimum: this.#minimum,
|
|
348
|
+
maximum: this.#maximum,
|
|
349
|
+
horizontalTrendThreshold: HORIZONTAL_TREND_THRESHOLD,
|
|
350
|
+
scaleUpK: SCALE_UP_K
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
#getAlignedMetrics (metricName, fromTick, now) {
|
|
355
|
+
const samplesByTimestamp = new Map()
|
|
356
|
+
|
|
357
|
+
for (const [instanceId, instance] of this.#instances) {
|
|
358
|
+
const metricSamples = instance[metricName]
|
|
359
|
+
if (!metricSamples) continue
|
|
360
|
+
|
|
361
|
+
// Bounds are per lifetime. The exit tick belongs to the remaining workers.
|
|
362
|
+
const instanceFromTick = Math.max(fromTick, instance.startTime)
|
|
363
|
+
const instanceToTick = instance.endTime === null
|
|
364
|
+
? now
|
|
365
|
+
: Math.min(now, instance.endTime - 1)
|
|
366
|
+
|
|
367
|
+
metricSamples.forEachEntry(instanceFromTick, instanceToTick, (timestamp, value) => {
|
|
368
|
+
let samplesAtTimestamp = samplesByTimestamp.get(timestamp)
|
|
369
|
+
if (!samplesAtTimestamp) {
|
|
370
|
+
samplesAtTimestamp = { timestamp, workerValues: {} }
|
|
371
|
+
samplesByTimestamp.set(timestamp, samplesAtTimestamp)
|
|
372
|
+
}
|
|
373
|
+
samplesAtTimestamp.workerValues[instanceId] = value
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return [...samplesByTimestamp.values()].sort((a, b) => a.timestamp - b.timestamp)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
#checkScaleUp (now) {
|
|
381
|
+
const {
|
|
382
|
+
scaleUpAfterScaleUpMs = 5000,
|
|
383
|
+
scaleUpAfterScaleDownMs = 5000
|
|
384
|
+
} = this.#cooldowns
|
|
385
|
+
|
|
386
|
+
if (this.#lastScaleUpTime && now - this.#lastScaleUpTime < scaleUpAfterScaleUpMs) {
|
|
387
|
+
return false
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (this.#lastScaleDownTime && now - this.#lastScaleDownTime < scaleUpAfterScaleDownMs) {
|
|
391
|
+
return false
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return true
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
#checkScaleDown (now) {
|
|
398
|
+
const {
|
|
399
|
+
scaleDownAfterScaleUpMs = 30000,
|
|
400
|
+
scaleDownAfterScaleDownMs = 20000
|
|
401
|
+
} = this.#cooldowns
|
|
402
|
+
|
|
403
|
+
if (this.#pendingScaleUps.length > 0) {
|
|
404
|
+
return false
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (this.#lastWorkerStartTime && now - this.#lastWorkerStartTime < scaleDownAfterScaleUpMs) {
|
|
408
|
+
return false
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (this.#lastScaleDownTime && now - this.#lastScaleDownTime < scaleDownAfterScaleDownMs) {
|
|
412
|
+
return false
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return true
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
#expirePendingScaleUps (now) {
|
|
419
|
+
const cutoff = now - PENDING_SCALE_UP_EXPIRY_MS
|
|
420
|
+
let remaining = 0
|
|
421
|
+
for (const pending of this.#pendingScaleUps) {
|
|
422
|
+
if (pending.scaleAt >= cutoff) {
|
|
423
|
+
this.#pendingScaleUps[remaining++] = pending
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (remaining === this.#pendingScaleUps.length) return
|
|
427
|
+
|
|
428
|
+
this.#pendingScaleUps.length = remaining
|
|
429
|
+
const liveCount = this.#workerIdMapper.size
|
|
430
|
+
this.#targetCount = liveCount + remaining
|
|
431
|
+
|
|
432
|
+
// Remaining requests still represent one extra worker each. Remove the
|
|
433
|
+
// expired capacity from their expected counts as well as from the target.
|
|
434
|
+
for (let i = 0; i < remaining; i++) {
|
|
435
|
+
this.#pendingScaleUps[i].expectedCount = liveCount + i + 1
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
#calculateHorizon () {
|
|
440
|
+
return Math.min(Math.max(HORIZON_MULTIPLIER * this.#initTimeoutMs, MIN_HORIZON_MS), MAX_HORIZON_MS)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
#resolvePendingScaleUp (startTime) {
|
|
444
|
+
// A replacement that only restores the previous count does not fulfil a
|
|
445
|
+
// scale-up. Measure when the requested number of live workers is reached.
|
|
446
|
+
while (this.#pendingScaleUps.length > 0) {
|
|
447
|
+
const pending = this.#pendingScaleUps[0]
|
|
448
|
+
if (this.#workerIdMapper.size < pending.expectedCount) break
|
|
449
|
+
|
|
450
|
+
this.#pendingScaleUps.shift()
|
|
451
|
+
const initTime = startTime - pending.decisionAt
|
|
452
|
+
this.#initTimeoutWindow.push(initTime)
|
|
453
|
+
if (this.#initTimeoutWindow.length > INIT_TIMEOUT_CONFIG.windowSize) {
|
|
454
|
+
this.#initTimeoutWindow.shift()
|
|
455
|
+
}
|
|
456
|
+
this.#initTimeoutMs = calculateInitTimeout(
|
|
457
|
+
this.#initTimeoutWindow,
|
|
458
|
+
this.#initTimeoutMs,
|
|
459
|
+
INIT_TIMEOUT_CONFIG
|
|
460
|
+
)
|
|
461
|
+
this.#horizonMs = this.#calculateHorizon()
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
#removeEndedInstances (now) {
|
|
466
|
+
for (const [instanceId, instance] of this.#instances) {
|
|
467
|
+
if (instance.endTime !== null && instance.endTime <= now) {
|
|
468
|
+
this.#instances.delete(instanceId)
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// The runtime reuses worker IDs when it restarts a worker, or when it scales
|
|
475
|
+
// down and back up. The algorithm needs to tell each worker lifetime apart, so
|
|
476
|
+
// every registration gets a fresh instance ID that is never reused.
|
|
477
|
+
export class WorkerIdMapper {
|
|
478
|
+
#map
|
|
479
|
+
#counter
|
|
480
|
+
|
|
481
|
+
constructor () {
|
|
482
|
+
this.#map = new Map()
|
|
483
|
+
this.#counter = 0
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
add (workerId) {
|
|
487
|
+
const instanceId = workerId + '-' + this.#counter++
|
|
488
|
+
this.#map.set(workerId, instanceId)
|
|
489
|
+
return instanceId
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
remove (workerId) {
|
|
493
|
+
this.#map.delete(workerId)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
get (workerId) {
|
|
497
|
+
return this.#map.get(workerId)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
get size () {
|
|
501
|
+
return this.#map.size
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function alignDown (timestamp, intervalMs) {
|
|
506
|
+
return Math.floor(timestamp / intervalMs) * intervalMs
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function alignUp (timestamp, intervalMs) {
|
|
510
|
+
return Math.ceil(timestamp / intervalMs) * intervalMs
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Worker metrics aligned to a fixed grid, with interpolation and carry-forward.
|
|
514
|
+
export class MetricStore {
|
|
515
|
+
#sampleIntervalMs
|
|
516
|
+
#maxWindowMs
|
|
517
|
+
#capacity
|
|
518
|
+
#values
|
|
519
|
+
#prevRawTs
|
|
520
|
+
#prevRawValue
|
|
521
|
+
#nextAlignedTs
|
|
522
|
+
|
|
523
|
+
constructor (sampleIntervalMs, maxSamples) {
|
|
524
|
+
this.#sampleIntervalMs = sampleIntervalMs
|
|
525
|
+
this.#capacity = maxSamples
|
|
526
|
+
// The oldest stored tick is at most this far behind the newest one.
|
|
527
|
+
this.#maxWindowMs = (maxSamples - 1) * sampleIntervalMs
|
|
528
|
+
this.#values = []
|
|
529
|
+
this.#prevRawTs = null
|
|
530
|
+
this.#prevRawValue = null
|
|
531
|
+
this.#nextAlignedTs = null
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
push (timestamp, value) {
|
|
535
|
+
const intervalMs = this.#sampleIntervalMs
|
|
536
|
+
const alignedTs = alignDown(timestamp, intervalMs)
|
|
537
|
+
|
|
538
|
+
if (this.#nextAlignedTs === null) {
|
|
539
|
+
this.#values.push(value)
|
|
540
|
+
this.#nextAlignedTs = alignedTs + intervalMs
|
|
541
|
+
} else if (this.#nextAlignedTs <= alignedTs) {
|
|
542
|
+
// A long silence must not generate ticks that are already outside the window.
|
|
543
|
+
const windowStartTs = alignedTs - this.#maxWindowMs
|
|
544
|
+
if (this.#nextAlignedTs < windowStartTs) {
|
|
545
|
+
this.#values.length = 0
|
|
546
|
+
this.#nextAlignedTs = windowStartTs
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const slope = (value - this.#prevRawValue) / (timestamp - this.#prevRawTs)
|
|
550
|
+
for (; this.#nextAlignedTs <= alignedTs; this.#nextAlignedTs += intervalMs) {
|
|
551
|
+
this.#values.push(value + slope * (this.#nextAlignedTs - timestamp))
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const expiredCount = this.#values.length - this.#capacity
|
|
555
|
+
if (expiredCount > 0) this.#values.splice(0, expiredCount)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
this.#prevRawTs = timestamp
|
|
559
|
+
this.#prevRawValue = value
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Read-only view of the stored samples and the latest raw reading. Sample i
|
|
563
|
+
// is at firstSampleTs + i * intervalMs.
|
|
564
|
+
getDiagnostics () {
|
|
565
|
+
return {
|
|
566
|
+
lastSampleTs: this.#prevRawTs,
|
|
567
|
+
lastValue: this.#prevRawValue,
|
|
568
|
+
firstSampleTs: this.#nextAlignedTs - this.#values.length * this.#sampleIntervalMs,
|
|
569
|
+
intervalMs: this.#sampleIntervalMs,
|
|
570
|
+
values: this.#values
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Bounds are finite and inclusive. Carry-forward never changes stored readings.
|
|
575
|
+
forEachEntry (startTs, endTs, visit) {
|
|
576
|
+
if (this.#nextAlignedTs === null) return
|
|
577
|
+
|
|
578
|
+
const intervalMs = this.#sampleIntervalMs
|
|
579
|
+
const firstStoredTs = this.#nextAlignedTs - this.#values.length * intervalMs
|
|
580
|
+
const firstTs = Math.max(firstStoredTs, alignUp(startTs, intervalMs))
|
|
581
|
+
|
|
582
|
+
let index = (firstTs - firstStoredTs) / intervalMs
|
|
583
|
+
for (let timestamp = firstTs; timestamp <= endTs; timestamp += intervalMs) {
|
|
584
|
+
const value = timestamp < this.#nextAlignedTs ? this.#values[index++] : this.#prevRawValue
|
|
585
|
+
visit(timestamp, value)
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Calculate stabilization weight using exponential curve.
|
|
592
|
+
* Weight goes from 0 (just added) to 1 (fully stable).
|
|
593
|
+
*
|
|
594
|
+
* @param {number} age - how long the worker has been running (ms)
|
|
595
|
+
* @param {number} redistributionMs - expected time for full stabilization
|
|
596
|
+
* @param {number} k - shape parameter
|
|
597
|
+
* @returns {number} weight in [0, 1]
|
|
598
|
+
*/
|
|
599
|
+
export function getStabilizationWeight (age, redistributionMs, k) {
|
|
600
|
+
const t = age / redistributionMs
|
|
601
|
+
return (Math.exp(k * t) - 1) / (Math.exp(k) - 1)
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Redistribute aggregated values to filter out scaling artifacts.
|
|
606
|
+
*
|
|
607
|
+
* Processes ticks using the previous sum and startup weights as current state.
|
|
608
|
+
* New workers (age < redistributionMs) contribute at partial weight.
|
|
609
|
+
* The prevSum monotonicity guard prevents the sum from dropping during redistribution.
|
|
610
|
+
* newSumDelta reports weight-driven growth separately for Holt compensation.
|
|
611
|
+
*
|
|
612
|
+
* @param {Array<{ timestamp: number, workerValues: Object<string, number> }>} state
|
|
613
|
+
* @param {Map<string, { startTime: number }>} workers - worker registry
|
|
614
|
+
* @param {{ redistributionMs: number, k?: number }} config
|
|
615
|
+
* @param {{ prevSum: number, prevSumOfWeight: number, prevNewAvgValue: number, prevNewCount: number } | null} prev
|
|
616
|
+
* @returns {{ prevSum: number, prevSumOfWeight: number, prevNewAvgValue: number, prevNewCount: number } | null}
|
|
617
|
+
*/
|
|
618
|
+
export function redistributeValues (state, workers, config, prev) {
|
|
619
|
+
if (state.length === 0) return prev
|
|
620
|
+
|
|
621
|
+
const { redistributionMs, k = 1 } = config
|
|
622
|
+
let prevSum = prev?.prevSum ?? null
|
|
623
|
+
let prevSumOfWeight = prev?.prevSumOfWeight ?? 0
|
|
624
|
+
let prevNewAvgValue = prev?.prevNewAvgValue ?? 0
|
|
625
|
+
let prevNewCount = prev?.prevNewCount ?? 0
|
|
626
|
+
|
|
627
|
+
for (let i = 0; i < state.length; i++) {
|
|
628
|
+
const entry = state[i]
|
|
629
|
+
const { timestamp, workerValues } = entry
|
|
630
|
+
|
|
631
|
+
// Classify workers into stable and new, compute weights
|
|
632
|
+
let stableSum = 0
|
|
633
|
+
let stableCount = 0
|
|
634
|
+
let newCount = 0
|
|
635
|
+
let sumOfWeights = 0
|
|
636
|
+
let total = 0
|
|
637
|
+
|
|
638
|
+
for (const id in workerValues) {
|
|
639
|
+
const value = workerValues[id]
|
|
640
|
+
total += value
|
|
641
|
+
|
|
642
|
+
const worker = workers.get(id)
|
|
643
|
+
const startTime = worker?.startTime
|
|
644
|
+
if (startTime !== undefined && startTime <= timestamp) {
|
|
645
|
+
const age = timestamp - startTime
|
|
646
|
+
if (age < redistributionMs) {
|
|
647
|
+
newCount++
|
|
648
|
+
sumOfWeights += getStabilizationWeight(age, redistributionMs, k)
|
|
649
|
+
continue
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
stableSum += value
|
|
654
|
+
stableCount++
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
let sum, count
|
|
658
|
+
let newSumDelta = 0
|
|
659
|
+
let newAvgValue = 0
|
|
660
|
+
if (newCount === 0) {
|
|
661
|
+
sum = stableSum
|
|
662
|
+
count = stableCount
|
|
663
|
+
} else {
|
|
664
|
+
const newVal = total - stableSum
|
|
665
|
+
// A graduating worker moves into stableSum. Add its full weight back
|
|
666
|
+
// when measuring weight growth, as in the original pod algorithm.
|
|
667
|
+
const graduatedCount = Math.max(0, prevNewCount - newCount)
|
|
668
|
+
const weightGrowth = sumOfWeights - prevSumOfWeight + graduatedCount
|
|
669
|
+
newSumDelta = weightGrowth * prevNewAvgValue
|
|
670
|
+
newAvgValue = newVal / newCount
|
|
671
|
+
const baseShare = sumOfWeights / newCount
|
|
672
|
+
|
|
673
|
+
count = stableCount + sumOfWeights
|
|
674
|
+
sum = stableSum + newVal * baseShare
|
|
675
|
+
|
|
676
|
+
if (prevSum !== null && prevSum > sum) {
|
|
677
|
+
sum = Math.min(total, prevSum)
|
|
678
|
+
newSumDelta = 0
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
prevSum = sum
|
|
683
|
+
prevSumOfWeight = sumOfWeights
|
|
684
|
+
prevNewAvgValue = newAvgValue
|
|
685
|
+
prevNewCount = newCount
|
|
686
|
+
const workerCount = Object.keys(workerValues).length
|
|
687
|
+
entry.redistribution = { sum, count, rawSum: total, workerCount, newSumDelta }
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return { prevSum, prevSumOfWeight, prevNewAvgValue, prevNewCount }
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Double Exponential Smoothing (Holt's Method)
|
|
695
|
+
*
|
|
696
|
+
* Reads from entry.redistribution.sum, writes entry.holt = { level, trend }.
|
|
697
|
+
* Uses asymmetric smoothing parameters (faster reaction to upward movement).
|
|
698
|
+
* Accounts for newSumDelta in the forecast and removes it from the trend update.
|
|
699
|
+
* Includes trend dampening to prevent downward overshoot.
|
|
700
|
+
*
|
|
701
|
+
* @param {Array<{ redistribution: { sum: number, newSumDelta?: number } }>} state - entries with redistribution data
|
|
702
|
+
* @param {{ alphaUp: number, alphaDown: number, betaUp: number, betaDown: number }} config
|
|
703
|
+
* @param {{ level: number, trend: number } | null} prev - previous state, null for cold start
|
|
704
|
+
* @returns {{ level: number, trend: number }}
|
|
705
|
+
*/
|
|
706
|
+
export function holt (state, config, prev) {
|
|
707
|
+
const { alphaUp, alphaDown, betaUp, betaDown, maxValue, saturationZone } = config
|
|
708
|
+
|
|
709
|
+
let level = prev?.level ?? null
|
|
710
|
+
let trend = prev?.trend ?? 0
|
|
711
|
+
|
|
712
|
+
for (let i = 0; i < state.length; i++) {
|
|
713
|
+
const entry = state[i]
|
|
714
|
+
const input = entry.redistribution.sum
|
|
715
|
+
const newSumDelta = entry.redistribution.newSumDelta ?? 0
|
|
716
|
+
|
|
717
|
+
if (level === null) {
|
|
718
|
+
level = input
|
|
719
|
+
trend = 0
|
|
720
|
+
entry.holt = { level, trend }
|
|
721
|
+
continue
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const forecast = level + trend + newSumDelta
|
|
725
|
+
const isAboveForecast = input > forecast
|
|
726
|
+
|
|
727
|
+
const alpha = isAboveForecast ? alphaUp : alphaDown
|
|
728
|
+
const beta = isAboveForecast ? betaUp : betaDown
|
|
729
|
+
|
|
730
|
+
const prevLevel = level
|
|
731
|
+
const prevTrend = trend
|
|
732
|
+
|
|
733
|
+
level = alpha * input + (1 - alpha) * forecast
|
|
734
|
+
|
|
735
|
+
// Weight growth changes the level, but must not appear as demand growth.
|
|
736
|
+
const levelDiff = level - prevLevel - newSumDelta
|
|
737
|
+
trend = beta * levelDiff + (1 - beta) * trend
|
|
738
|
+
|
|
739
|
+
// Check if metric is saturated — only allow trend to increase, not decrease
|
|
740
|
+
if (maxValue !== undefined) {
|
|
741
|
+
const rawSum = entry.redistribution.rawSum
|
|
742
|
+
const rawCount = entry.redistribution.workerCount
|
|
743
|
+
const maxSum = rawCount * maxValue
|
|
744
|
+
const threshold = maxSum * (1 - saturationZone)
|
|
745
|
+
|
|
746
|
+
if (rawSum >= threshold) {
|
|
747
|
+
trend = Math.max(trend, prevTrend)
|
|
748
|
+
level = Math.min(level, maxSum)
|
|
749
|
+
entry.holt = { level, trend }
|
|
750
|
+
continue
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Dampen trend when smoothed is above real value to prevent undershoot
|
|
755
|
+
if (level > input) {
|
|
756
|
+
const gap = level - input
|
|
757
|
+
trend *= gap / (gap + Math.abs(trend) + 1e-9)
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
entry.holt = { level, trend }
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return { level, trend }
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
export const TrendDirection = {
|
|
767
|
+
UP: 'up',
|
|
768
|
+
HORIZONTAL: 'horizontal',
|
|
769
|
+
DOWN: 'down'
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Classify the current trend direction based on the normalized growth rate.
|
|
774
|
+
* The trend is normalized by level so classification is independent of absolute load.
|
|
775
|
+
*
|
|
776
|
+
* @param {number} trend - current Holt trend
|
|
777
|
+
* @param {number} level - current Holt level
|
|
778
|
+
* @param {number} horizontalTrendThreshold - deadband angle in degrees
|
|
779
|
+
* @returns {'up' | 'horizontal' | 'down'}
|
|
780
|
+
*/
|
|
781
|
+
export function getTrendDirection (trend, level, horizontalTrendThreshold) {
|
|
782
|
+
if (level === 0) return TrendDirection.HORIZONTAL
|
|
783
|
+
const normalizedTrend = trend / level
|
|
784
|
+
const angleDegrees = Math.atan(normalizedTrend) * (180 / Math.PI)
|
|
785
|
+
if (angleDegrees > horizontalTrendThreshold) return TrendDirection.UP
|
|
786
|
+
if (angleDegrees < -horizontalTrendThreshold) return TrendDirection.DOWN
|
|
787
|
+
return TrendDirection.HORIZONTAL
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Make a scaling decision: compute the target worker count.
|
|
792
|
+
*
|
|
793
|
+
* The full target is computed without step clamping — the orchestrator
|
|
794
|
+
* is responsible for global rate limiting across apps.
|
|
795
|
+
*
|
|
796
|
+
* @param {object} params
|
|
797
|
+
* @param {number} params.level - current smoothed aggregated value (Holt level)
|
|
798
|
+
* @param {number} params.trend - current Holt trend
|
|
799
|
+
* @param {number} params.count - contribution-weighted worker count from redistribution
|
|
800
|
+
* @param {number} params.threshold - per-worker overload threshold
|
|
801
|
+
* @param {number} params.targetCount - current target worker count
|
|
802
|
+
* @param {number} params.horizonMs - prediction horizon in ms
|
|
803
|
+
* @param {number} params.minimum - minimum worker count
|
|
804
|
+
* @param {number} params.maximum - maximum worker count
|
|
805
|
+
* @param {number} params.horizontalTrendThreshold - deadband angle in degrees
|
|
806
|
+
* @param {number} params.scaleUpK - consequence-asymmetric weight steepness
|
|
807
|
+
* @returns {number} target worker count
|
|
808
|
+
*/
|
|
809
|
+
export function makeScalingDecision ({
|
|
810
|
+
level,
|
|
811
|
+
trend,
|
|
812
|
+
count,
|
|
813
|
+
threshold,
|
|
814
|
+
targetCount,
|
|
815
|
+
horizonMs,
|
|
816
|
+
minimum,
|
|
817
|
+
maximum,
|
|
818
|
+
horizontalTrendThreshold,
|
|
819
|
+
scaleUpK
|
|
820
|
+
}) {
|
|
821
|
+
const horizonSeconds = horizonMs / 1000
|
|
822
|
+
const predictedSum = level + trend * horizonSeconds
|
|
823
|
+
|
|
824
|
+
const trendDirection = getTrendDirection(trend, level, horizontalTrendThreshold)
|
|
825
|
+
const isOverloaded = level / count > threshold
|
|
826
|
+
const isOverloadedAtHorizon = predictedSum / targetCount > threshold
|
|
827
|
+
|
|
828
|
+
if (trendDirection === TrendDirection.UP || isOverloadedAtHorizon) {
|
|
829
|
+
return findScaleUpTarget({
|
|
830
|
+
level,
|
|
831
|
+
predictedSum,
|
|
832
|
+
isOverloaded,
|
|
833
|
+
threshold,
|
|
834
|
+
maximum,
|
|
835
|
+
targetCount,
|
|
836
|
+
scaleUpK
|
|
837
|
+
})
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (!isOverloaded) {
|
|
841
|
+
return findScaleDownTarget({
|
|
842
|
+
level,
|
|
843
|
+
threshold,
|
|
844
|
+
minimum,
|
|
845
|
+
targetCount
|
|
846
|
+
})
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
return targetCount
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function findScaleUpTarget ({
|
|
853
|
+
level,
|
|
854
|
+
predictedSum,
|
|
855
|
+
isOverloaded,
|
|
856
|
+
threshold,
|
|
857
|
+
maximum,
|
|
858
|
+
targetCount,
|
|
859
|
+
scaleUpK
|
|
860
|
+
}) {
|
|
861
|
+
let predictedSumIncrease = predictedSum - level
|
|
862
|
+
if (level > 0 && predictedSumIncrease > 0) {
|
|
863
|
+
const growth = predictedSumIncrease / level
|
|
864
|
+
const weight = scaleUpK / (scaleUpK + growth)
|
|
865
|
+
predictedSumIncrease = weight * predictedSumIncrease
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const adjustedPredictedSum = level + predictedSumIncrease
|
|
869
|
+
|
|
870
|
+
let newTarget = Math.floor(adjustedPredictedSum / threshold)
|
|
871
|
+
const targetOverload = adjustedPredictedSum - newTarget * threshold
|
|
872
|
+
if (targetOverload > 0 && (isOverloaded || targetOverload / threshold > SCALE_UP_MARGIN)) {
|
|
873
|
+
newTarget++
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
newTarget = Math.max(newTarget, targetCount)
|
|
877
|
+
newTarget = Math.min(newTarget, maximum)
|
|
878
|
+
return newTarget
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function findScaleDownTarget ({
|
|
882
|
+
level,
|
|
883
|
+
threshold,
|
|
884
|
+
minimum,
|
|
885
|
+
targetCount
|
|
886
|
+
}) {
|
|
887
|
+
const minInstances = Math.floor((1 + SCALE_DOWN_MARGIN) * level / threshold) + 1
|
|
888
|
+
return Math.max(minimum, Math.min(targetCount, minInstances))
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
export function median (values) {
|
|
892
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
893
|
+
const mid = Math.floor(sorted.length / 2)
|
|
894
|
+
if (sorted.length % 2 === 0) {
|
|
895
|
+
return (sorted[mid - 1] + sorted[mid]) / 2
|
|
896
|
+
}
|
|
897
|
+
return sorted[mid]
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
export function calculateInitTimeout (window, currentTimeout, config) {
|
|
901
|
+
const { stepRate, upFactor, downFactor } = config
|
|
902
|
+
const target = median(window)
|
|
903
|
+
|
|
904
|
+
const delta = target - currentTimeout
|
|
905
|
+
const maxUp = currentTimeout * stepRate * upFactor
|
|
906
|
+
const maxDown = currentTimeout * stepRate * downFactor
|
|
907
|
+
const clampedDelta = Math.min(Math.max(delta, -maxDown), maxUp)
|
|
908
|
+
|
|
909
|
+
return Math.round(currentTimeout + clampedDelta)
|
|
910
|
+
}
|