@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.
@@ -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
+ }