@platformatic/runtime 3.7.1 → 3.9.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
@@ -332,6 +332,24 @@ export type PlatformaticRuntimeConfig = {
332
332
  [k: string]: unknown;
333
333
  };
334
334
  };
335
+ verticalScaler?: {
336
+ enabled?: boolean;
337
+ maxTotalWorkers?: number;
338
+ minWorkers?: number;
339
+ maxWorkers?: number;
340
+ scaleUpELU?: number;
341
+ scaleDownELU?: number;
342
+ minELUDiff?: number;
343
+ timeWindowSec?: number;
344
+ cooldownSec?: number;
345
+ scaleIntervalSec?: number;
346
+ applications?: {
347
+ [k: string]: {
348
+ minWorkers?: number;
349
+ maxWorkers?: number;
350
+ };
351
+ };
352
+ };
335
353
  inspectorOptions?: {
336
354
  host?: string;
337
355
  port?: number;
@@ -126,7 +126,8 @@ export async function managementApiPlugin (app, opts) {
126
126
  const { id } = request.params
127
127
  app.log.debug('stop profiling', { id })
128
128
 
129
- const profileData = await runtime.stopApplicationProfiling(id)
129
+ const options = request.body || {}
130
+ const profileData = await runtime.stopApplicationProfiling(id, options)
130
131
  reply.type('application/octet-stream').code(200).send(profileData)
131
132
  })
132
133
 
package/lib/runtime.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  kTimeout,
10
10
  parseMemorySize
11
11
  } from '@platformatic/foundation'
12
+ import os from 'node:os'
12
13
  import { ITC } from '@platformatic/itc'
13
14
  import fastify from 'fastify'
14
15
  import { EventEmitter, once } from 'node:events'
@@ -44,6 +45,7 @@ import { createSharedStore } from './shared-http-cache.js'
44
45
  import { version } from './version.js'
45
46
  import { sendViaITC, waitEventFromITC } from './worker/itc.js'
46
47
  import { RoundRobinMap } from './worker/round-robin-map.js'
