@platformatic/runtime 3.9.0 → 3.11.0

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 CHANGED
@@ -31,10 +31,39 @@ export type PlatformaticRuntimeConfig = {
31
31
  maxHeapTotal?: number | string;
32
32
  maxYoungGeneration?: number | string;
33
33
  };
34
- preload?: string | string[];
35
34
  dependencies?: string[];
36
35
  arguments?: string[];
36
+ env?: {
37
+ [k: string]: string;
38
+ };
39
+ envfile?: string;
40
+ sourceMaps?: boolean;
41
+ packageManager?: "npm" | "pnpm" | "yarn";
42
+ preload?: string | string[];
37
43
  nodeOptions?: string;
44
+ permissions?: {
45
+ fs?: {
46
+ read?: string[];
47
+ write?: string[];
48
+ };
49
+ };
50
+ telemetry?: {
51
+ /**
52
+ * An array of instrumentations loaded if telemetry is enabled
53
+ */
54
+ instrumentations?: (
55
+ | string
56
+ | {
57
+ package: string;
58
+ exportName?: string;
59
+ options?: {
60
+ [k: string]: unknown;
61
+ };
62
+ [k: string]: unknown;
63
+ }
64
+ )[];
65
+ [k: string]: unknown;
66
+ };
38
67
  };
39
68
  };
40
69
  };
@@ -255,6 +284,7 @@ export type PlatformaticRuntimeConfig = {
255
284
  };
256
285
  };
257
286
  plugins?: string[];
287
+ timeout?: number | string;
258
288
  };
