@platformatic/runtime 3.13.1 → 3.15.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 +73 -4
- package/index.d.ts +4 -0
- package/index.js +8 -3
- package/lib/config.js +118 -72
- package/lib/dynamic-workers-scaler.js +218 -0
- package/lib/errors.js +17 -0
- package/lib/logger.js +144 -11
- package/lib/runtime.js +401 -423
- package/lib/scaling-algorithm.js +26 -31
- package/lib/worker/controller.js +14 -4
- package/lib/worker/health-signals.js +80 -0
- package/lib/worker/main.js +6 -0
- package/lib/worker/symbols.js +2 -0
- package/package.json +15 -15
- package/schema.json +200 -22
package/config.d.ts
CHANGED
|
@@ -20,7 +20,16 @@ export type PlatformaticRuntimeConfig = {
|
|
|
20
20
|
id: string;
|
|
21
21
|
config?: string;
|
|
22
22
|
useHttp?: boolean;
|
|
23
|
-
|
|
23
|
+
reuseTcpPorts?: boolean;
|
|
24
|
+
workers?:
|
|
25
|
+
| number
|
|
26
|
+
| string
|
|
27
|
+
| {
|
|
28
|
+
static?: number;
|
|
29
|
+
minimum?: number;
|
|
30
|
+
maximum?: number;
|
|
31
|
+
[k: string]: unknown;
|
|
32
|
+
};
|
|
24
33
|
health?: {
|
|
25
34
|
enabled?: boolean | string;
|
|
26
35
|
interval?: number | string;
|
|
@@ -76,7 +85,20 @@ export type PlatformaticRuntimeConfig = {
|
|
|
76
85
|
web?: {
|
|
77
86
|
[k: string]: unknown;
|
|
78
87
|
}[];
|
|
79
|
-
workers?:
|
|
88
|
+
workers?:
|
|
89
|
+
| number
|
|
90
|
+
| string
|
|
91
|
+
| {
|
|
92
|
+
static?: number;
|
|
93
|
+
dynamic?: boolean;
|
|
94
|
+
minimum?: number;
|
|
95
|
+
maximum?: number;
|
|
96
|
+
total?: number;
|
|
97
|
+
maxMemory?: number;
|
|
98
|
+
cooldown?: number;
|
|
99
|
+
gracePeriod?: number;
|
|
100
|
+
[k: string]: unknown;
|
|
101
|
+
};
|
|
80
102
|
workersRestartDelay?: number | string;
|
|
81
103
|
logger?: {
|
|
82
104
|
level: (
|
|
@@ -161,6 +183,7 @@ export type PlatformaticRuntimeConfig = {
|
|
|
161
183
|
rejectUnauthorized?: boolean;
|
|
162
184
|
};
|
|
163
185
|
};
|
|
186
|
+
reuseTcpPorts?: boolean;
|
|
164
187
|
startTimeout?: number;
|
|
165
188
|
restartOnError?: boolean | number;
|
|
166
189
|
exitOnUnhandledErrors?: boolean;
|
|
@@ -285,6 +308,37 @@ export type PlatformaticRuntimeConfig = {
|
|
|
285
308
|
};
|
|
286
309
|
plugins?: string[];
|
|
287
310
|
timeout?: number | string;
|
|
311
|
+
/**
|
|
312
|
+
* Configuration for exporting metrics to an OTLP endpoint
|
|
313
|
+
*/
|
|
314
|
+
otlpExporter?: {
|
|
315
|
+
/**
|
|
316
|
+
* Enable or disable OTLP metrics export
|
|
317
|
+
*/
|
|
318
|
+
enabled?: boolean | string;
|
|
319
|
+
/**
|
|
320
|
+
* OTLP endpoint URL (e.g., http://collector:4318/v1/metrics)
|
|
321
|
+
*/
|
|
322
|
+
endpoint: string;
|
|
323
|
+
/**
|
|
324
|
+
* Interval in milliseconds between metric pushes
|
|
325
|
+
*/
|
|
326
|
+
interval?: number | string;
|
|
327
|
+
/**
|
|
328
|
+
* Additional HTTP headers for authentication
|
|
329
|
+
*/
|
|
330
|
+
headers?: {
|
|
331
|
+
[k: string]: string;
|
|
332
|
+
};
|
|
333
|
+
/**
|
|
334
|
+
* Service name for OTLP resource attributes
|
|
335
|
+
*/
|
|
336
|
+
serviceName?: string;
|
|
337
|
+
/**
|
|
338
|
+
* Service version for OTLP resource attributes
|
|
339
|
+
*/
|
|
340
|
+
serviceVersion?: string;
|
|
341
|
+
};
|
|
288
342
|
};
|
|
289
343
|
telemetry?: {
|
|
290
344
|
enabled?: boolean | string;
|
|
@@ -368,13 +422,28 @@ export type PlatformaticRuntimeConfig = {
|
|
|
368
422
|
maxTotalMemory?: number;
|
|
369
423
|
minWorkers?: number;
|
|
370
424
|
maxWorkers?: number;
|
|
425
|
+
cooldownSec?: number;
|
|
426
|
+
gracePeriod?: number;
|
|
427
|
+
/**
|
|
428
|
+
* @deprecated
|
|
429
|
+
*/
|
|
371
430
|
scaleUpELU?: number;
|
|
431
|
+
/**
|
|
432
|
+
* @deprecated
|
|
433
|
+
*/
|
|
372
434
|
scaleDownELU?: number;
|
|
435
|
+
/**
|
|
436
|
+
* @deprecated
|
|
437
|
+
*/
|
|
373
438
|
timeWindowSec?: number;
|
|
439
|
+
/**
|
|
440
|
+
* @deprecated
|
|
441
|
+
*/
|
|
374
442
|
scaleDownTimeWindowSec?: number;
|
|
375
|
-
|
|
443
|
+
/**
|
|
444
|
+
* @deprecated
|
|
445
|
+
*/
|
|
376
446
|
scaleIntervalSec?: number;
|
|
377
|
-
gracePeriod?: number;
|
|
378
447
|
applications?: {
|
|
379
448
|
[k: string]: {
|
|
380
449
|
minWorkers?: number;
|
package/index.d.ts
CHANGED
|
@@ -55,9 +55,11 @@ export module symbols {
|
|
|
55
55
|
export declare const kWorkerId: unique symbol
|
|
56
56
|
export declare const kITC: unique symbol
|
|
57
57
|
export declare const kHealthCheckTimer: unique symbol
|
|
58
|
+
export declare const kHealthMetricsTimer: unique symbol
|
|
58
59
|
export declare const kLastHealthCheckELU: unique symbol
|
|
59
60
|
export declare const kLastVerticalScalerELU: unique symbol
|
|
60
61
|
export declare const kWorkerStatus: unique symbol
|
|
62
|
+
export declare const kWorkerHealthSignals: unique symbol
|
|
61
63
|
export declare const kStderrMarker: string
|
|
62
64
|
export declare const kInterceptors: unique symbol
|
|
63
65
|
export declare const kWorkersBroadcast: unique symbol
|
|
@@ -90,6 +92,8 @@ export function create (
|
|
|
90
92
|
context?: ConfigurationOptions
|
|
91
93
|
): Promise<Runtime>
|
|
92
94
|
|
|
95
|
+
export declare function prepareApplication (config: RuntimeConfiguration, application: object): object
|
|
96
|
+
|
|
93
97
|
export declare function transform (config: RuntimeConfiguration): Promise<RuntimeConfiguration>
|
|
94
98
|
|
|
95
99
|
export declare function loadApplicationsCommands (): Promise<ApplicationsCommands>
|
package/index.js
CHANGED
|
@@ -127,6 +127,7 @@ export async function loadApplicationsCommands () {
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
export async function create (configOrRoot, sourceOrConfig, context) {
|
|
130
|
+
const setupSignals = context?.setupSignals ?? true
|
|
130
131
|
const config = await loadConfiguration(configOrRoot, sourceOrConfig, context)
|
|
131
132
|
|
|
132
133
|
if (inspector.url() && !config[kMetadata].env.VSCODE_INSPECTOR_OPTIONS) {
|
|
@@ -134,7 +135,9 @@ export async function create (configOrRoot, sourceOrConfig, context) {
|
|
|
134
135
|
}
|
|
135
136
|
|
|
136
137
|
let runtime = new Runtime(config, context)
|
|
137
|
-
|
|
138
|
+
if (setupSignals) {
|
|
139
|
+
handleSignal(runtime, config)
|
|
140
|
+
}
|
|
138
141
|
|
|
139
142
|
// Handle port handling
|
|
140
143
|
if (context?.start) {
|
|
@@ -160,7 +163,9 @@ export async function create (configOrRoot, sourceOrConfig, context) {
|
|
|
160
163
|
|
|
161
164
|
config.server.port = ++port
|
|
162
165
|
runtime = new Runtime(config, context)
|
|
163
|
-
|
|
166
|
+
if (setupSignals) {
|
|
167
|
+
handleSignal(runtime, config)
|
|
168
|
+
}
|
|
164
169
|
}
|
|
165
170
|
}
|
|
166
171
|
}
|
|
@@ -168,7 +173,7 @@ export async function create (configOrRoot, sourceOrConfig, context) {
|
|
|
168
173
|
return runtime
|
|
169
174
|
}
|
|
170
175
|
|
|
171
|
-
export { transform, wrapInRuntimeConfig } from './lib/config.js'
|
|
176
|
+
export { prepareApplication, transform, wrapInRuntimeConfig } from './lib/config.js'
|
|
172
177
|
export * as errors from './lib/errors.js'
|
|
173
178
|
export { RuntimeGenerator as Generator, WrappedGenerator } from './lib/generator.js'
|
|
174
179
|
export { Runtime } from './lib/runtime.js'
|
package/lib/config.js
CHANGED
|
@@ -45,6 +45,42 @@ 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
|
+
function parseWorkers (config, prefix, defaultWorkers = 1) {
|
|
49
|
+
if (typeof config.workers !== 'undefined') {
|
|
50
|
+
// Number
|
|
51
|
+
if (typeof config.workers !== 'object') {
|
|
52
|
+
const coerced = coercePositiveInteger(config.workers)
|
|
53
|
+
|
|
54
|
+
if (coerced === null) {
|
|
55
|
+
const raw = config.workers
|
|
56
|
+
const hint = typeof raw === 'string' && /\{.*\}/.test(raw) ? 'check your environment variable' : ''
|
|
57
|
+
raiseInvalidWorkersError(prefix, config.workers, hint)
|
|
58
|
+
} else {
|
|
59
|
+
config.workers = { static: coerced, dynamic: false }
|
|
60
|
+
}
|
|
61
|
+
// Object
|
|
62
|
+
} else {
|
|
63
|
+
for (const key of ['minimum', 'maximum', 'static']) {
|
|
64
|
+
if (typeof config.workers[key] === 'undefined') {
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const coerced = coercePositiveInteger(config.workers[key])
|
|
69
|
+
if (coerced === null) {
|
|
70
|
+
const raw = config.workers
|
|
71
|
+
const hint = typeof raw === 'string' && /\{.*\}/.test(raw) ? 'check your environment variable' : ''
|
|
72
|
+
raiseInvalidWorkersError(`${prefix} ${key}`, config.workers, hint)
|
|
73
|
+
} else {
|
|
74
|
+
config.workers[key] = coerced
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// No value, inherit from runtime
|
|
79
|
+
} else {
|
|
80
|
+
config.workers = { static: defaultWorkers }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
48
84
|
export function pprofCapturePreloadPath () {
|
|
49
85
|
const require = createRequire(import.meta.url)
|
|
50
86
|
|
|
@@ -162,6 +198,64 @@ export function parseInspectorOptions (config, inspect, inspectBreak) {
|
|
|
162
198
|
config.watch = false
|
|
163
199
|
}
|
|
164
200
|
|
|
201
|
+
export async function prepareApplication (config, application, defaultWorkers) {
|
|
202
|
+
// We need to have absolute paths here, ot the `loadConfig` will fail
|
|
203
|
+
// Make sure we don't resolve if env var was not replaced
|
|
204
|
+
if (application.path && !isAbsolute(application.path) && !application.path.match(/^\{.*\}$/)) {
|
|
205
|
+
application.path = resolvePath(config[kMetadata].root, application.path)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (application.path && application.config) {
|
|
209
|
+
application.config = resolvePath(application.path, application.config)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
let pkg
|
|
214
|
+
|
|
215
|
+
if (application.config) {
|
|
216
|
+
const config = await loadConfiguration(application.config)
|
|
217
|
+
pkg = await loadConfigurationModule(application.path, config)
|
|
218
|
+
|
|
219
|
+
application.type = extractModuleFromSchemaUrl(config, true).module
|
|
220
|
+
application.skipTelemetryHooks = pkg.skipTelemetryHooks
|
|
221
|
+
} else {
|
|
222
|
+
const { moduleName, capability } = await importCapabilityAndConfig(application.path)
|
|
223
|
+
pkg = capability
|
|
224
|
+
|
|
225
|
+
application.type = moduleName
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
application.skipTelemetryHooks = pkg.skipTelemetryHooks
|
|
229
|
+
|
|
230
|
+
// This is needed to work around Rust bug on dylibs:
|
|
231
|
+
// https://github.com/rust-lang/rust/issues/91979
|
|
232
|
+
// https://github.com/rollup/rollup/issues/5761
|
|
233
|
+
const _require = createRequire(application.path)
|
|
234
|
+
for (const m of pkg.modulesToLoad ?? []) {
|
|
235
|
+
const toLoad = _require.resolve(m)
|
|
236
|
+
loadModule(_require, toLoad).catch(() => {})
|
|
237
|
+
}
|
|
238
|
+
} catch (err) {
|
|
239
|
+
// This should not happen, it happens on running some unit tests if we prepare the runtime
|
|
240
|
+
// when not all the applications configs are available. Given that we are running this only
|
|
241
|
+
// to ddetermine the type of the application, it's safe to ignore this error and default to unknown
|
|
242
|
+
application.type = 'unknown'
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Validate and coerce per-service workers
|
|
246
|
+
parseWorkers(application, `Service "${application.id}"`, defaultWorkers)
|
|
247
|
+
|
|
248
|
+
application.entrypoint = application.id === config.entrypoint
|
|
249
|
+
application.dependencies ??= []
|
|
250
|
+
application.localUrl = `http://${application.id}.plt.local`
|
|
251
|
+
|
|
252
|
+
if (typeof application.watch === 'undefined') {
|
|
253
|
+
application.watch = config.watch
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return application
|
|
257
|
+
}
|
|
258
|
+
|
|
165
259
|
export async function transform (config, _, context) {
|
|
166
260
|
const production = context?.isProduction ?? context?.production
|
|
167
261
|
const applications = [...(config.applications ?? []), ...(config.services ?? []), ...(config.web ?? [])]
|
|
@@ -173,6 +267,27 @@ export async function transform (config, _, context) {
|
|
|
173
267
|
config.watch = !production
|
|
174
268
|
}
|
|
175
269
|
|
|
270
|
+
// Migrate the old verticalScaler property, only applied if the new settings are not set, otherwise workers takes precedence
|
|
271
|
+
// TODO: Remove in the next major version
|
|
272
|
+
if (config.verticalScaler) {
|
|
273
|
+
config.workers ??= {}
|
|
274
|
+
config.workers.dynamic ??= config.verticalScaler.enabled
|
|
275
|
+
config.workers.minimum ??= config.verticalScaler.minWorkers
|
|
276
|
+
config.workers.maximum ??= config.verticalScaler.maxWorkers
|
|
277
|
+
config.workers.total ??= config.verticalScaler.maxTotalWorkers
|
|
278
|
+
config.workers.maxMemory ??= config.verticalScaler.maxTotalMemory
|
|
279
|
+
|
|
280
|
+
if (typeof config.workers.cooldown === 'undefined' && typeof config.verticalScaler.cooldownSec === 'number') {
|
|
281
|
+
config.workers.cooldown = config.verticalScaler.cooldownSec * 1000
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (typeof config.workers.gracePeriod === 'undefined' && typeof config.verticalScaler.gracePeriod === 'number') {
|
|
285
|
+
config.workers.gracePeriod = config.verticalScaler.gracePeriod
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
config.verticalScaler = undefined
|
|
289
|
+
}
|
|
290
|
+
|
|
176
291
|
if (config.autoload) {
|
|
177
292
|
const { exclude = [], mappings = {} } = config.autoload
|
|
178
293
|
let { path } = config.autoload
|
|
@@ -215,80 +330,11 @@ export async function transform (config, _, context) {
|
|
|
215
330
|
let hasValidEntrypoint = false
|
|
216
331
|
|
|
217
332
|
// Root-level workers
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if (coerced === null) {
|
|
221
|
-
const raw = config.workers
|
|
222
|
-
const hint = typeof raw === 'string' && /\{.*\}/.test(raw) ? 'check your environment variable' : ''
|
|
223
|
-
raiseInvalidWorkersError('Runtime', config.workers, hint)
|
|
224
|
-
}
|
|
225
|
-
config.workers = coerced
|
|
226
|
-
}
|
|
333
|
+
parseWorkers(config, 'Runtime')
|
|
334
|
+
const defaultWorkers = config.workers.static
|
|
227
335
|
|
|
228
336
|
for (let i = 0; i < applications.length; ++i) {
|
|
229
|
-
const application = applications[i]
|
|
230
|
-
|
|
231
|
-
// We need to have absolute paths here, ot the `loadConfig` will fail
|
|
232
|
-
// Make sure we don't resolve if env var was not replaced
|
|
233
|
-
if (application.path && !isAbsolute(application.path) && !application.path.match(/^\{.*\}$/)) {
|
|
234
|
-
application.path = resolvePath(config[kMetadata].root, application.path)
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
if (application.path && application.config) {
|
|
238
|
-
application.config = resolvePath(application.path, application.config)
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
try {
|
|
242
|
-
let pkg
|
|
243
|
-
|
|
244
|
-
if (application.config) {
|
|
245
|
-
const config = await loadConfiguration(application.config)
|
|
246
|
-
pkg = await loadConfigurationModule(application.path, config)
|
|
247
|
-
|
|
248
|
-
application.type = extractModuleFromSchemaUrl(config, true).module
|
|
249
|
-
application.skipTelemetryHooks = pkg.skipTelemetryHooks
|
|
250
|
-
} else {
|
|
251
|
-
const { moduleName, capability } = await importCapabilityAndConfig(application.path)
|
|
252
|
-
pkg = capability
|
|
253
|
-
|
|
254
|
-
application.type = moduleName
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
application.skipTelemetryHooks = pkg.skipTelemetryHooks
|
|
258
|
-
|
|
259
|
-
// This is needed to work around Rust bug on dylibs:
|
|
260
|
-
// https://github.com/rust-lang/rust/issues/91979
|
|
261
|
-
// https://github.com/rollup/rollup/issues/5761
|
|
262
|
-
const _require = createRequire(application.path)
|
|
263
|
-
for (const m of pkg.modulesToLoad ?? []) {
|
|
264
|
-
const toLoad = _require.resolve(m)
|
|
265
|
-
loadModule(_require, toLoad).catch(() => {})
|
|
266
|
-
}
|
|
267
|
-
} catch (err) {
|
|
268
|
-
// This should not happen, it happens on running some unit tests if we prepare the runtime
|
|
269
|
-
// when not all the applications configs are available. Given that we are running this only
|
|
270
|
-
// to ddetermine the type of the application, it's safe to ignore this error and default to unknown
|
|
271
|
-
application.type = 'unknown'
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// Validate and coerce per-service workers
|
|
275
|
-
if (typeof application.workers !== 'undefined') {
|
|
276
|
-
const coerced = coercePositiveInteger(application.workers)
|
|
277
|
-
if (coerced === null) {
|
|
278
|
-
const raw = config.application?.[i]?.workers
|
|
279
|
-
const hint = typeof raw === 'string' && /\{.*\}/.test(raw) ? 'check your environment variable' : ''
|
|
280
|
-
raiseInvalidWorkersError(`Service "${application.id}"`, application.workers, hint)
|
|
281
|
-
}
|
|
282
|
-
application.workers = coerced
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
application.entrypoint = application.id === config.entrypoint
|
|
286
|
-
application.dependencies ??= []
|
|
287
|
-
application.localUrl = `http://${application.id}.plt.local`
|
|
288
|
-
|
|
289
|
-
if (typeof application.watch === 'undefined') {
|
|
290
|
-
application.watch = config.watch
|
|
291
|
-
}
|
|
337
|
+
const application = await prepareApplication(config, applications[i], defaultWorkers)
|
|
292
338
|
|
|
293
339
|
if (application.entrypoint) {
|
|
294
340
|
hasValidEntrypoint = true
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { features } from '@platformatic/foundation'
|
|
2
|
+
import { availableParallelism } from 'node:os'
|
|
3
|
+
import { getMemoryInfo } from './metrics.js'
|
|
4
|
+
import { ScalingAlgorithm, scaleUpELUThreshold } from './scaling-algorithm.js'
|
|
5
|
+
import { kApplicationId, kId, kLastVerticalScalerELU, kWorkerStartTime, kWorkerStatus } from './worker/symbols.js'
|
|
6
|
+
|
|
7
|
+
const healthCheckInterval = 1000
|
|
8
|
+
export const kOriginalWorkers = Symbol('plt.runtime.application.dynamicWorkersScalerOriginalWorkers')
|
|
9
|
+
|
|
10
|
+
const defaultCooldown = 60_000
|
|
11
|
+
const defaultGracePeriod = 30_000
|
|
12
|
+
const scaleIntervalPeriod = 60_000
|
|
13
|
+
|
|
14
|
+
export class DynamicWorkersScaler {
|
|
15
|
+
#status
|
|
16
|
+
#runtime
|
|
17
|
+
#algorithm
|
|
18
|
+
|
|
19
|
+
#maxTotalMemory
|
|
20
|
+
#maxTotalWorkers
|
|
21
|
+
#maxWorkers
|
|
22
|
+
#minWorkers
|
|
23
|
+
#cooldown
|
|
24
|
+
#gracePeriod
|
|
25
|
+
|
|
26
|
+
#initialUpdates
|
|
27
|
+
#memoryInfo
|
|
28
|
+
#healthCheckTimeout
|
|
29
|
+
#checkScalingInterval
|
|
30
|
+
#isScaling
|
|
31
|
+
#lastScaling
|
|
32
|
+
|
|
33
|
+
constructor (runtime, config) {
|
|
34
|
+
this.#runtime = runtime
|
|
35
|
+
|
|
36
|
+
this.#maxTotalMemory = config.maxMemory // This is defaulted in start()
|
|
37
|
+
this.#maxTotalWorkers = config.total ?? availableParallelism()
|
|
38
|
+
this.#maxWorkers = config.maximum ?? this.#maxTotalWorkers
|
|
39
|
+
this.#minWorkers = config.minimum ?? 1
|
|
40
|
+
this.#cooldown = config.cooldown ?? defaultCooldown
|
|
41
|
+
this.#gracePeriod = config.gracePeriod ?? defaultGracePeriod
|
|
42
|
+
|
|
43
|
+
this.#algorithm = new ScalingAlgorithm({ maxTotalWorkers: this.#maxTotalWorkers })
|
|
44
|
+
|
|
45
|
+
this.#isScaling = false
|
|
46
|
+
this.#lastScaling = 0
|
|
47
|
+
this.#initialUpdates = []
|
|
48
|
+
this.#status = 'init'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
getConfig () {
|
|
52
|
+
return {
|
|
53
|
+
maxTotalMemory: this.#maxTotalMemory,
|
|
54
|
+
maxTotalWorkers: this.#maxTotalWorkers,
|
|
55
|
+
maxWorkers: this.#maxWorkers,
|
|
56
|
+
minWorkers: this.#minWorkers,
|
|
57
|
+
cooldown: this.#cooldown,
|
|
58
|
+
gracePeriod: this.#gracePeriod
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async start () {
|
|
63
|
+
this.#memoryInfo = await getMemoryInfo()
|
|
64
|
+
this.#maxTotalMemory ??= this.#memoryInfo.total * 0.9
|
|
65
|
+
|
|
66
|
+
this.#checkScalingInterval = setInterval(this.#checkScaling.bind(this), scaleIntervalPeriod)
|
|
67
|
+
this.#healthCheckTimeout = setTimeout(this.#chechHealth.bind(this), healthCheckInterval)
|
|
68
|
+
|
|
69
|
+
if (this.#initialUpdates.length > 0) {
|
|
70
|
+
await this.#runtime.updateApplicationsResources(this.#initialUpdates)
|
|
71
|
+
this.#initialUpdates = []
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
this.#status = 'started'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
stop () {
|
|
78
|
+
clearTimeout(this.#healthCheckTimeout)
|
|
79
|
+
clearInterval(this.#checkScalingInterval)
|
|
80
|
+
this.#status = 'stopped'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async add (application) {
|
|
84
|
+
const config = {}
|
|
85
|
+
|
|
86
|
+
if (application.entrypoint && !features.node.reusePort) {
|
|
87
|
+
this.#runtime.logger.warn(
|
|
88
|
+
`The "${application.id}" application cannot be scaled because it is an entrypoint and the "reusePort" feature is not available in your OS.`
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
config.minWorkers = 1
|
|
92
|
+
config.maxWorkers = 1
|
|
93
|
+
} else if (application.workers.dynamic === false) {
|
|
94
|
+
this.#runtime.logger.warn(
|
|
95
|
+
`The "${application.id}" application cannot be scaled because it has a fixed number of workers (${application.workers.static}).`
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
config.minWorkers = application.workers.static
|
|
99
|
+
config.maxWorkers = application.workers.static
|
|
100
|
+
} else {
|
|
101
|
+
config.minWorkers = application.workers.minimum
|
|
102
|
+
config.maxWorkers = application.workers.maximum
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
config.minWorkers ??= this.#minWorkers
|
|
106
|
+
config.maxWorkers ??= this.#maxWorkers
|
|
107
|
+
|
|
108
|
+
if (config.minWorkers > 1) {
|
|
109
|
+
const update = { application: application.id, workers: config.minWorkers }
|
|
110
|
+
|
|
111
|
+
if (!this.#status === 'started') {
|
|
112
|
+
await this.#runtime.updateApplicationsResources([update])
|
|
113
|
+
} else {
|
|
114
|
+
this.#initialUpdates.push(update)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
this.#algorithm.addApplication(application.id, config)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
remove (application) {
|
|
122
|
+
this.#algorithm.removeApplication(application.id)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async #chechHealth () {
|
|
126
|
+
let shouldCheckForScaling = false
|
|
127
|
+
|
|
128
|
+
const now = Date.now()
|
|
129
|
+
|
|
130
|
+
const workers = await this.#runtime.getWorkers(true)
|
|
131
|
+
|
|
132
|
+
for (const { raw: worker } of Object.values(workers)) {
|
|
133
|
+
if (worker[kWorkerStatus] !== 'started' || worker[kWorkerStartTime] + this.#gracePeriod > now) {
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const health = await this.#runtime.getWorkerHealth(worker, { previousELU: worker[kLastVerticalScalerELU] })
|
|
139
|
+
|
|
140
|
+
if (!health) {
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
worker[kLastVerticalScalerELU] = health.currentELU
|
|
145
|
+
|
|
146
|
+
this.#algorithm.addWorkerHealthInfo({
|
|
147
|
+
workerId: worker[kId],
|
|
148
|
+
applicationId: worker[kApplicationId],
|
|
149
|
+
elu: health.elu,
|
|
150
|
+
heapUsed: health.heapUsed,
|
|
151
|
+
heapTotal: health.heapTotal
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
if (health.elu > scaleUpELUThreshold) {
|
|
155
|
+
shouldCheckForScaling = true
|
|
156
|
+
}
|
|
157
|
+
} catch (err) {
|
|
158
|
+
this.logger.error({ err }, 'Failed to get health for worker')
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (shouldCheckForScaling) {
|
|
163
|
+
await this.#checkScaling()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
this.#healthCheckTimeout.refresh()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async #checkScaling () {
|
|
170
|
+
const isInCooldown = Date.now() < this.#lastScaling + this.#cooldown
|
|
171
|
+
if (this.#isScaling || isInCooldown) {
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
this.#isScaling = true
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const workersInfo = await this.#runtime.getWorkers()
|
|
179
|
+
const mem = await getMemoryInfo({ scope: this.#memoryInfo.scope })
|
|
180
|
+
|
|
181
|
+
const appsWorkersInfo = {}
|
|
182
|
+
for (const worker of Object.values(workersInfo)) {
|
|
183
|
+
if (worker.status === 'exited') {
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const applicationId = worker.application
|
|
188
|
+
appsWorkersInfo[applicationId] ??= 0
|
|
189
|
+
appsWorkersInfo[applicationId]++
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const availableMemory = this.#maxTotalMemory - mem.used
|
|
193
|
+
const recommendations = this.#algorithm.getRecommendations(appsWorkersInfo, { availableMemory })
|
|
194
|
+
|
|
195
|
+
if (recommendations.length > 0) {
|
|
196
|
+
await this.#applyRecommendations(recommendations)
|
|
197
|
+
this.#lastScaling = Date.now()
|
|
198
|
+
}
|
|
199
|
+
} catch (err) {
|
|
200
|
+
this.#runtime.logger.error({ err }, 'Failed to scale applications')
|
|
201
|
+
} finally {
|
|
202
|
+
this.#isScaling = false
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async #applyRecommendations (recommendations) {
|
|
207
|
+
const resourcesUpdates = []
|
|
208
|
+
|
|
209
|
+
for (const recommendation of recommendations) {
|
|
210
|
+
const { applicationId, workersCount, direction } = recommendation
|
|
211
|
+
this.#runtime.logger.info(`Scaling ${direction} the "${applicationId}" app to ${workersCount} workers`)
|
|
212
|
+
|
|
213
|
+
resourcesUpdates.push({ application: applicationId, workers: workersCount })
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return this.#runtime.updateApplicationsResources(resourcesUpdates)
|
|
217
|
+
}
|
|
218
|
+
}
|
package/lib/errors.js
CHANGED
|
@@ -127,7 +127,24 @@ export const MissingPprofCapture = createError(
|
|
|
127
127
|
`${ERROR_PREFIX}_MISSING_PPROF_CAPTURE`,
|
|
128
128
|
'Please install @platformatic/wattpm-pprof-capture'
|
|
129
129
|
)
|
|
130
|
+
|
|
130
131
|
export const GetHeapStatisticUnavailable = createError(
|
|
131
132
|
`${ERROR_PREFIX}_GET_HEAP_STATISTIC_UNAVAILABLE`,
|
|
132
133
|
'The getHeapStatistics method is not available in your Node version'
|
|
133
134
|
)
|
|
135
|
+
export const FailedToSendHealthSignalsError = createError(
|
|
136
|
+
`${ERROR_PREFIX}_FAILED_TO_SEND_HEALTH_SIGNALS`,
|
|
137
|
+
'Cannot send health signals from application "%s": %s'
|
|
138
|
+
)
|
|
139
|
+
export const HealthSignalMustBeObjectError = createError(
|
|
140
|
+
`${ERROR_PREFIX}_HEALTH_SIGNAL_MUST_BE_OBJECT`,
|
|
141
|
+
'Health signal must be an object'
|
|
142
|
+
)
|
|
143
|
+
export const HealthSignalTypeMustBeStringError = createError(
|
|
144
|
+
`${ERROR_PREFIX}_HEALTH_SIGNAL_TYPE_MUST_BE_STRING`,
|
|
145
|
+
'Health signal type must be a string, received "%s"'
|
|
146
|
+
)
|
|
147
|
+
export const CannotRemoveEntrypointError = createError(
|
|
148
|
+
`${ERROR_PREFIX}_CANNOT_REMOVE_ENTRYPOINT`,
|
|
149
|
+
'Cannot remove the entrypoint application.'
|
|
150
|
+
)
|