48
+ import ScalingAlgorithm from './scaling-algorithm.js'
47
49
  import {
48
50
  kApplicationId,
49
51
  kConfig,
@@ -179,7 +181,7 @@ export class Runtime extends EventEmitter {
179
181
 
180
182
  const workersConfig = []
181
183
  for (const application of config.applications) {
182
- const count = application.workers ?? this.#config.workers
184
+ const count = application.workers ?? this.#config.workers ?? 1
183
185
  if (count > 1 && application.entrypoint && !features.node.reusePort) {
184
186
  this.logger.warn(
185
187
  `"${application.id}" is set as the entrypoint, but reusePort is not available in your OS; setting workers to 1 instead of ${count}`
@@ -274,6 +276,10 @@ export class Runtime extends EventEmitter {
274
276
  this.startCollectingMetrics()
275
277
  }
276
278
 
279
+ if (this.#config.verticalScaler?.enabled) {
280
+ this.#setupVerticalScaler()
281
+ }
282
+
277
283
  this.#showUrl()
278
284
  return this.#url
279
285
  }
@@ -556,11 +562,11 @@ export class Runtime extends EventEmitter {
556
562
  return sendViaITC(service, 'startProfiling', options)
557
563
  }
558
564
 
559
- async stopApplicationProfiling (id, ensureStarted = true) {
565
+ async stopApplicationProfiling (id, options = {}, ensureStarted = true) {
560
566
  const service = await this.#getApplicationById(id, ensureStarted)
561
567
  this.#validatePprofCapturePreload()
562
568
 
563
- return sendViaITC(service, 'stopProfiling')
569
+ return sendViaITC(service, 'stopProfiling', options)
564
570
  }
565
571
 
566
572
  async updateUndiciInterceptors (undiciConfig) {
@@ -2434,4 +2440,138 @@ export class Runtime extends EventEmitter {
2434
2440
  throw new MissingPprofCapture()
2435
2441
  }
2436
2442
  }
2443
+
2444
+ #setupVerticalScaler () {
2445
+ const isWorkersFixed = this.#config.workers !== undefined
2446
+ if (isWorkersFixed) return
2447
+
2448
+ const scalerConfig = this.#config.verticalScaler
2449
+
2450
+ scalerConfig.maxTotalWorkers ??= os.availableParallelism()
2451
+ scalerConfig.maxWorkers ??= scalerConfig.maxTotalWorkers
2452
+ scalerConfig.minWorkers ??= 1
2453
+ scalerConfig.cooldownSec ??= 60
2454
+ scalerConfig.scaleUpELU ??= 0.8
2455
+ scalerConfig.scaleDownELU ??= 0.2
2456
+ scalerConfig.minELUDiff ??= 0.2
2457
+ scalerConfig.scaleIntervalSec ??= 60
2458
+ scalerConfig.timeWindowSec ??= 60
2459
+ scalerConfig.applications ??= {}
2460
+
2461
+ const maxTotalWorkers = scalerConfig.maxTotalWorkers
2462
+ const maxWorkers = scalerConfig.maxWorkers
2463
+ const minWorkers = scalerConfig.minWorkers
2464
+ const cooldown = scalerConfig.cooldownSec
2465
+ const scaleUpELU = scalerConfig.scaleUpELU
2466
+ const scaleDownELU = scalerConfig.scaleDownELU
2467
+ const minELUDiff = scalerConfig.minELUDiff
2468
+ const scaleIntervalSec = scalerConfig.scaleIntervalSec
2469
+ const timeWindowSec = scalerConfig.timeWindowSec
2470
+ const applicationsConfigs = scalerConfig.applications
2471
+
2472
+ for (const application of this.#config.applications) {
2473
+ if (application.entrypoint && !features.node.reusePort) {
2474
+ applicationsConfigs[application.id] = {
2475
+ minWorkers: 1,
2476
+ maxWorkers: 1
2477
+ }
2478
+ continue
2479
+ }
2480
+ if (application.workers !== undefined) {
2481
+ applicationsConfigs[application.id] = {
2482
+ minWorkers: application.workers,
2483
+ maxWorkers: application.workers
2484
+ }
2485
+ continue
2486
+ }
2487
+
2488
+ applicationsConfigs[application.id] ??= {}
2489
+ applicationsConfigs[application.id].minWorkers ??= minWorkers
2490
+ applicationsConfigs[application.id].maxWorkers ??= maxWorkers
2491
+ }
2492
+
2493
+ for (const applicationId in applicationsConfigs) {
2494
+ const application = this.#config.applications.find(
2495
+ app => app.id === applicationId
2496
+ )
2497
+ if (!application) {
2498
+ delete applicationsConfigs[applicationId]
2499
+
2500
+ this.logger.warn(
2501
+ `Vertical scaler configuration has a configuration for non-existing application "${applicationId}"`
2502
+ )
2503
+ }
2504
+ }
2505
+
2506
+ const scalingAlgorithm = new ScalingAlgorithm({
2507
+ maxTotalWorkers,
2508
+ scaleUpELU,
2509
+ scaleDownELU,
2510
+ minELUDiff,
2511
+ timeWindowSec,
2512
+ applications: applicationsConfigs
2513
+ })
2514
+
2515
+ this.on('application:worker:health', async (healthInfo) => {
2516
+ if (!healthInfo) {
2517
+ this.logger.error('No health info received')
2518
+ return
2519
+ }
2520
+
2521
+ scalingAlgorithm.addWorkerHealthInfo(healthInfo)
2522
+
2523
+ if (healthInfo.currentHealth.elu > scaleUpELU) {
2524
+ await checkForScaling()
2525
+ }
2526
+ })
2527
+
2528
+ let isScaling = false
2529
+ let lastScaling = 0
2530
+
2531
+ const checkForScaling = async () => {
2532
+ const isInCooldown = Date.now() < lastScaling + cooldown * 1000
2533
+ if (isScaling || isInCooldown) return
2534
+ isScaling = true
2535
+
2536
+ try {
2537
+ const workersInfo = await this.getWorkers()
2538
+
2539
+ const appsWorkersInfo = {}
2540
+ for (const worker of Object.values(workersInfo)) {
2541
+ if (worker.status === 'exited') continue
2542
+
2543
+ const applicationId = worker.application
2544
+ appsWorkersInfo[applicationId] ??= 0
2545
+ appsWorkersInfo[applicationId]++
2546
+ }
2547
+
2548
+ const recommendations = scalingAlgorithm.getRecommendations(appsWorkersInfo)
2549
+ if (recommendations.length > 0) {
2550
+ await applyRecommendations(recommendations)
2551
+ }
2552
+ } catch (err) {
2553
+ this.logger.error({ err }, 'Failed to scale applications')
2554
+ } finally {
2555
+ isScaling = false
2556
+ lastScaling = Date.now()
2557
+ }
2558
+ }
2559
+
2560
+ const applyRecommendations = async (recommendations) => {
2561
+ const resourcesUpdates = []
2562
+ for (const recommendation of recommendations) {
2563
+ const { applicationId, workersCount, direction } = recommendation
2564
+ this.logger.info(`Scaling ${direction} the "${applicationId}" app to ${workersCount} workers`)
2565
+
2566
+ resourcesUpdates.push({
2567
+ application: applicationId,
2568
+ workers: workersCount
2569
+ })
2570
+ }
2571
+ await this.updateApplicationsResources(resourcesUpdates)
2572
+ }
2573
+
2574
+ // Interval for periodic scaling checks
2575
+ setInterval(checkForScaling, scaleIntervalSec * 1000).unref()
2576
+ }
2437
2577
  }
@@ -0,0 +1,179 @@
1
+ class ScalingAlgorithm {
2
+ #scaleUpELU
3
+ #scaleDownELU
4
+ #maxTotalWorkers
5
+ #timeWindowSec
6
+ #appsELUs
7
+ #minELUDiff
8
+ #appsConfigs
9
+
10
+ constructor (options = {}) {
11
+ this.#scaleUpELU = options.scaleUpELU ?? 0.8
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
16
+ this.#appsConfigs = options.applications ?? {}
17
+
18
+ this.#appsELUs = {}
19
+ }
20
+
21
+ addWorkerHealthInfo (healthInfo) {
22
+ const workerId = healthInfo.id
23
+ const applicationId = healthInfo.application
24
+ const elu = healthInfo.currentHealth.elu
25
+ const timestamp = Date.now()
26
+
27
+ if (!this.#appsELUs[applicationId]) {
28
+ this.#appsELUs[applicationId] = {}
29
+ }
30
+ if (!this.#appsELUs[applicationId][workerId]) {
31
+ this.#appsELUs[applicationId][workerId] = []
32
+ }
33
+ this.#appsELUs[applicationId][workerId].push({ elu, timestamp })
34
+ this.#removeOutdatedAppELUs(applicationId)
35
+ }
36
+
37
+ getRecommendations (appsWorkersInfo) {
38
+ let totalWorkersCount = 0
39
+ let appsInfo = []
40
+
41
+ for (const applicationId in appsWorkersInfo) {
42
+ const workersCount = appsWorkersInfo[applicationId]
43
+ const elu = this.#calculateAppAvgELU(applicationId)
44
+ appsInfo.push({ applicationId, workersCount, elu })
45
+ totalWorkersCount += workersCount
46
+ }
47
+
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
+ const recommendations = []
59
+
60
+ for (const { applicationId, elu, workersCount } of appsInfo) {
61
+ const appMinWorkers = this.#appsConfigs[applicationId]?.minWorkers ?? 1
62
+
63
+ if (elu < this.#scaleDownELU && workersCount > appMinWorkers) {
64
+ recommendations.push({
65
+ applicationId,
66
+ workersCount: workersCount - 1,
67
+ direction: 'down'
68
+ })
69
+ totalWorkersCount--
70
+ }
71
+ }
72
+
73
+ for (const scaleUpCandidate of appsInfo.toReversed()) {
74
+ if (scaleUpCandidate.elu < this.#scaleUpELU) break
75
+
76
+ const { applicationId, workersCount } = scaleUpCandidate
77
+
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
+ }
89
+ }
90
+
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
+ })
106
+ }
107
+ }
108
+ } else {
109
+ recommendations.push({
110
+ applicationId,
111
+ workersCount: workersCount + 1,
112
+ direction: 'up'
113
+ })
114
+ totalWorkersCount++
115
+ }
116
+ break
117
+ }
118
+
119
+ return recommendations
120
+ }
121
+
122
+ #calculateAppAvgELU (applicationId) {
123
+ this.#removeOutdatedAppELUs(applicationId)
124
+
125
+ const appELUs = this.#appsELUs[applicationId]
126
+ if (!appELUs) return 0
127
+
128
+ let eluSum = 0
129
+ let eluCount = 0
130
+
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
+ }
139
+
140
+ if (eluCount === 0) return 0
141
+
142
+ return Math.round(eluSum / eluCount * 100) / 100
143
+ }
144
+
145
+ #removeOutdatedAppELUs (applicationId) {
146
+ const appELUs = this.#appsELUs[applicationId]
147
+ if (!appELUs) return
148
+
149
+ const now = Date.now()
150
+
151
+ for (const workerId in appELUs) {
152
+ const workerELUs = appELUs[workerId]
153
+
154
+ let firstValidIndex = -1
155
+ for (let i = 0; i < workerELUs.length; i++) {
156
+ const timestamp = workerELUs[i].timestamp
157
+ if (timestamp >= now - this.#timeWindowSec * 1000) {
158
+ firstValidIndex = i
159
+ break
160
+ }
161
+ }
162
+
163
+ if (firstValidIndex > 0) {
164
+ // Remove all outdated entries before the first valid one
165
+ workerELUs.splice(0, firstValidIndex)
166
+ } else if (firstValidIndex === -1) {
167
+ // All entries are outdated, clear the array
168
+ workerELUs.length = 0
169
+ }
170
+
171
+ // If there are no more workerELUs, remove the workerId
172
+ if (workerELUs.length === 0) {
173
+ delete appELUs[workerId]
174
+ }
175
+ }
176
+ }
177
+ }
178
+
179
+ export default ScalingAlgorithm
package/lib/schema.js CHANGED
@@ -16,6 +16,18 @@ const runtimeLogger = {
16
16
 
17
17
  schemaComponents.runtimeProperties.logger = runtimeLogger
18
18
 
19
+ schemaComponents.runtimeProperties.verticalScaler.properties.applications = {
20
+ type: 'object',
21
+ additionalProperties: {
22
+ type: 'object',
23
+ properties: {
24
+ minWorkers: { type: 'number', minimum: 1 },
25
+ maxWorkers: { type: 'number', minimum: 1 }
26
+ },
27
+ additionalProperties: false
28
+ }
29
+ }
30
+
19
31
  const platformaticRuntimeSchema = {
20
32
  $id: `https://schemas.platformatic.dev/@platformatic/runtime/${version}.json`,
21
33
  $schema: 'http://json-schema.org/draft-07/schema#',
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  ensureLoggableError,
3
+ executeWithTimeout,
3
4
  FileWatcher,
4
5
  kHandledError,
5
6
  listRecognizedConfigurationFiles,
@@ -24,6 +25,21 @@ function fetchApplicationUrl (application, key) {
24
25
  return getApplicationUrl(application.id)
25
26
  }
26
27
 
28
+ function handleUnhandled (app, type, err) {
29
+ const label =
30
+ workerData.worker.count > 1
31
+ ? `worker ${workerData.worker.index} of the application "${workerData.applicationConfig.id}"`
32
+ : `application "${workerData.applicationConfig.id}"`
33
+
34
+ globalThis.platformatic.logger.error({ err: ensureLoggableError(err) }, `The ${label} threw an ${type}.`)
35
+
36
+ executeWithTimeout(app?.stop(), 1000)
37
+ .catch()
38
+ .finally(() => {
39
+ process.exit(1)
40
+ })
41
+ }
42
+
27
43
  export class Controller extends EventEmitter {
28
44
  #starting
29
45
  #started
@@ -34,21 +50,13 @@ export class Controller extends EventEmitter {
34
50
  #context
35
51
  #lastELU
36
52
 
37
- constructor (
38
- appConfig,
39
- workerId,
40
- telemetryConfig,
41
- loggerConfig,
42
- serverConfig,
43
- metricsConfig,
44
- hasManagementApi,
45
- watch
46
- ) {
53
+ constructor (runtimeConfig, applicationConfig, workerId, serverConfig, metricsConfig) {
47
54
  super()
48
- this.appConfig = appConfig
49
- this.applicationId = this.appConfig.id
55
+ this.runtimeConfig = runtimeConfig
56
+ this.applicationConfig = applicationConfig
57
+ this.applicationId = this.applicationConfig.id
50
58
  this.workerId = workerId
51
- this.#watch = watch
59
+ this.#watch = !!runtimeConfig.watch
52
60
  this.#starting = false
53
61
  this.#started = false
54
62
  this.#listening = false
@@ -60,17 +68,17 @@ export class Controller extends EventEmitter {
60
68
  controller: this,
61
69
  applicationId: this.applicationId,
62
70
  workerId: this.workerId,
63
- directory: this.appConfig.path,
64
- dependencies: this.appConfig.dependencies,
65
- isEntrypoint: this.appConfig.entrypoint,
66
- isProduction: this.appConfig.isProduction,
67
- telemetryConfig,
71
+ directory: this.applicationConfig.path,
72
+ dependencies: this.applicationConfig.dependencies,
73
+ isEntrypoint: this.applicationConfig.entrypoint,
74
+ isProduction: this.applicationConfig.isProduction,
75
+ telemetryConfig: this.applicationConfig.telemetry,
76
+ loggerConfig: runtimeConfig.logger,
68
77
  metricsConfig,
69
- loggerConfig,
70
78
  serverConfig,
71
79
  worker: workerData?.worker,
72
- hasManagementApi: !!hasManagementApi,
73
- fetchApplicationUrl: fetchApplicationUrl.bind(null, appConfig)
80
+ hasManagementApi: !!runtimeConfig.managementApi,
81
+ fetchApplicationUrl: fetchApplicationUrl.bind(null, applicationConfig)
74
82
  }
75
83
  }
76
84
 
@@ -90,7 +98,7 @@ export class Controller extends EventEmitter {
90
98
  // Note: capability's init() is executed within start
91
99
  async init () {
92
100
  try {
93
- const appConfig = this.appConfig
101
+ const appConfig = this.applicationConfig
94
102
 
95
103
  if (appConfig.isProduction && !process.env.NODE_ENV) {
96
104
  process.env.NODE_ENV = 'production'
@@ -120,6 +128,10 @@ export class Controller extends EventEmitter {
120
128
  }
121
129
 
122
130
  this.#updateDispatcher()
131
+
132
+ if (this.capability.exitOnUnhandledErrors && this.runtimeConfig.exitOnUnhandledErrors) {
133
+ this.#setupHandlers()
134
+ }
123
135
  } catch (err) {
124
136
  if (err.validationErrors) {
125
137
  globalThis.platformatic.logger.error(
@@ -168,7 +180,7 @@ export class Controller extends EventEmitter {
168
180
  }
169
181
  }
170
182
 
171
- const listen = !!this.appConfig.useHttp
183
+ const listen = !!this.applicationConfig.useHttp
172
184
 
173
185
  try {
174
186
  await this.capability.start({ listen })
@@ -203,7 +215,7 @@ export class Controller extends EventEmitter {
203
215
 
204
216
  async listen () {
205
217
  // This server is not an entrypoint or already listened in start. Behave as no-op.
206
- if (!this.appConfig.entrypoint || this.appConfig.useHttp || this.#listening) {
218
+ if (!this.applicationConfig.entrypoint || this.applicationConfig.useHttp || this.#listening) {
207
219
  return
208
220
  }
209
221
 
@@ -299,4 +311,17 @@ export class Controller extends EventEmitter {
299
311
 
300
312
  setGlobalDispatcher(dispatcher)
301
313
  }
314
+
315
+ #setupHandlers () {
316
+ process.on('uncaughtException', handleUnhandled.bind(null, this, 'uncaught exception'))
317
+ process.on('unhandledRejection', handleUnhandled.bind(null, this, 'unhandled rejection'))
318
+
319
+ process.on('newListener', event => {
320
+ if (event === 'uncaughtException' || event === 'unhandledRejection') {
321
+ globalThis.platformatic.logger.warn(
322
+ `A listener has been added for the "process.${event}" event. This listener will be never triggered as Watt default behavior will kill the process before.\n To disable this behavior, set "exitOnUnhandledErrors" to false in the runtime config.`
323
+ )
324
+ }
325
+ })
326
+ }
302
327
  }
package/lib/worker/itc.js CHANGED
@@ -71,7 +71,7 @@ export async function waitEventFromITC (worker, event) {
71
71
 
72
72
  export function setupITC (controller, application, dispatcher, sharedContext) {
73
73
  const logger = globalThis.platformatic.logger
74
- const messaging = new MessagingITC(controller.appConfig.id, workerData.config, logger)
74
+ const messaging = new MessagingITC(controller.applicationConfig.id, workerData.config, logger)
75
75
 
76
76
  Object.assign(globalThis.platformatic ?? {}, {
77
77
  messaging: {
@@ -82,7 +82,7 @@ export function setupITC (controller, application, dispatcher, sharedContext) {
82
82
  })
83
83
 
84
84
  const itc = new ITC({
85
- name: controller.appConfig.id + '-worker',
85
+ name: controller.applicationConfig.id + '-worker',
86
86
  port: parentPort,
87
87
  handlers: {
88
88
  async start () {
@@ -2,8 +2,6 @@ import {
2
2
  buildPinoFormatters,
3
3
  buildPinoTimestamp,
4
4
  disablePinoDirectWrite,
5
- ensureLoggableError,
6
- executeWithTimeout,
7
5
  getPrivateSymbol
8
6
  } from '@platformatic/foundation'
9
7
  import dotenv from 'dotenv'
@@ -30,21 +28,6 @@ class ForwardingEventEmitter extends EventEmitter {
30
28
  }
31
29
  }
32
30
 
33
- function handleUnhandled (app, type, err) {
34
- const label =
35
- workerData.worker.count > 1
36
- ? `worker ${workerData.worker.index} of the application "${workerData.applicationConfig.id}"`
37
- : `application "${workerData.applicationConfig.id}"`
38
-
39
- globalThis.platformatic.logger.error({ err: ensureLoggableError(err) }, `The ${label} threw an ${type}.`)
40
-
41
- executeWithTimeout(app?.stop(), 1000)
42
- .catch()
43
- .finally(() => {
44
- process.exit(1)
45
- })
46
- }
47
-
48
31
  function patchLogging () {
49
32
  disablePinoDirectWrite()
50
33
 
@@ -113,16 +96,16 @@ async function main () {
113
96
  events: new ForwardingEventEmitter()
114
97
  })
115
98
 
116
- const config = workerData.config
99
+ const runtimeConfig = workerData.config
117
100
 
118
- await performPreloading(config, workerData.applicationConfig)
101
+ await performPreloading(runtimeConfig, workerData.applicationConfig)
119
102
 
120
- const application = workerData.applicationConfig
103
+ const applicationConfig = workerData.applicationConfig
121
104
 
122
105
  // Load env file and mixin env vars from application config
123
106
  let envfile
124
- if (application.envfile) {
125
- envfile = resolve(workerData.dirname, application.envfile)
107
+ if (applicationConfig.envfile) {
108
+ envfile = resolve(workerData.dirname, applicationConfig.envfile)
126
109
  } else {
127
110
  envfile = resolve(workerData.applicationConfig.path, '.env')
128
111
  }
@@ -133,20 +116,20 @@ async function main () {
133
116
  path: envfile
134
117
  })
135
118
 
136
- if (config.env) {
137
- Object.assign(process.env, config.env)
119
+ if (runtimeConfig.env) {
120
+ Object.assign(process.env, runtimeConfig.env)
138
121
  }
139
- if (application.env) {
140
- Object.assign(process.env, application.env)
122
+ if (applicationConfig.env) {
123
+ Object.assign(process.env, applicationConfig.env)
141
124
  }
142
125
 
143
- const { threadDispatcher } = await setDispatcher(config)
126
+ const { threadDispatcher } = await setDispatcher(runtimeConfig)
144
127
 
145
128
  // If the application is an entrypoint and runtime server config is defined, use it.
146
129
  let serverConfig = null
147
- if (config.server && application.entrypoint) {
148
- serverConfig = config.server
149
- } else if (application.useHttp) {
130
+ if (runtimeConfig.server && applicationConfig.entrypoint) {
131
+ serverConfig = runtimeConfig.server
132
+ } else if (applicationConfig.useHttp) {
150
133
  serverConfig = {
151
134
  port: 0,
152
135
  hostname: '127.0.0.1',
@@ -169,48 +152,32 @@ async function main () {
169
152
  const res = await fetch(url)
170
153
  const [{ devtoolsFrontendUrl }] = await res.json()
171
154
 
172
- console.log(`For ${application.id} debugger open the following in chrome: "${devtoolsFrontendUrl}"`)
155
+ console.log(`For ${applicationConfig.id} debugger open the following in chrome: "${devtoolsFrontendUrl}"`)
173
156
  }
174
157
 
175
158
  // Create the application
176
159
  // Add idLabel to metrics config to determine which label name to use (defaults to applicationId)
177
- const metricsConfig = config.metrics
160
+ const metricsConfig = runtimeConfig.metrics
178
161
  ? {
179
- ...config.metrics,
180
- idLabel: config.metrics.applicationLabel || 'applicationId'
162
+ ...runtimeConfig.metrics,
163
+ idLabel: runtimeConfig.metrics.applicationLabel || 'applicationId'
181
164
  }
182
- : config.metrics
165
+ : runtimeConfig.metrics
183
166
 
184
167
  const controller = new Controller(
185
- application,
168
+ runtimeConfig,
169
+ applicationConfig,
186
170
  workerData.worker.count > 1 ? workerData.worker.index : undefined,
187
- application.telemetry,
188
- config.logger,
189
171
  serverConfig,
190
- metricsConfig,
191
- !!config.managementApi,
192
- !!config.watch
172
+ metricsConfig
193
173
  )
194
174
 
195
- if (config.exitOnUnhandledErrors) {
196
- process.on('uncaughtException', handleUnhandled.bind(null, controller, 'uncaught exception'))
197
- process.on('unhandledRejection', handleUnhandled.bind(null, controller, 'unhandled rejection'))
198
-
199
- process.on('newListener', event => {
200
- if (event === 'uncaughtException' || event === 'unhandledRejection') {
201
- globalThis.platformatic.logger.warn(
202
- `A listener has been added for the "process.${event}" event. This listener will be never triggered as Watt default behavior will kill the process before.\n To disable this behavior, set "exitOnUnhandledErrors" to false in the runtime config.`
203
- )
204
- }
205
- })
206
- }
207
-
208
175
  await controller.init()
209
176
 
210
- if (application.entrypoint && config.basePath) {
177
+ if (applicationConfig.entrypoint && runtimeConfig.basePath) {
211
178
  const meta = await controller.capability.getMeta()
212
179
  if (!meta.gateway.wantsAbsoluteUrls) {
213
- stripBasePath(config.basePath)
180
+ stripBasePath(runtimeConfig.basePath)
214
181
  }
215
182
  }
216
183
 
@@ -222,7 +189,7 @@ async function main () {
222
189
  }
223
190
 
224
191
  // Setup interaction with parent port
225
- const itc = setupITC(controller, application, threadDispatcher, sharedContext)
192
+ const itc = setupITC(controller, applicationConfig, threadDispatcher, sharedContext)
226
193
  globalThis[kITC] = itc
227
194
  globalThis.platformatic.itc = itc
228
195
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/runtime",
3
- "version": "3.7.1",
3
+ "version": "3.9.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "@fastify/express": "^4.0.0",
20
20
  "@fastify/formbody": "^8.0.0",
21
21
  "autocannon": "^8.0.0",
22
+ "atomic-sleep": "^1.0.0",
22
23
  "c8": "^10.0.0",
23
24
  "cleaner-spec-reporter": "^0.5.0",
24
25
  "eslint": "9",
@@ -34,14 +35,14 @@
34
35
  "typescript": "^5.5.4",
35
36
  "undici-oidc-interceptor": "^0.5.0",
36
37
  "why-is-node-running": "^2.2.2",
37
- "@platformatic/composer": "3.7.1",
38
- "@platformatic/db": "3.7.1",
39
- "@platformatic/gateway": "3.7.1",
40
- "@platformatic/node": "3.7.1",
41
- "@platformatic/service": "3.7.1",
42
- "@platformatic/sql-graphql": "3.7.1",
43
- "@platformatic/sql-mapper": "3.7.1",
44
- "@platformatic/wattpm-pprof-capture": "3.7.1"
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"
45
46
  },
46
47
  "dependencies": {
47
48
  "@fastify/accepts": "^5.0.0",
@@ -71,12 +72,12 @@
71
72
  "undici": "^7.0.0",
72
73
  "undici-thread-interceptor": "^0.14.0",
73
74
  "ws": "^8.16.0",
74
- "@platformatic/foundation": "3.7.1",
75
- "@platformatic/basic": "3.7.1",
76
- "@platformatic/itc": "3.7.1",
77
- "@platformatic/generators": "3.7.1",
78
- "@platformatic/metrics": "3.7.1",
79
- "@platformatic/telemetry": "3.7.1"
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"
80
81
  },
81
82
  "engines": {
82
83
  "node": ">=22.19.0"
package/schema.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.7.1.json",
2
+ "$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.9.0.json",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "title": "Platformatic Runtime Config",
5
5
  "type": "object",
@@ -923,8 +923,7 @@
923
923
  {
924
924
  "type": "string"
925
925
  }
926
- ],
927
- "default": 1
926
+ ]
928
927
  },
929
928
  "workersRestartDelay": {
930
929
  "anyOf": [
@@ -1828,6 +1827,72 @@
1828
1827
  ],
1829
1828
  "additionalProperties": false
1830
1829
  },
1830
+ "verticalScaler": {
1831
+ "type": "object",
1832
+ "properties": {
1833
+ "enabled": {
1834
+ "type": "boolean",
1835
+ "default": true
1836
+ },
1837
+ "maxTotalWorkers": {
1838
+ "type": "number",
1839
+ "minimum": 1
1840
+ },
1841
+ "minWorkers": {
1842
+ "type": "number",
1843
+ "minimum": 1
1844
+ },
1845
+ "maxWorkers": {
1846
+ "type": "number",
1847
+ "minimum": 1
1848
+ },
1849
+ "scaleUpELU": {
1850
+ "type": "number",
1851
+ "minimum": 0,
1852
+ "maximum": 1
1853
+ },
1854
+ "scaleDownELU": {
1855
+ "type": "number",
1856
+ "minimum": 0,
1857
+ "maximum": 1
1858
+ },
1859
+ "minELUDiff": {
1860
+ "type": "number",
1861
+ "minimum": 0,
1862
+ "maximum": 1
1863
+ },
1864
+ "timeWindowSec": {
1865
+ "type": "number",
1866
+ "minimum": 0
1867
+ },
1868
+ "cooldownSec": {
1869
+ "type": "number",
1870
+ "minimum": 0
1871
+ },
1872
+ "scaleIntervalSec": {
1873
+ "type": "number",
1874
+ "minimum": 0
1875
+ },
1876
+ "applications": {
1877
+ "type": "object",
1878
+ "additionalProperties": {
1879
+ "type": "object",
1880
+ "properties": {
1881
+ "minWorkers": {
1882
+ "type": "number",
1883
+ "minimum": 1
1884
+ },
1885
+ "maxWorkers": {
1886
+ "type": "number",
1887
+ "minimum": 1
1888
+ }
1889
+ },
1890
+ "additionalProperties": false
1891
+ }
1892
+ }
1893
+ },
1894
+ "additionalProperties": false
1895
+ },
1831
1896
  "inspectorOptions": {
1832
1897
  "type": "object",
1833
1898
  "properties": {