259
289
  telemetry?: {
260
290
  enabled?: boolean | string;
@@ -335,14 +365,16 @@ export type PlatformaticRuntimeConfig = {
335
365
  verticalScaler?: {
336
366
  enabled?: boolean;
337
367
  maxTotalWorkers?: number;
368
+ maxTotalMemory?: number;
338
369
  minWorkers?: number;
339
370
  maxWorkers?: number;
340
371
  scaleUpELU?: number;
341
372
  scaleDownELU?: number;
342
- minELUDiff?: number;
343
373
  timeWindowSec?: number;
374
+ scaleDownTimeWindowSec?: number;
344
375
  cooldownSec?: number;
345
376
  scaleIntervalSec?: number;
377
+ gracePeriod?: number;
346
378
  applications?: {
347
379
  [k: string]: {
348
380
  minWorkers?: number;
package/lib/config.js CHANGED
@@ -45,7 +45,7 @@ function raiseInvalidWorkersError (location, received, hint) {
45
45
  throw new InvalidArgumentError(`${location} workers must be a positive integer; received "${received}"${extra}`)
46
46
  }
47
47
 
48
- export function autoDetectPprofCapture (config) {
48
+ export function pprofCapturePreloadPath () {
49
49
  const require = createRequire(import.meta.url)
50
50
 
51
51
  let pprofCapturePath
@@ -55,6 +55,12 @@ export function autoDetectPprofCapture (config) {
55
55
  // No-op
56
56
  }
57
57
 
58
+ return pprofCapturePath
59
+ }
60
+
61
+ export function autoDetectPprofCapture (config) {
62
+ const pprofCapturePath = pprofCapturePreloadPath()
63
+
58
64
  // Add to preload if not already present
59
65
  if (!config.preload) {
60
66
  config.preload = []
@@ -192,14 +198,7 @@ export async function transform (config, _, context) {
192
198
  config = join(entryPath, configFilename)
193
199
  }
194
200
 
195
- const application = {
196
- id,
197
- config,
198
- path: entryPath,
199
- useHttp: !!mapping.useHttp,
200
- health: mapping.health,
201
- dependencies: mapping.dependencies
202
- }
201
+ const application = { id, config, path: entryPath, ...mapping }
203
202
  const existingApplicationId = applications.findIndex(application => application.id === id)
204
203
 
205
204
  if (existingApplicationId !== -1) {
@@ -336,6 +335,13 @@ export async function transform (config, _, context) {
336
335
  // like adding other applications.
337
336
  }
338
337
 
338
+ if (config.metrics === true) {
339
+ config.metrics = {
340
+ enabled: true,
341
+ timeout: 1000
342
+ }
343
+ }
344
+
339
345
  config.applications = applications
340
346
  config.web = undefined
341
347
  config.services = undefined
package/lib/metrics.js ADDED
@@ -0,0 +1,73 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import si from 'systeminformation'
3
+
4
+ async function readNumberFromCgroupFile (path) {
5
+ try {
6
+ const raw = (await readFile(path, 'utf8')).trim()
7
+ if (raw === 'max') return null
8
+ return Number(raw)
9
+ } catch {
10
+ return null
11
+ }
12
+ }
13
+
14
+ async function getCgroupV2MemoryInfo () {
15
+ let [total, used] = await Promise.all([
16
+ readNumberFromCgroupFile('/sys/fs/cgroup/memory.max'),
17
+ readNumberFromCgroupFile('/sys/fs/cgroup/memory.current')
18
+ ])
19
+ if (total == null && used == null) return null
20
+
21
+ if (total === null) {
22
+ const mem = await si.mem()
23
+ total = mem.total
24
+ }
25
+
26
+ return { scope: 'cgroup-v2', used, total }
27
+ }
28
+
29
+ async function getCgroupV1MemoryInfo () {
30
+ let [total, used] = await Promise.all([
31
+ readNumberFromCgroupFile('/sys/fs/cgroup/memory/memory.limit_in_bytes'),
32
+ readNumberFromCgroupFile('/sys/fs/cgroup/memory/memory.usage_in_bytes')
33
+ ])
34
+ if (total == null && used == null) return null
35
+
36
+ // Some v1 setups report 9.22e18 (≈unlimited)
37
+ if (total === null || total > 1e18) {
38
+ const mem = await si.mem()
39
+ total = mem.total
40
+ }
41
+
42
+ return { scope: 'cgroup-v1', used, total }
43
+ }
44
+
45
+ async function readHostMemoryInfo () {
46
+ const mem = await si.mem()
47
+ return { scope: 'host', used: mem.active, total: mem.total }
48
+ }
49
+
50
+ export async function getMemoryInfo (options = {}) {
51
+ const scope = options.scope
52
+
53
+ if (scope === 'cgroup-v2') {
54
+ return getCgroupV2MemoryInfo()
55
+ }
56
+ if (scope === 'cgroup-v1') {
57
+ return getCgroupV1MemoryInfo()
58
+ }
59
+ if (scope === 'host') {
60
+ return readHostMemoryInfo()
61
+ }
62
+
63
+ let memInfo = await getCgroupV2MemoryInfo()
64
+
65
+ if (!memInfo) {
66
+ memInfo = await getCgroupV1MemoryInfo()
67
+ }
68
+ if (!memInfo) {
69
+ memInfo = await readHostMemoryInfo()
70
+ }
71
+
72
+ return memInfo
73
+ }
package/lib/runtime.js CHANGED
@@ -9,7 +9,6 @@ import {
9
9
  kTimeout,
10
10
  parseMemorySize
11
11
  } from '@platformatic/foundation'
12
- import os from 'node:os'
13
12
  import { ITC } from '@platformatic/itc'
14
13
  import fastify from 'fastify'
15
14
  import { EventEmitter, once } from 'node:events'
@@ -17,13 +16,15 @@ import { existsSync } from 'node:fs'
17
16
  import { readFile } from 'node:fs/promises'
18
17
  import { STATUS_CODES } from 'node:http'
19
18
  import { createRequire } from 'node:module'
20
- import { join } from 'node:path'
19
+ import os from 'node:os'
20
+ import { dirname, isAbsolute, join } from 'node:path'
21
21
  import { setImmediate as immediate, setTimeout as sleep } from 'node:timers/promises'
22
22
  import { pathToFileURL } from 'node:url'
23
23
  import { Worker } from 'node:worker_threads'
24
24
  import SonicBoom from 'sonic-boom'
25
25
  import { Agent, request, interceptors as undiciInterceptors } from 'undici'
26
26
  import { createThreadInterceptor } from 'undici-thread-interceptor'
27
+ import { pprofCapturePreloadPath } from './config.js'
27
28
  import {
28
29
  ApplicationAlreadyStartedError,
29
30
  ApplicationNotFoundError,
@@ -40,12 +41,13 @@ import {
40
41
  import { abstractLogger, createLogger } from './logger.js'
41
42
  import { startManagementApi } from './management-api.js'
42
43
  import { startPrometheusServer } from './prom-server.js'
44
+ import ScalingAlgorithm from './scaling-algorithm.js'
43
45
  import { startScheduler } from './scheduler.js'
44
46
  import { createSharedStore } from './shared-http-cache.js'
45
47
  import { version } from './version.js'
46
48
  import { sendViaITC, waitEventFromITC } from './worker/itc.js'
47
49
  import { RoundRobinMap } from './worker/round-robin-map.js'
48
- import ScalingAlgorithm from './scaling-algorithm.js'
50
+ import { getMemoryInfo } from './metrics.js'
49
51
  import {
50
52
  kApplicationId,
51
53
  kConfig,
@@ -57,7 +59,8 @@ import {
57
59
  kStderrMarker,
58
60
  kWorkerId,
59
61
  kWorkersBroadcast,
60
- kWorkerStatus
62
+ kWorkerStatus,
63
+ kWorkerStartTime
61
64
  } from './worker/symbols.js'
62
65
 
63
66
  const kWorkerFile = join(import.meta.dirname, 'worker/main.js')
@@ -277,7 +280,7 @@ export class Runtime extends EventEmitter {
277
280
  }
278
281
 
279
282
  if (this.#config.verticalScaler?.enabled) {
280
- this.#setupVerticalScaler()
283
+ await this.#setupVerticalScaler()
281
284
  }
282
285
 
283
286
  this.#showUrl()
@@ -891,8 +894,12 @@ export class Runtime extends EventEmitter {
891
894
  continue
892
895
  }
893
896
 
894
- const applicationMetrics = await sendViaITC(worker, 'getMetrics', format)
895
- if (applicationMetrics) {
897
+ const applicationMetrics = await executeWithTimeout(
898
+ sendViaITC(worker, 'getMetrics', format),
899
+ this.#config.metrics?.timeout ?? 10000
900
+ )
901
+
902
+ if (applicationMetrics && applicationMetrics !== kTimeout) {
896
903
  if (metrics === null) {
897
904
  metrics = format === 'json' ? [] : ''
898
905
  }
@@ -1315,6 +1322,17 @@ export class Runtime extends EventEmitter {
1315
1322
  execArgv.push('--enable-source-maps')
1316
1323
  }
1317
1324
 
1325
+ if (applicationConfig.permissions?.fs) {
1326
+ execArgv.push(...this.#setupPermissions(applicationConfig))
1327
+ }
1328
+
1329
+ let preload = config.preload
1330
+ if (execArgv.includes('--permission')) {
1331
+ // Remove wattpm-pprof-capture from preload since it is not supported
1332
+ const pprofCapturePath = pprofCapturePreloadPath()
1333
+ preload = preload.filter(p => p !== pprofCapturePath)
1334
+ }
1335
+
1318
1336
  const workerEnv = structuredClone(this.#env)
1319
1337
 
1320
1338
  if (applicationConfig.nodeOptions?.trim().length > 0) {
@@ -1337,7 +1355,10 @@ export class Runtime extends EventEmitter {
1337
1355
 
1338
1356
  const worker = new Worker(kWorkerFile, {
1339
1357
  workerData: {
1340
- config,
1358
+ config: {
1359
+ ...config,
1360
+ preload
1361
+ },
1341
1362
  applicationConfig: {
1342
1363
  ...applicationConfig,
1343
1364
  isProduction: this.#isProduction,
@@ -1642,6 +1663,8 @@ export class Runtime extends EventEmitter {
1642
1663
  }
1643
1664
 
1644
1665
  worker[kWorkerStatus] = 'started'
1666
+ worker[kWorkerStartTime] = Date.now()
1667
+
1645
1668
  this.emitAndNotify('application:worker:started', eventPayload)
1646
1669
  this.#broadcastWorkers()
1647
1670
 
@@ -2441,36 +2464,55 @@ export class Runtime extends EventEmitter {
2441
2464
  }
2442
2465
  }
2443
2466
 
2444
- #setupVerticalScaler () {
2445
- const isWorkersFixed = this.#config.workers !== undefined
2446
- if (isWorkersFixed) return
2467
+ async #setupVerticalScaler () {
2468
+ const fixedWorkersCount = this.#config.workers
2469
+ if (fixedWorkersCount !== undefined) {
2470
+ this.logger.warn(
2471
+ `Vertical scaler disabled because the "workers" configuration is set to ${fixedWorkersCount}`
2472
+ )
2473
+ return
2474
+ }
2447
2475
 
2448
2476
  const scalerConfig = this.#config.verticalScaler
2477
+ const memInfo = await getMemoryInfo()
2478
+ const memScope = memInfo.scope
2449
2479
 
2450
2480
  scalerConfig.maxTotalWorkers ??= os.availableParallelism()
2481
+ scalerConfig.maxTotalMemory ??= memInfo.total * 0.9
2451
2482
  scalerConfig.maxWorkers ??= scalerConfig.maxTotalWorkers
2452
2483
  scalerConfig.minWorkers ??= 1
2453
2484
  scalerConfig.cooldownSec ??= 60
2454
2485
  scalerConfig.scaleUpELU ??= 0.8
2455
2486
  scalerConfig.scaleDownELU ??= 0.2
2456
- scalerConfig.minELUDiff ??= 0.2
2457
2487
  scalerConfig.scaleIntervalSec ??= 60
2458
- scalerConfig.timeWindowSec ??= 60
2488
+ scalerConfig.timeWindowSec ??= 10
2489
+ scalerConfig.scaleDownTimeWindowSec ??= 60
2490
+ scalerConfig.gracePeriod ??= 30 * 1000
2459
2491
  scalerConfig.applications ??= {}
2460
2492
 
2461
2493
  const maxTotalWorkers = scalerConfig.maxTotalWorkers
2494
+ const maxTotalMemory = scalerConfig.maxTotalMemory
2462
2495
  const maxWorkers = scalerConfig.maxWorkers
2463
2496
  const minWorkers = scalerConfig.minWorkers
2464
2497
  const cooldown = scalerConfig.cooldownSec
2465
2498
  const scaleUpELU = scalerConfig.scaleUpELU
2466
2499
  const scaleDownELU = scalerConfig.scaleDownELU
2467
- const minELUDiff = scalerConfig.minELUDiff
2468
2500
  const scaleIntervalSec = scalerConfig.scaleIntervalSec
2469
2501
  const timeWindowSec = scalerConfig.timeWindowSec
2502
+ const scaleDownTimeWindowSec = scalerConfig.scaleDownTimeWindowSec
2470
2503
  const applicationsConfigs = scalerConfig.applications
2504
+ const gracePeriod = scalerConfig.gracePeriod
2505
+ const healthCheckInterval = 1000
2506
+
2507
+ const initialResourcesUpdates = []
2471
2508
 
2472
2509
  for (const application of this.#config.applications) {
2473
2510
  if (application.entrypoint && !features.node.reusePort) {
2511
+ this.logger.warn(
2512
+ `The "${application.id}" application cannot be scaled because it is an entrypoint` +
2513
+ ' and the "reusePort" feature is not available in your OS.'
2514
+ )
2515
+
2474
2516
  applicationsConfigs[application.id] = {
2475
2517
  minWorkers: 1,
2476
2518
  maxWorkers: 1
@@ -2478,6 +2520,10 @@ export class Runtime extends EventEmitter {
2478
2520
  continue
2479
2521
  }
2480
2522
  if (application.workers !== undefined) {
2523
+ this.logger.warn(
2524
+ `The "${application.id}" application cannot be scaled because` +
2525
+ ` it has a fixed number of workers (${application.workers}).`
2526
+ )
2481
2527
  applicationsConfigs[application.id] = {
2482
2528
  minWorkers: application.workers,
2483
2529
  maxWorkers: application.workers
@@ -2488,12 +2534,22 @@ export class Runtime extends EventEmitter {
2488
2534
  applicationsConfigs[application.id] ??= {}
2489
2535
  applicationsConfigs[application.id].minWorkers ??= minWorkers
2490
2536
  applicationsConfigs[application.id].maxWorkers ??= maxWorkers
2537
+
2538
+ const appMinWorkers = applicationsConfigs[application.id].minWorkers
2539
+ if (appMinWorkers > 1) {
2540
+ initialResourcesUpdates.push({
2541
+ application: application.id,
2542
+ workers: minWorkers
2543
+ })
2544
+ }
2545
+ }
2546
+
2547
+ if (initialResourcesUpdates.length > 0) {
2548
+ await this.updateApplicationsResources(initialResourcesUpdates)
2491
2549
  }
2492
2550
 
2493
2551
  for (const applicationId in applicationsConfigs) {
2494
- const application = this.#config.applications.find(
2495
- app => app.id === applicationId
2496
- )
2552
+ const application = this.#config.applications.find(app => app.id === applicationId)
2497
2553
  if (!application) {
2498
2554
  delete applicationsConfigs[applicationId]
2499
2555
 
@@ -2507,23 +2563,50 @@ export class Runtime extends EventEmitter {
2507
2563
  maxTotalWorkers,
2508
2564
  scaleUpELU,
2509
2565
  scaleDownELU,
2510
- minELUDiff,
2511
- timeWindowSec,
2566
+ scaleUpTimeWindowSec: timeWindowSec,
2567
+ scaleDownTimeWindowSec,
2512
2568
  applications: applicationsConfigs
2513
2569
  })
2514
2570
 
2515
- this.on('application:worker:health', async (healthInfo) => {
2516
- if (!healthInfo) {
2517
- this.logger.error('No health info received')
2518
- return
2519
- }
2571
+ const healthCheckTimeout = setTimeout(async () => {
2572
+ let shouldCheckForScaling = false
2573
+
2574
+ const now = Date.now()
2520
2575
 
2521
- scalingAlgorithm.addWorkerHealthInfo(healthInfo)
2576
+ for (const worker of this.#workers.values()) {
2577
+ if (
2578
+ worker[kWorkerStatus] !== 'started' ||
2579
+ worker[kWorkerStartTime] + gracePeriod > now
2580
+ ) {
2581
+ continue
2582
+ }
2583
+
2584
+ try {
2585
+ const health = await this.#getHealth(worker)
2586
+ if (!health) continue
2587
+
2588
+ scalingAlgorithm.addWorkerHealthInfo({
2589
+ workerId: worker[kId],
2590
+ applicationId: worker[kApplicationId],
2591
+ elu: health.elu,
2592
+ heapUsed: health.heapUsed,
2593
+ heapTotal: health.heapTotal
2594
+ })
2595
+
2596
+ if (health.elu > scaleUpELU) {
2597
+ shouldCheckForScaling = true
2598
+ }
2599
+ } catch (err) {
2600
+ this.logger.error({ err }, 'Failed to get health for worker')
2601
+ }
2602
+ }
2522
2603
 
2523
- if (healthInfo.currentHealth.elu > scaleUpELU) {
2604
+ if (shouldCheckForScaling) {
2524
2605
  await checkForScaling()
2525
2606
  }
2526
- })
2607
+
2608
+ healthCheckTimeout.refresh()
2609
+ }, healthCheckInterval).unref()
2527
2610
 
2528
2611
  let isScaling = false
2529
2612
  let lastScaling = 0
@@ -2535,6 +2618,7 @@ export class Runtime extends EventEmitter {
2535
2618
 
2536
2619
  try {
2537
2620
  const workersInfo = await this.getWorkers()
2621
+ const mem = await getMemoryInfo({ scope: memScope })
2538
2622
 
2539
2623
  const appsWorkersInfo = {}
2540
2624
  for (const worker of Object.values(workersInfo)) {
@@ -2545,19 +2629,22 @@ export class Runtime extends EventEmitter {
2545
2629
  appsWorkersInfo[applicationId]++
2546
2630
  }
2547
2631
 
2548
- const recommendations = scalingAlgorithm.getRecommendations(appsWorkersInfo)
2632
+ const availableMemory = maxTotalMemory - mem.used
2633
+ const recommendations = scalingAlgorithm.getRecommendations(appsWorkersInfo, {
2634
+ availableMemory
2635
+ })
2549
2636
  if (recommendations.length > 0) {
2550
2637
  await applyRecommendations(recommendations)
2638
+ lastScaling = Date.now()
2551
2639
  }
2552
2640
  } catch (err) {
2553
2641
  this.logger.error({ err }, 'Failed to scale applications')
2554
2642
  } finally {
2555
2643
  isScaling = false
2556
- lastScaling = Date.now()
2557
2644
  }
2558
2645
  }
2559
2646
 
2560
- const applyRecommendations = async (recommendations) => {
2647
+ const applyRecommendations = async recommendations => {
2561
2648
  const resourcesUpdates = []
2562
2649
  for (const recommendation of recommendations) {
2563
2650
  const { applicationId, workersCount, direction } = recommendation
@@ -2574,4 +2661,48 @@ export class Runtime extends EventEmitter {
2574
2661
  // Interval for periodic scaling checks
2575
2662
  setInterval(checkForScaling, scaleIntervalSec * 1000).unref()
2576
2663
  }
2664
+
2665
+ #setupPermissions (applicationConfig) {
2666
+ const argv = []
2667
+ const allows = new Set()
2668
+ const { read, write } = applicationConfig.permissions.fs
2669
+
2670
+ if (read?.length) {
2671
+ for (const p of read) {
2672
+ allows.add(`--allow-fs-read=${isAbsolute(p) ? p : join(applicationConfig.path, p)}`)
2673
+ }
2674
+ }
2675
+
2676
+ if (write?.length) {
2677
+ for (const p of write) {
2678
+ allows.add(`--allow-fs-write=${isAbsolute(p) ? p : join(applicationConfig.path, p)}`)
2679
+ }
2680
+ }
2681
+
2682
+ if (allows.size === 0) {
2683
+ return argv
2684
+ }
2685
+
2686
+ // We need to allow read access to the node_modules folder both at the runtime level and at the application level
2687
+ allows.add(`--allow-fs-read=${join(this.#root, 'node_modules', '*')}`)
2688
+ allows.add(`--allow-fs-read=${join(applicationConfig.path, 'node_modules', '*')}`)
2689
+
2690
+ // Since we can't really predict how dependencies are installed (symlinks, pnpm store, and so forth), we also
2691
+ // add any node_modules folder found in the ancestors of the current file
2692
+ let lastPath = import.meta.dirname
2693
+ let currentPath = import.meta.dirname
2694
+
2695
+ do {
2696
+ lastPath = currentPath
2697
+ const nodeModules = join(currentPath, 'node_modules')
2698
+ if (existsSync(nodeModules)) {
2699
+ allows.add(`--allow-fs-read=${join(nodeModules, '*')}`)
2700
+ }
2701
+
2702
+ currentPath = dirname(currentPath)
2703
+ } while (lastPath !== currentPath)
2704
+
2705
+ argv.push('--permission', ...allows)
2706
+ return argv
2707
+ }
2577
2708
  }
@@ -2,151 +2,201 @@ class ScalingAlgorithm {
2
2
  #scaleUpELU
3
3
  #scaleDownELU
4
4
  #maxTotalWorkers
5
- #timeWindowSec
6
- #appsELUs
7
- #minELUDiff
5
+ #scaleUpTimeWindowSec
6
+ #scaleDownTimeWindowSec
7
+ #appsMetrics
8
8
  #appsConfigs
9
9
 
10
10
  constructor (options = {}) {
11
11
  this.#scaleUpELU = options.scaleUpELU ?? 0.8
12
12
  this.#scaleDownELU = options.scaleDownELU ?? 0.2
13
- this.#maxTotalWorkers = options.maxTotalWorkers
14
- this.#minELUDiff = options.minELUDiff ?? 0.2
15
- this.#timeWindowSec = options.timeWindowSec ?? 60
13
+ this.#maxTotalWorkers = options.maxTotalWorkers ?? Infinity
14
+ this.#scaleUpTimeWindowSec = options.scaleUpTimeWindowSec ?? 10
15
+ this.#scaleDownTimeWindowSec = options.scaleDownTimeWindowSec ?? 60
16
16
  this.#appsConfigs = options.applications ?? {}
17
17
 
18
- this.#appsELUs = {}
18
+ this.#appsMetrics = {}
19
19
  }
20
20
 
21
21
  addWorkerHealthInfo (healthInfo) {
22
- const workerId = healthInfo.id
23
- const applicationId = healthInfo.application
24
- const elu = healthInfo.currentHealth.elu
22
+ const { workerId, applicationId, elu, heapUsed } = healthInfo
25
23
  const timestamp = Date.now()
26
24
 
27
- if (!this.#appsELUs[applicationId]) {
28
- this.#appsELUs[applicationId] = {}
25
+ if (!this.#appsMetrics[applicationId]) {
26
+ this.#appsMetrics[applicationId] = {}
29
27
  }
30
- if (!this.#appsELUs[applicationId][workerId]) {
31
- this.#appsELUs[applicationId][workerId] = []
28
+ if (!this.#appsMetrics[applicationId][workerId]) {
29
+ this.#appsMetrics[applicationId][workerId] = []
32
30
  }
33
- this.#appsELUs[applicationId][workerId].push({ elu, timestamp })
31
+ this.#appsMetrics[applicationId][workerId].push({
32
+ elu,
33
+ timestamp,
34
+ heapUsed
35
+ })
34
36
  this.#removeOutdatedAppELUs(applicationId)
35
37
  }
36
38
 
37
- getRecommendations (appsWorkersInfo) {
39
+ getRecommendations (appsWorkersInfo, options = {}) {
38
40
  let totalWorkersCount = 0
39
- let appsInfo = []
41
+ let totalAvailableMemory = options.availableMemory ?? Infinity
42
+
43
+ const appsInfo = []
40
44
 
41
45
  for (const applicationId in appsWorkersInfo) {
42
46
  const workersCount = appsWorkersInfo[applicationId]
43
- const elu = this.#calculateAppAvgELU(applicationId)
44
- appsInfo.push({ applicationId, workersCount, elu })
47
+
48
+ const { heapUsed } = this.#calculateAppAvgMetrics(applicationId)
49
+
50
+ appsInfo.push({
51
+ applicationId,
52
+ workersCount,
53
+ avgHeapUsed: heapUsed,
54
+ })
55
+
45
56
  totalWorkersCount += workersCount
46
57
  }
47
58
 
48
- appsInfo = appsInfo.sort(
49
- (app1, app2) => {
50
- if (app1.elu > app2.elu) return 1
51
- if (app1.elu < app2.elu) return -1
52
- if (app1.workersCount < app2.workersCount) return 1
53
- if (app1.workersCount > app2.workersCount) return -1
54
- return 0
55
- }
56
- )
57
-
58
59
  const recommendations = []
59
60
 
60
- for (const { applicationId, elu, workersCount } of appsInfo) {
61
+ for (const { applicationId, workersCount, avgHeapUsed } of appsInfo) {
61
62
  const appMinWorkers = this.#appsConfigs[applicationId]?.minWorkers ?? 1
63
+ const appMaxWorkers = this.#appsConfigs[applicationId]?.maxWorkers ?? this.#maxTotalWorkers
62
64
 
63
- if (elu < this.#scaleDownELU && workersCount > appMinWorkers) {
65
+ if (workersCount < appMinWorkers) {
64
66
  recommendations.push({
65
67
  applicationId,
66
- workersCount: workersCount - 1,
67
- direction: 'down'
68
+ workersCount: appMinWorkers,
69
+ direction: 'up'
68
70
  })
69
- totalWorkersCount--
71
+
72
+ const newWorkersCount = appMinWorkers - workersCount
73
+ totalWorkersCount += newWorkersCount
74
+ totalAvailableMemory += newWorkersCount * avgHeapUsed
75
+ continue
70
76
  }
71
- }
72
77
 
73
- for (const scaleUpCandidate of appsInfo.toReversed()) {
74
- if (scaleUpCandidate.elu < this.#scaleUpELU) break
78
+ if (workersCount > appMaxWorkers) {
79
+ recommendations.push({
80
+ applicationId,
81
+ workersCount: appMaxWorkers,
82
+ direction: 'down'
83
+ })
75
84
 
76
- const { applicationId, workersCount } = scaleUpCandidate
85
+ const removedWorkersCount = workersCount - appMaxWorkers
86
+ totalWorkersCount -= removedWorkersCount
87
+ totalAvailableMemory -= removedWorkersCount * avgHeapUsed
88
+ continue
89
+ }
77
90
 
78
- const appMaxWorkers = this.#appsConfigs[applicationId]?.maxWorkers ?? this.#maxTotalWorkers
79
- if (workersCount >= appMaxWorkers) continue
80
-
81
- if (totalWorkersCount >= this.#maxTotalWorkers) {
82
- let scaleDownCandidate = null
83
- for (const app of appsInfo) {
84
- const appMinWorkers = this.#appsConfigs[app.applicationId]?.minWorkers ?? 1
85
- if (app.workersCount > appMinWorkers) {
86
- scaleDownCandidate = app
87
- break
88
- }
91
+ if (workersCount > appMinWorkers) {
92
+ const recommendation = this.#getApplicationScaleRecommendation(applicationId)
93
+ if (recommendation.recommendation === 'scaleDown') {
94
+ recommendations.push({
95
+ applicationId,
96
+ workersCount: workersCount - 1,
97
+ direction: 'down'
98
+ })
99
+
100
+ const removedWorkersCount = 1
101
+ totalWorkersCount -= removedWorkersCount
102
+ totalAvailableMemory -= removedWorkersCount * avgHeapUsed
89
103
  }
104
+ }
105
+ }
90
106
 
91
- if (scaleDownCandidate) {
92
- const eluDiff = scaleUpCandidate.elu - scaleDownCandidate.elu
93
- const workersDiff = scaleDownCandidate.workersCount - scaleUpCandidate.workersCount
94
-
95
- if (eluDiff >= this.#minELUDiff || workersDiff >= 2) {
96
- recommendations.push({
97
- applicationId: scaleDownCandidate.applicationId,
98
- workersCount: scaleDownCandidate.workersCount - 1,
99
- direction: 'down'
100
- })
101
- recommendations.push({
102
- applicationId,
103
- workersCount: workersCount + 1,
104
- direction: 'up'
105
- })
107
+ if (totalWorkersCount < this.#maxTotalWorkers) {
108
+ let scaleUpCandidate = null
109
+
110
+ for (const { applicationId, workersCount, avgHeapUsed } of appsInfo) {
111
+ const appMaxWorkers = this.#appsConfigs[applicationId]?.maxWorkers ?? this.#maxTotalWorkers
112
+ if (workersCount >= appMaxWorkers) continue
113
+ if (avgHeapUsed >= totalAvailableMemory) continue
114
+
115
+ const isScaled = recommendations.some(
116
+ r => r.applicationId === applicationId
117
+ )
118
+ if (isScaled) continue
119
+
120
+ const recommendation = this.#getApplicationScaleRecommendation(applicationId)
121
+ if (recommendation.recommendation !== 'scaleUp') continue
122
+
123
+ if (
124
+ !scaleUpCandidate ||
125
+ (recommendation.scaleUpELU > scaleUpCandidate.scaleUpELU) ||
126
+ (recommendation.scaleUpELU === scaleUpCandidate.scaleUpELU &&
127
+ workersCount < scaleUpCandidate.workersCount
128
+ )
129
+ ) {
130
+ scaleUpCandidate = {
131
+ applicationId,
132
+ workersCount,
133
+ heapUsed: recommendation.avgHeapUsage,
134
+ elu: recommendation.scaleUpELU
106
135
  }
107
136
  }
108
- } else {
137
+ }
138
+
139
+ if (scaleUpCandidate) {
109
140
  recommendations.push({
110
- applicationId,
111
- workersCount: workersCount + 1,
141
+ applicationId: scaleUpCandidate.applicationId,
142
+ workersCount: scaleUpCandidate.workersCount + 1,
112
143
  direction: 'up'
113
144
  })
114
145
  totalWorkersCount++
146
+ totalAvailableMemory -= scaleUpCandidate.heapUsed
115
147
  }
116
- break
117
148
  }
118
149
 
119
150
  return recommendations
120
151
  }
121
152
 
122
- #calculateAppAvgELU (applicationId) {
153
+ #calculateAppAvgMetrics (applicationId, options = {}) {
123
154
  this.#removeOutdatedAppELUs(applicationId)
124
155
 
125
- const appELUs = this.#appsELUs[applicationId]
126
- if (!appELUs) return 0
156
+ const appMetrics = this.#appsMetrics[applicationId]
157
+ if (!appMetrics) return { elu: 0, heapUsed: 0 }
158
+
159
+ const defaultTimeWindow = this.#getMetricsTimeWindow()
160
+ const timeWindow = options.timeWindow ?? defaultTimeWindow
127
161
 
128
162
  let eluSum = 0
129
- let eluCount = 0
163
+ let heapUsedSum = 0
164
+ let count = 0
130
165
 
131
- for (const workerId in appELUs) {
132
- const workerELUs = appELUs[workerId]
133
- const workerELUSum = workerELUs.reduce(
134
- (sum, workerELU) => sum + workerELU.elu, 0
135
- )
136
- eluSum += workerELUSum / workerELUs.length
137
- eluCount++
138
- }
166
+ const now = Date.now()
167
+
168
+ for (const workerId in appMetrics) {
169
+ const workerMetrics = appMetrics[workerId]
170
+
171
+ let workerELUSum = 0
172
+ let workerHeapUsedSum = 0
173
+ let metricCount = 0
174
+
175
+ for (const metric of workerMetrics) {
176
+ if (metric.timestamp < now - timeWindow) continue
177
+ workerELUSum += metric.elu
178
+ workerHeapUsedSum += metric.heapUsed
179
+ metricCount++
180
+ }
181
+
182
+ if (metricCount === 0) continue
139
183
 
140
- if (eluCount === 0) return 0
184
+ eluSum += workerELUSum / metricCount
185
+ heapUsedSum += workerHeapUsedSum / metricCount
186
+ count++
187
+ }
141
188
 
142
- return Math.round(eluSum / eluCount * 100) / 100
189
+ const elu = Math.round(eluSum / count * 100) / 100
190
+ const heapUsed = Math.round(heapUsedSum / count * 100) / 100
191
+ return { elu, heapUsed }
143
192
  }
144
193
 
145
194
  #removeOutdatedAppELUs (applicationId) {
146
- const appELUs = this.#appsELUs[applicationId]
195
+ const appELUs = this.#appsMetrics[applicationId]
147
196
  if (!appELUs) return
148
197
 
149
198
  const now = Date.now()
199
+ const timeWindow = this.#getMetricsTimeWindow()
150
200
 
151
201
  for (const workerId in appELUs) {
152
202
  const workerELUs = appELUs[workerId]
@@ -154,7 +204,7 @@ class ScalingAlgorithm {
154
204
  let firstValidIndex = -1
155
205
  for (let i = 0; i < workerELUs.length; i++) {
156
206
  const timestamp = workerELUs[i].timestamp
157
- if (timestamp >= now - this.#timeWindowSec * 1000) {
207
+ if (timestamp >= now - timeWindow) {
158
208
  firstValidIndex = i
159
209
  break
160
210
  }
@@ -174,6 +224,30 @@ class ScalingAlgorithm {
174
224
  }
175
225
  }
176
226
  }
227
+
228
+ #getMetricsTimeWindow () {
229
+ return Math.max(this.#scaleUpTimeWindowSec, this.#scaleDownTimeWindowSec) * 1000
230
+ }
231
+
232
+ #getApplicationScaleRecommendation (applicationId) {
233
+ const { elu: scaleUpELU } = this.#calculateAppAvgMetrics(applicationId, {
234
+ timeWindow: this.#scaleUpTimeWindowSec * 1000
235
+ })
236
+ const { elu: scaleDownELU } = this.#calculateAppAvgMetrics(applicationId, {
237
+ timeWindow: this.#scaleDownTimeWindowSec * 1000
238
+ })
239
+ const { heapUsed: avgHeapUsage } = this.#calculateAppAvgMetrics(applicationId)
240
+
241
+ let recommendation = null
242
+ if (scaleUpELU > this.#scaleUpELU) {
243
+ recommendation = 'scaleUp'
244
+ }
245
+ if (scaleDownELU < this.#scaleDownELU) {
246
+ recommendation = 'scaleDown'
247
+ }
248
+
249
+ return { recommendation, scaleUpELU, scaleDownELU, avgHeapUsage }
250
+ }
177
251
  }
178
252
 
179
253
  export default ScalingAlgorithm
@@ -6,6 +6,7 @@ export const kWorkerId = Symbol.for('plt.runtime.worker.id')
6
6
  export const kITC = Symbol.for('plt.runtime.itc')
7
7
  export const kHealthCheckTimer = Symbol.for('plt.runtime.worker.healthCheckTimer')
8
8
  export const kWorkerStatus = Symbol('plt.runtime.worker.status')
9
+ export const kWorkerStartTime = Symbol.for('plt.runtime.worker.startTime')
9
10
  export const kInterceptors = Symbol.for('plt.runtime.worker.interceptors')
10
11
  export const kLastELU = Symbol.for('plt.runtime.worker.lastELU')
11
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/runtime",
3
- "version": "3.9.0",
3
+ "version": "3.11.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -18,8 +18,8 @@
18
18
  "@fastify/compress": "^8.0.0",
19
19
  "@fastify/express": "^4.0.0",
20
20
  "@fastify/formbody": "^8.0.0",
21
- "autocannon": "^8.0.0",
22
21
  "atomic-sleep": "^1.0.0",
22
+ "autocannon": "^8.0.0",
23
23
  "c8": "^10.0.0",
24
24
  "cleaner-spec-reporter": "^0.5.0",
25
25
  "eslint": "9",
@@ -35,14 +35,14 @@
35
35
  "typescript": "^5.5.4",
36
36
  "undici-oidc-interceptor": "^0.5.0",
37
37
  "why-is-node-running": "^2.2.2",
38
- "@platformatic/composer": "3.9.0",
39
- "@platformatic/db": "3.9.0",
40
- "@platformatic/gateway": "3.9.0",
41
- "@platformatic/sql-graphql": "3.9.0",
42
- "@platformatic/node": "3.9.0",
43
- "@platformatic/sql-mapper": "3.9.0",
44
- "@platformatic/service": "3.9.0",
45
- "@platformatic/wattpm-pprof-capture": "3.9.0"
38
+ "@platformatic/composer": "3.11.0",
39
+ "@platformatic/db": "3.11.0",
40
+ "@platformatic/gateway": "3.11.0",
41
+ "@platformatic/node": "3.11.0",
42
+ "@platformatic/service": "3.11.0",
43
+ "@platformatic/sql-mapper": "3.11.0",
44
+ "@platformatic/wattpm-pprof-capture": "3.11.0",
45
+ "@platformatic/sql-graphql": "3.11.0"
46
46
  },
47
47
  "dependencies": {
48
48
  "@fastify/accepts": "^5.0.0",
@@ -69,15 +69,16 @@
69
69
  "prom-client": "^15.1.2",
70
70
  "semgrator": "^0.3.0",
71
71
  "sonic-boom": "^4.2.0",
72
+ "systeminformation": "^5.27.11",
72
73
  "undici": "^7.0.0",
73
74
  "undici-thread-interceptor": "^0.14.0",
74
75
  "ws": "^8.16.0",
75
- "@platformatic/foundation": "3.9.0",
76
- "@platformatic/generators": "3.9.0",
77
- "@platformatic/itc": "3.9.0",
78
- "@platformatic/basic": "3.9.0",
79
- "@platformatic/metrics": "3.9.0",
80
- "@platformatic/telemetry": "3.9.0"
76
+ "@platformatic/basic": "3.11.0",
77
+ "@platformatic/foundation": "3.11.0",
78
+ "@platformatic/itc": "3.11.0",
79
+ "@platformatic/generators": "3.11.0",
80
+ "@platformatic/metrics": "3.11.0",
81
+ "@platformatic/telemetry": "3.11.0"
81
82
  },
82
83
  "engines": {
83
84
  "node": ">=22.19.0"
package/schema.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.9.0.json",
2
+ "$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.11.0.json",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "title": "Platformatic Runtime Config",
5
5
  "type": "object",
@@ -171,6 +171,40 @@
171
171
  },
172
172
  "additionalProperties": false
173
173
  },
174
+ "dependencies": {
175
+ "type": "array",
176
+ "items": {
177
+ "type": "string"
178
+ },
179
+ "default": []
180
+ },
181
+ "arguments": {
182
+ "type": "array",
183
+ "items": {
184
+ "type": "string"
185
+ }
186
+ },
187
+ "env": {
188
+ "type": "object",
189
+ "additionalProperties": {
190
+ "type": "string"
191
+ }
192
+ },
193
+ "envfile": {
194
+ "type": "string"
195
+ },
196
+ "sourceMaps": {
197
+ "type": "boolean",
198
+ "default": false
199
+ },
200
+ "packageManager": {
201
+ "type": "string",
202
+ "enum": [
203
+ "npm",
204
+ "pnpm",
205
+ "yarn"
206
+ ]
207
+ },
174
208
  "preload": {
175
209
  "anyOf": [
176
210
  {
@@ -186,20 +220,66 @@
186
220
  }
187
221
  ]
188
222
  },
189
- "dependencies": {
190
- "type": "array",
191
- "items": {
192
- "type": "string"
193
- }
194
- },
195
- "arguments": {
196
- "type": "array",
197
- "items": {
198
- "type": "string"
199
- }
200
- },
201
223
  "nodeOptions": {
202
224
  "type": "string"
225
+ },
226
+ "permissions": {
227
+ "type": "object",
228
+ "properties": {
229
+ "fs": {
230
+ "type": "object",
231
+ "properties": {
232
+ "read": {
233
+ "type": "array",
234
+ "items": {
235
+ "type": "string"
236
+ }
237
+ },
238
+ "write": {
239
+ "type": "array",
240
+ "items": {
241
+ "type": "string"
242
+ }
243
+ }
244
+ },
245
+ "additionalProperties": false
246
+ }
247
+ },
248
+ "additionalProperties": false
249
+ },
250
+ "telemetry": {
251
+ "type": "object",
252
+ "properties": {
253
+ "instrumentations": {
254
+ "type": "array",
255
+ "description": "An array of instrumentations loaded if telemetry is enabled",
256
+ "items": {
257
+ "oneOf": [
258
+ {
259
+ "type": "string"
260
+ },
261
+ {
262
+ "type": "object",
263
+ "properties": {
264
+ "package": {
265
+ "type": "string"
266
+ },
267
+ "exportName": {
268
+ "type": "string"
269
+ },
270
+ "options": {
271
+ "type": "object",
272
+ "additionalProperties": true
273
+ }
274
+ },
275
+ "required": [
276
+ "package"
277
+ ]
278
+ }
279
+ ]
280
+ }
281
+ }
282
+ }
203
283
  }
204
284
  }
205
285
  }
@@ -405,6 +485,30 @@
405
485
  "nodeOptions": {
406
486
  "type": "string"
407
487
  },
488
+ "permissions": {
489
+ "type": "object",
490
+ "properties": {
491
+ "fs": {
492
+ "type": "object",
493
+ "properties": {
494
+ "read": {
495
+ "type": "array",
496
+ "items": {
497
+ "type": "string"
498
+ }
499
+ },
500
+ "write": {
501
+ "type": "array",
502
+ "items": {
503
+ "type": "string"
504
+ }
505
+ }
506
+ },
507
+ "additionalProperties": false
508
+ }
509
+ },
510
+ "additionalProperties": false
511
+ },
408
512
  "telemetry": {
409
513
  "type": "object",
410
514
  "properties": {
@@ -641,6 +745,30 @@
641
745
  "nodeOptions": {
642
746
  "type": "string"
643
747
  },
748
+ "permissions": {
749
+ "type": "object",
750
+ "properties": {
751
+ "fs": {
752
+ "type": "object",
753
+ "properties": {
754
+ "read": {
755
+ "type": "array",
756
+ "items": {
757
+ "type": "string"
758
+ }
759
+ },
760
+ "write": {
761
+ "type": "array",
762
+ "items": {
763
+ "type": "string"
764
+ }
765
+ }
766
+ },
767
+ "additionalProperties": false
768
+ }
769
+ },
770
+ "additionalProperties": false
771
+ },
644
772
  "telemetry": {
645
773
  "type": "object",
646
774
  "properties": {
@@ -877,6 +1005,30 @@
877
1005
  "nodeOptions": {
878
1006
  "type": "string"
879
1007
  },
1008
+ "permissions": {
1009
+ "type": "object",
1010
+ "properties": {
1011
+ "fs": {
1012
+ "type": "object",
1013
+ "properties": {
1014
+ "read": {
1015
+ "type": "array",
1016
+ "items": {
1017
+ "type": "string"
1018
+ }
1019
+ },
1020
+ "write": {
1021
+ "type": "array",
1022
+ "items": {
1023
+ "type": "string"
1024
+ }
1025
+ }
1026
+ },
1027
+ "additionalProperties": false
1028
+ }
1029
+ },
1030
+ "additionalProperties": false
1031
+ },
880
1032
  "telemetry": {
881
1033
  "type": "object",
882
1034
  "properties": {
@@ -1691,6 +1843,17 @@
1691
1843
  }
1692
1844
  ]
1693
1845
  }
1846
+ },
1847
+ "timeout": {
1848
+ "anyOf": [
1849
+ {
1850
+ "type": "integer"
1851
+ },
1852
+ {
1853
+ "type": "string"
1854
+ }
1855
+ ],
1856
+ "default": 10000
1694
1857
  }
1695
1858
  },
1696
1859
  "additionalProperties": false
@@ -1838,6 +2001,10 @@
1838
2001
  "type": "number",
1839
2002
  "minimum": 1
1840
2003
  },
2004
+ "maxTotalMemory": {
2005
+ "type": "number",
2006
+ "minimum": 0
2007
+ },
1841
2008
  "minWorkers": {
1842
2009
  "type": "number",
1843
2010
  "minimum": 1
@@ -1856,12 +2023,11 @@
1856
2023
  "minimum": 0,
1857
2024
  "maximum": 1
1858
2025
  },
1859
- "minELUDiff": {
2026
+ "timeWindowSec": {
1860
2027
  "type": "number",
1861
- "minimum": 0,
1862
- "maximum": 1
2028
+ "minimum": 0
1863
2029
  },
1864
- "timeWindowSec": {
2030
+ "scaleDownTimeWindowSec": {
1865
2031
  "type": "number",
1866
2032
  "minimum": 0
1867
2033
  },
@@ -1873,6 +2039,10 @@
1873
2039
  "type": "number",
1874
2040
  "minimum": 0
1875
2041
  },
2042
+ "gracePeriod": {
2043
+ "type": "number",
2044
+ "minimum": 0
2045
+ },
1876
2046
  "applications": {
1877
2047
  "type": "object",
1878
2048
  "additionalProperties": {