@platformatic/runtime 2.70.0 → 2.70.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config.d.ts +2 -1
- package/lib/errors.js +93 -23
- package/lib/runtime.js +113 -27
- package/lib/worker/itc.js +21 -4
- package/lib/worker/messaging.js +186 -0
- package/lib/worker/round-robin-map.js +1 -1
- package/lib/worker/symbols.js +5 -1
- package/package.json +20 -20
- package/schema.json +13 -1
package/config.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* and run json-schema-to-typescript to regenerate this file.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
export type
|
|
8
|
+
export type HttpsSchemasPlatformaticDevPlatformaticRuntime2701Json = {
|
|
9
9
|
[k: string]: unknown;
|
|
10
10
|
} & {
|
|
11
11
|
$schema?: string;
|
|
@@ -331,6 +331,7 @@ export type HttpsSchemasPlatformaticDevPlatformaticRuntime2700Json = {
|
|
|
331
331
|
[k: string]: unknown;
|
|
332
332
|
};
|
|
333
333
|
serviceTimeout?: number | string;
|
|
334
|
+
messagingTimeout?: number | string;
|
|
334
335
|
resolvedServicesBasePath?: string;
|
|
335
336
|
env?: {
|
|
336
337
|
[k: string]: string;
|
package/lib/errors.js
CHANGED
|
@@ -9,38 +9,108 @@ module.exports = {
|
|
|
9
9
|
RuntimeExitedError: createError(`${ERROR_PREFIX}_RUNTIME_EXIT`, 'The runtime exited before the operation completed'),
|
|
10
10
|
RuntimeAbortedError: createError(`${ERROR_PREFIX}_RUNTIME_ABORT`, 'The runtime aborted the operation'),
|
|
11
11
|
// The following two use the same code as we only need to differentiate the label
|
|
12
|
-
ServiceExitedError: createError(
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
ServiceExitedError: createError(
|
|
13
|
+
`${ERROR_PREFIX}_SERVICE_EXIT`,
|
|
14
|
+
'The service "%s" exited prematurely with error code %d'
|
|
15
|
+
),
|
|
16
|
+
WorkerExitedError: createError(
|
|
17
|
+
`${ERROR_PREFIX}_SERVICE_EXIT`,
|
|
18
|
+
'The worker %s of the service "%s" exited prematurely with error code %d'
|
|
19
|
+
),
|
|
20
|
+
UnknownRuntimeAPICommandError: createError(
|
|
21
|
+
`${ERROR_PREFIX}_UNKNOWN_RUNTIME_API_COMMAND`,
|
|
22
|
+
'Unknown Runtime API command "%s"'
|
|
23
|
+
),
|
|
24
|
+
ServiceNotFoundError: createError(
|
|
25
|
+
`${ERROR_PREFIX}_SERVICE_NOT_FOUND`,
|
|
26
|
+
'Service %s not found. Available services are: %s'
|
|
27
|
+
),
|
|
28
|
+
WorkerNotFoundError: createError(
|
|
29
|
+
`${ERROR_PREFIX}_WORKER_NOT_FOUND`,
|
|
30
|
+
'Worker %s of service %s not found. Available services are: %s'
|
|
31
|
+
),
|
|
17
32
|
ServiceNotStartedError: createError(`${ERROR_PREFIX}_SERVICE_NOT_STARTED`, "Service with id '%s' is not started"),
|
|
18
|
-
ServiceStartTimeoutError: createError(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
ServiceStartTimeoutError: createError(
|
|
34
|
+
`${ERROR_PREFIX}_SERVICE_START_TIMEOUT`,
|
|
35
|
+
"Service with id '%s' failed to start in %dms."
|
|
36
|
+
),
|
|
37
|
+
FailedToRetrieveOpenAPISchemaError: createError(
|
|
38
|
+
`${ERROR_PREFIX}_FAILED_TO_RETRIEVE_OPENAPI_SCHEMA`,
|
|
39
|
+
'Failed to retrieve OpenAPI schema for service with id "%s": %s'
|
|
40
|
+
),
|
|
41
|
+
FailedToRetrieveGraphQLSchemaError: createError(
|
|
42
|
+
`${ERROR_PREFIX}_FAILED_TO_RETRIEVE_GRAPHQL_SCHEMA`,
|
|
43
|
+
'Failed to retrieve GraphQL schema for service with id "%s": %s'
|
|
44
|
+
),
|
|
45
|
+
FailedToRetrieveMetaError: createError(
|
|
46
|
+
`${ERROR_PREFIX}_FAILED_TO_RETRIEVE_META`,
|
|
47
|
+
'Failed to retrieve metadata for service with id "%s": %s'
|
|
48
|
+
),
|
|
49
|
+
FailedToRetrieveMetricsError: createError(
|
|
50
|
+
`${ERROR_PREFIX}_FAILED_TO_RETRIEVE_METRICS`,
|
|
51
|
+
'Failed to retrieve metrics for service with id "%s": %s'
|
|
52
|
+
),
|
|
53
|
+
FailedToRetrieveHealthError: createError(
|
|
54
|
+
`${ERROR_PREFIX}_FAILED_TO_RETRIEVE_HEALTH`,
|
|
55
|
+
'Failed to retrieve health for service with id "%s": %s'
|
|
56
|
+
),
|
|
57
|
+
FailedToPerformCustomHealthCheckError: createError(
|
|
58
|
+
`${ERROR_PREFIX}_FAILED_TO_PERFORM_CUSTOM_HEALTH_CHECK`,
|
|
59
|
+
'Failed to perform custom healthcheck for service with id "%s": %s'
|
|
60
|
+
),
|
|
61
|
+
FailedToPerformCustomReadinessCheckError: createError(
|
|
62
|
+
`${ERROR_PREFIX}_FAILED_TO_PERFORM_CUSTOM_READINESS_CHECK`,
|
|
63
|
+
'Failed to perform custom readiness check for service with id "%s": %s'
|
|
64
|
+
),
|
|
65
|
+
ApplicationAlreadyStartedError: createError(
|
|
66
|
+
`${ERROR_PREFIX}_APPLICATION_ALREADY_STARTED`,
|
|
67
|
+
'Application is already started'
|
|
68
|
+
),
|
|
69
|
+
ApplicationNotStartedError: createError(
|
|
70
|
+
`${ERROR_PREFIX}_APPLICATION_NOT_STARTED`,
|
|
71
|
+
'Application has not been started'
|
|
72
|
+
),
|
|
73
|
+
ConfigPathMustBeStringError: createError(
|
|
74
|
+
`${ERROR_PREFIX}_CONFIG_PATH_MUST_BE_STRING`,
|
|
75
|
+
'Config path must be a string'
|
|
76
|
+
),
|
|
29
77
|
NoConfigFileFoundError: createError(`${ERROR_PREFIX}_NO_CONFIG_FILE_FOUND`, "No config file found for service '%s'"),
|
|
30
78
|
InvalidEntrypointError: createError(`${ERROR_PREFIX}_INVALID_ENTRYPOINT`, "Invalid entrypoint: '%s' does not exist"),
|
|
31
79
|
MissingEntrypointError: createError(`${ERROR_PREFIX}_MISSING_ENTRYPOINT`, 'Missing application entrypoint.'),
|
|
32
|
-
InvalidServicesWithWebError: createError(
|
|
80
|
+
InvalidServicesWithWebError: createError(
|
|
81
|
+
`${ERROR_PREFIX}_INVALID_SERVICES_WITH_WEB`,
|
|
82
|
+
'The "services" property cannot be used when the "web" property is also defined'
|
|
83
|
+
),
|
|
33
84
|
MissingDependencyError: createError(`${ERROR_PREFIX}_MISSING_DEPENDENCY`, 'Missing dependency: "%s"'),
|
|
34
|
-
InspectAndInspectBrkError: createError(
|
|
35
|
-
|
|
85
|
+
InspectAndInspectBrkError: createError(
|
|
86
|
+
`${ERROR_PREFIX}_INSPECT_AND_INSPECT_BRK`,
|
|
87
|
+
'--inspect and --inspect-brk cannot be used together'
|
|
88
|
+
),
|
|
89
|
+
InspectorPortError: createError(
|
|
90
|
+
`${ERROR_PREFIX}_INSPECTOR_PORT`,
|
|
91
|
+
'Inspector port must be 0 or in range 1024 to 65535'
|
|
92
|
+
),
|
|
36
93
|
InspectorHostError: createError(`${ERROR_PREFIX}_INSPECTOR_HOST`, 'Inspector host cannot be empty'),
|
|
37
|
-
CannotMapSpecifierToAbsolutePathError: createError(
|
|
38
|
-
|
|
39
|
-
|
|
94
|
+
CannotMapSpecifierToAbsolutePathError: createError(
|
|
95
|
+
`${ERROR_PREFIX}_CANNOT_MAP_SPECIFIER_TO_ABSOLUTE_PATH`,
|
|
96
|
+
'Cannot map "%s" to an absolute path'
|
|
97
|
+
),
|
|
98
|
+
NodeInspectorFlagsNotSupportedError: createError(
|
|
99
|
+
`${ERROR_PREFIX}_NODE_INSPECTOR_FLAGS_NOT_SUPPORTED`,
|
|
100
|
+
"The Node.js inspector flags are not supported. Please use 'platformatic start --inspect' instead."
|
|
101
|
+
),
|
|
102
|
+
FailedToUnlinkManagementApiSocket: createError(
|
|
103
|
+
`${ERROR_PREFIX}_FAILED_TO_UNLINK_MANAGEMENT_API_SOCKET`,
|
|
104
|
+
'Failed to unlink management API socket "%s"'
|
|
105
|
+
),
|
|
40
106
|
LogFileNotFound: createError(`${ERROR_PREFIX}_LOG_FILE_NOT_FOUND`, 'Log file with index %s not found', 404),
|
|
41
107
|
WorkerIsRequired: createError(`${ERROR_PREFIX}_REQUIRED_WORKER`, 'The worker parameter is required'),
|
|
42
108
|
InvalidArgumentError: createError(`${ERROR_PREFIX}_INVALID_ARGUMENT`, 'Invalid argument: "%s"'),
|
|
109
|
+
MessagingError: createError(`${ERROR_PREFIX}_MESSAGING_ERROR`, 'Cannot send a message to service "%s": %s'),
|
|
43
110
|
|
|
44
111
|
// TODO: should remove next one as it's not used anymore
|
|
45
|
-
CannotRemoveServiceOnUpdateError: createError(
|
|
112
|
+
CannotRemoveServiceOnUpdateError: createError(
|
|
113
|
+
`${ERROR_PREFIX}_CANNOT_REMOVE_SERVICE_ON_UPDATE`,
|
|
114
|
+
'Cannot remove service "%s" when updating a Runtime'
|
|
115
|
+
)
|
|
46
116
|
}
|
package/lib/runtime.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const { ITC } = require('@platformatic/itc')
|
|
4
|
-
const { features, ensureLoggableError, executeWithTimeout, deepmerge, parseMemorySize } = require('@platformatic/utils')
|
|
4
|
+
const { features, ensureLoggableError, executeWithTimeout, deepmerge, parseMemorySize, kTimeout } = require('@platformatic/utils')
|
|
5
5
|
const { once, EventEmitter } = require('node:events')
|
|
6
6
|
const { createReadStream, watch, existsSync } = require('node:fs')
|
|
7
7
|
const { readdir, readFile, stat, access } = require('node:fs/promises')
|
|
@@ -34,7 +34,8 @@ const {
|
|
|
34
34
|
kConfig,
|
|
35
35
|
kWorkerStatus,
|
|
36
36
|
kStderrMarker,
|
|
37
|
-
kLastELU
|
|
37
|
+
kLastELU,
|
|
38
|
+
kWorkersBroadcast
|
|
38
39
|
} = require('./worker/symbols')
|
|
39
40
|
|
|
40
41
|
const fastify = require('fastify')
|
|
@@ -75,6 +76,8 @@ class Runtime extends EventEmitter {
|
|
|
75
76
|
#prometheusServer
|
|
76
77
|
#inspectorServer
|
|
77
78
|
#workers
|
|
79
|
+
#workersBroadcastChannel
|
|
80
|
+
#workerITCHandlers
|
|
78
81
|
#restartingWorkers
|
|
79
82
|
#sharedHttpCache
|
|
80
83
|
servicesConfigsPatches
|
|
@@ -107,6 +110,18 @@ class Runtime extends EventEmitter {
|
|
|
107
110
|
stderr: new SonicBoom({ fd: process.stderr.fd })
|
|
108
111
|
}
|
|
109
112
|
}
|
|
113
|
+
|
|
114
|
+
this.#workerITCHandlers = {
|
|
115
|
+
getServiceMeta: this.getServiceMeta.bind(this),
|
|
116
|
+
listServices: () => this.#servicesIds,
|
|
117
|
+
getServices: this.getServices.bind(this),
|
|
118
|
+
getWorkers: this.getWorkers.bind(this),
|
|
119
|
+
getWorkerMessagingChannel: this.#getWorkerMessagingChannel.bind(this),
|
|
120
|
+
getHttpCacheValue: this.#getHttpCacheValue.bind(this),
|
|
121
|
+
setHttpCacheValue: this.#setHttpCacheValue.bind(this),
|
|
122
|
+
deleteHttpCacheValue: this.#deleteHttpCacheValue.bind(this),
|
|
123
|
+
invalidateHttpCache: this.invalidateHttpCache.bind(this)
|
|
124
|
+
}
|
|
110
125
|
}
|
|
111
126
|
|
|
112
127
|
async init () {
|
|
@@ -131,12 +146,15 @@ class Runtime extends EventEmitter {
|
|
|
131
146
|
|
|
132
147
|
this.#isProduction = this.#configManager.args?.production ?? false
|
|
133
148
|
this.#servicesIds = config.services.map(service => service.id)
|
|
149
|
+
this.#createWorkersBroadcastChannel()
|
|
134
150
|
|
|
135
151
|
const workersConfig = []
|
|
136
152
|
for (const service of config.services) {
|
|
137
153
|
const count = service.workers ?? this.#configManager.current.workers
|
|
138
154
|
if (count > 1 && service.entrypoint && !features.node.reusePort) {
|
|
139
|
-
this.logger.warn(
|
|
155
|
+
this.logger.warn(
|
|
156
|
+
`"${service.id}" is set as the entrypoint, but reusePort is not available in your OS; setting workers to 1 instead of ${count}`
|
|
157
|
+
)
|
|
140
158
|
workersConfig.push({ id: service.id, workers: 1 })
|
|
141
159
|
} else {
|
|
142
160
|
workersConfig.push({ id: service.id, workers: count })
|
|
@@ -226,6 +244,7 @@ class Runtime extends EventEmitter {
|
|
|
226
244
|
throw new errors.MissingEntrypointError()
|
|
227
245
|
}
|
|
228
246
|
this.#updateStatus('starting')
|
|
247
|
+
this.#createWorkersBroadcastChannel()
|
|
229
248
|
|
|
230
249
|
// Important: do not use Promise.all here since it won't properly manage dependencies
|
|
231
250
|
try {
|
|
@@ -312,6 +331,7 @@ class Runtime extends EventEmitter {
|
|
|
312
331
|
}
|
|
313
332
|
|
|
314
333
|
await this.#meshInterceptor.close()
|
|
334
|
+
this.#workersBroadcastChannel?.close()
|
|
315
335
|
|
|
316
336
|
this.#updateStatus('stopped')
|
|
317
337
|
}
|
|
@@ -1211,6 +1231,8 @@ class Runtime extends EventEmitter {
|
|
|
1211
1231
|
setImmediate(() => {
|
|
1212
1232
|
if (started && (!config.watch || code !== 0)) {
|
|
1213
1233
|
this.emit('service:worker:error', { ...eventPayload, code })
|
|
1234
|
+
this.#broadcastWorkers()
|
|
1235
|
+
|
|
1214
1236
|
this.logger.warn(`The ${errorLabel} unexpectedly exited with code ${code}.`)
|
|
1215
1237
|
}
|
|
1216
1238
|
|
|
@@ -1254,14 +1276,7 @@ class Runtime extends EventEmitter {
|
|
|
1254
1276
|
name: workerId + '-runtime',
|
|
1255
1277
|
port: worker,
|
|
1256
1278
|
handlers: {
|
|
1257
|
-
|
|
1258
|
-
listServices: () => this.#servicesIds,
|
|
1259
|
-
getServices: this.getServices.bind(this),
|
|
1260
|
-
getWorkers: this.getWorkers.bind(this),
|
|
1261
|
-
getHttpCacheValue: this.#getHttpCacheValue.bind(this),
|
|
1262
|
-
setHttpCacheValue: this.#setHttpCacheValue.bind(this),
|
|
1263
|
-
deleteHttpCacheValue: this.#deleteHttpCacheValue.bind(this),
|
|
1264
|
-
invalidateHttpCache: this.invalidateHttpCache.bind(this),
|
|
1279
|
+
...this.#workerITCHandlers,
|
|
1265
1280
|
setEventsForwarding (value) {
|
|
1266
1281
|
worker[kForwardEvents] = value
|
|
1267
1282
|
}
|
|
@@ -1286,7 +1301,7 @@ class Runtime extends EventEmitter {
|
|
|
1286
1301
|
await this.startService(serviceId)
|
|
1287
1302
|
}
|
|
1288
1303
|
|
|
1289
|
-
this.logger?.info(`
|
|
1304
|
+
this.logger?.info(`The service "${serviceId}" has been successfully reloaded ...`)
|
|
1290
1305
|
|
|
1291
1306
|
if (serviceConfig.entrypoint) {
|
|
1292
1307
|
this.#showUrl()
|
|
@@ -1331,16 +1346,18 @@ class Runtime extends EventEmitter {
|
|
|
1331
1346
|
if (features.node.worker.getHeapStatistics) {
|
|
1332
1347
|
const { used_heap_size: heapUsed, total_heap_size: heapTotal } = await worker.getHeapStatistics()
|
|
1333
1348
|
const currentELU = worker.performance.eventLoopUtilization()
|
|
1334
|
-
const elu = worker
|
|
1349
|
+
const elu = worker[kLastELU]
|
|
1350
|
+
? worker.performance.eventLoopUtilization(currentELU, worker[kLastELU])
|
|
1351
|
+
: currentELU
|
|
1335
1352
|
worker[kLastELU] = currentELU
|
|
1336
|
-
return { elu, heapUsed, heapTotal }
|
|
1353
|
+
return { elu: elu.utilization, heapUsed, heapTotal }
|
|
1337
1354
|
}
|
|
1338
1355
|
|
|
1339
1356
|
const health = await worker[kITC].send('getHealth')
|
|
1340
1357
|
return health
|
|
1341
1358
|
}
|
|
1342
1359
|
|
|
1343
|
-
#setupHealthCheck (config, serviceConfig, workersCount, id, index, worker, errorLabel) {
|
|
1360
|
+
#setupHealthCheck (config, serviceConfig, workersCount, id, index, worker, errorLabel, timeout) {
|
|
1344
1361
|
// Clear the timeout when exiting
|
|
1345
1362
|
worker.on('exit', () => clearTimeout(worker[kHealthCheckTimer]))
|
|
1346
1363
|
|
|
@@ -1350,6 +1367,10 @@ class Runtime extends EventEmitter {
|
|
|
1350
1367
|
let unhealthyChecks = 0
|
|
1351
1368
|
|
|
1352
1369
|
worker[kHealthCheckTimer] = setTimeout(async () => {
|
|
1370
|
+
if (worker[kWorkerStatus] !== 'started') {
|
|
1371
|
+
return
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1353
1374
|
let health, unhealthy, memoryUsage
|
|
1354
1375
|
try {
|
|
1355
1376
|
health = await this.#getHealth(worker)
|
|
@@ -1358,7 +1379,8 @@ class Runtime extends EventEmitter {
|
|
|
1358
1379
|
} catch (err) {
|
|
1359
1380
|
this.logger.error({ err }, `Failed to get health for ${errorLabel}.`)
|
|
1360
1381
|
unhealthy = true
|
|
1361
|
-
memoryUsage =
|
|
1382
|
+
memoryUsage = -1
|
|
1383
|
+
health = { elu: -1, heapUsed: -1, heapTotal: -1 }
|
|
1362
1384
|
}
|
|
1363
1385
|
|
|
1364
1386
|
const serviceId = worker[kServiceId]
|
|
@@ -1408,7 +1430,7 @@ class Runtime extends EventEmitter {
|
|
|
1408
1430
|
} else {
|
|
1409
1431
|
worker[kHealthCheckTimer].refresh()
|
|
1410
1432
|
}
|
|
1411
|
-
}, interval)
|
|
1433
|
+
}, timeout ?? interval)
|
|
1412
1434
|
}
|
|
1413
1435
|
|
|
1414
1436
|
async #startWorker (
|
|
@@ -1448,7 +1470,7 @@ class Runtime extends EventEmitter {
|
|
|
1448
1470
|
if (config.startTimeout > 0) {
|
|
1449
1471
|
workerUrl = await executeWithTimeout(sendViaITC(worker, 'start'), config.startTimeout)
|
|
1450
1472
|
|
|
1451
|
-
if (workerUrl ===
|
|
1473
|
+
if (workerUrl === kTimeout) {
|
|
1452
1474
|
this.emit('service:worker:startTimeout', eventPayload)
|
|
1453
1475
|
this.logger.info(`The ${label} failed to start in ${config.startTimeout}ms. Forcefully killing the thread.`)
|
|
1454
1476
|
worker.terminate()
|
|
@@ -1466,6 +1488,7 @@ class Runtime extends EventEmitter {
|
|
|
1466
1488
|
|
|
1467
1489
|
worker[kWorkerStatus] = 'started'
|
|
1468
1490
|
this.emit('service:worker:started', eventPayload)
|
|
1491
|
+
this.#broadcastWorkers()
|
|
1469
1492
|
|
|
1470
1493
|
if (!silent) {
|
|
1471
1494
|
this.logger?.info(`Started the ${label}...`)
|
|
@@ -1473,12 +1496,9 @@ class Runtime extends EventEmitter {
|
|
|
1473
1496
|
|
|
1474
1497
|
const { enabled, gracePeriod } = worker[kConfig].health
|
|
1475
1498
|
if (enabled && config.restartOnError > 0) {
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
},
|
|
1480
|
-
gracePeriod > 0 ? gracePeriod : 1
|
|
1481
|
-
)
|
|
1499
|
+
// if gracePeriod is 0, it will be set to 1 to start health checks immediately
|
|
1500
|
+
// however, the health event will start when the worker is started
|
|
1501
|
+
this.#setupHealthCheck(config, serviceConfig, workersCount, id, index, worker, label, gracePeriod > 0 ? gracePeriod : 1)
|
|
1482
1502
|
}
|
|
1483
1503
|
} catch (error) {
|
|
1484
1504
|
// TODO: handle port allocation error here
|
|
@@ -1569,7 +1589,7 @@ class Runtime extends EventEmitter {
|
|
|
1569
1589
|
const res = await executeWithTimeout(exitPromise, exitTimeout)
|
|
1570
1590
|
|
|
1571
1591
|
// If the worker didn't exit in time, kill it
|
|
1572
|
-
if (res ===
|
|
1592
|
+
if (res === kTimeout) {
|
|
1573
1593
|
this.emit('service:worker:exit:timeout', eventPayload)
|
|
1574
1594
|
await worker.terminate()
|
|
1575
1595
|
}
|
|
@@ -1578,6 +1598,7 @@ class Runtime extends EventEmitter {
|
|
|
1578
1598
|
|
|
1579
1599
|
worker[kWorkerStatus] = 'stopped'
|
|
1580
1600
|
this.emit('service:worker:stopped', eventPayload)
|
|
1601
|
+
this.#broadcastWorkers()
|
|
1581
1602
|
}
|
|
1582
1603
|
|
|
1583
1604
|
#cleanupWorker (worker) {
|
|
@@ -1627,6 +1648,9 @@ class Runtime extends EventEmitter {
|
|
|
1627
1648
|
await this.#setupWorker(config, serviceConfig, workersCount, id, index)
|
|
1628
1649
|
await this.#startWorker(config, serviceConfig, workersCount, id, index, silent, bootstrapAttempt)
|
|
1629
1650
|
|
|
1651
|
+
this.logger.info(
|
|
1652
|
+
`The ${this.#workerExtendedLabel(id, index, workersCount)} has been successfully restarted ...`
|
|
1653
|
+
)
|
|
1630
1654
|
resolve()
|
|
1631
1655
|
} catch (err) {
|
|
1632
1656
|
// The runtime was stopped while the restart was happening, ignore any error.
|
|
@@ -1710,7 +1734,15 @@ class Runtime extends EventEmitter {
|
|
|
1710
1734
|
return null
|
|
1711
1735
|
}
|
|
1712
1736
|
|
|
1713
|
-
|
|
1737
|
+
if (this.#servicesIds.includes(serviceId)) {
|
|
1738
|
+
const availableWorkers = Array.from(this.#workers.keys())
|
|
1739
|
+
.filter(key => key.startsWith(serviceId + ':'))
|
|
1740
|
+
.map(key => key.split(':')[1])
|
|
1741
|
+
.join(', ')
|
|
1742
|
+
throw new errors.WorkerNotFoundError(workerId, serviceId, availableWorkers)
|
|
1743
|
+
} else {
|
|
1744
|
+
throw new errors.ServiceNotFoundError(serviceId, Array.from(this.#servicesIds).join(', '))
|
|
1745
|
+
}
|
|
1714
1746
|
}
|
|
1715
1747
|
|
|
1716
1748
|
if (ensureStarted) {
|
|
@@ -1724,6 +1756,59 @@ class Runtime extends EventEmitter {
|
|
|
1724
1756
|
return worker
|
|
1725
1757
|
}
|
|
1726
1758
|
|
|
1759
|
+
async #createWorkersBroadcastChannel () {
|
|
1760
|
+
this.#workersBroadcastChannel?.close()
|
|
1761
|
+
this.#workersBroadcastChannel = new BroadcastChannel(kWorkersBroadcast)
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
async #broadcastWorkers () {
|
|
1765
|
+
const workers = new Map()
|
|
1766
|
+
|
|
1767
|
+
// Create the list of workers
|
|
1768
|
+
for (const worker of this.#workers.values()) {
|
|
1769
|
+
if (worker[kWorkerStatus] !== 'started') {
|
|
1770
|
+
continue
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
const service = worker[kServiceId]
|
|
1774
|
+
let serviceWorkers = workers.get(service)
|
|
1775
|
+
|
|
1776
|
+
if (!serviceWorkers) {
|
|
1777
|
+
serviceWorkers = []
|
|
1778
|
+
workers.set(service, serviceWorkers)
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
serviceWorkers.push({
|
|
1782
|
+
id: worker[kId],
|
|
1783
|
+
service: worker[kServiceId],
|
|
1784
|
+
worker: worker[kWorkerId],
|
|
1785
|
+
thread: worker.threadId
|
|
1786
|
+
})
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
this.#workersBroadcastChannel.postMessage(workers)
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
async #getWorkerMessagingChannel ({ service, worker }, context) {
|
|
1793
|
+
const target = await this.#getWorkerById(service, worker, true, true)
|
|
1794
|
+
|
|
1795
|
+
const { port1, port2 } = new MessageChannel()
|
|
1796
|
+
|
|
1797
|
+
// Send the first port to the target
|
|
1798
|
+
const response = await executeWithTimeout(
|
|
1799
|
+
sendViaITC(target, 'saveMessagingChannel', port1, [port1]),
|
|
1800
|
+
this.#configManager.current.messagingTimeout
|
|
1801
|
+
)
|
|
1802
|
+
|
|
1803
|
+
if (response === kTimeout) {
|
|
1804
|
+
throw new errors.MessagingError(service, 'Timeout while establishing a communication channel.')
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
context.transferList = [port2]
|
|
1808
|
+
this.emit('service:worker:messagingChannel', { service, worker })
|
|
1809
|
+
return port2
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1727
1812
|
async #getRuntimePackageJson () {
|
|
1728
1813
|
const runtimeDir = this.#configManager.dirname
|
|
1729
1814
|
const packageJsonPath = join(runtimeDir, 'package.json')
|
|
@@ -1829,7 +1914,8 @@ class Runtime extends EventEmitter {
|
|
|
1829
1914
|
}
|
|
1830
1915
|
}
|
|
1831
1916
|
|
|
1832
|
-
const pinoLog =
|
|
1917
|
+
const pinoLog =
|
|
1918
|
+
typeof message?.level === 'number' && typeof message?.time === 'number' && typeof message?.msg === 'string'
|
|
1833
1919
|
|
|
1834
1920
|
// Directly write to the Pino destination
|
|
1835
1921
|
if (pinoLog) {
|
package/lib/worker/itc.js
CHANGED
|
@@ -9,6 +9,7 @@ const { Unpromise } = require('@watchable/unpromise')
|
|
|
9
9
|
const errors = require('../errors')
|
|
10
10
|
const { updateUndiciInterceptors } = require('./interceptors')
|
|
11
11
|
const { kITC, kId, kServiceId, kWorkerId } = require('./symbols')
|
|
12
|
+
const { MessagingITC } = require('./messaging')
|
|
12
13
|
|
|
13
14
|
async function safeHandleInITC (worker, fn) {
|
|
14
15
|
try {
|
|
@@ -47,8 +48,8 @@ async function safeHandleInITC (worker, fn) {
|
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
async function sendViaITC (worker, name, message) {
|
|
51
|
-
return safeHandleInITC(worker, () => worker[kITC].send(name, message))
|
|
51
|
+
async function sendViaITC (worker, name, message, transferList) {
|
|
52
|
+
return safeHandleInITC(worker, () => worker[kITC].send(name, message, { transferList }))
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
async function waitEventFromITC (worker, event) {
|
|
@@ -56,6 +57,15 @@ async function waitEventFromITC (worker, event) {
|
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
function setupITC (app, service, dispatcher) {
|
|
60
|
+
const messaging = new MessagingITC(app.appConfig.id, workerData.config)
|
|
61
|
+
|
|
62
|
+
Object.assign(globalThis.platformatic ?? {}, {
|
|
63
|
+
messaging: {
|
|
64
|
+
handle: messaging.handle.bind(messaging),
|
|
65
|
+
send: messaging.send.bind(messaging)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
59
69
|
const itc = new ITC({
|
|
60
70
|
name: app.appConfig.id + '-worker',
|
|
61
71
|
port: parentPort,
|
|
@@ -96,6 +106,7 @@ function setupITC (app, service, dispatcher) {
|
|
|
96
106
|
|
|
97
107
|
await dispatcher.interceptor.close()
|
|
98
108
|
itc.close()
|
|
109
|
+
messaging.close()
|
|
99
110
|
},
|
|
100
111
|
|
|
101
112
|
async build () {
|
|
@@ -116,8 +127,10 @@ function setupITC (app, service, dispatcher) {
|
|
|
116
127
|
|
|
117
128
|
async updateWorkersCount (data) {
|
|
118
129
|
const { serviceId, workers } = data
|
|
119
|
-
const
|
|
120
|
-
if (
|
|
130
|
+
const worker = workerData.config.serviceMap.get(serviceId)
|
|
131
|
+
if (worker) {
|
|
132
|
+
worker.workers = workers
|
|
133
|
+
}
|
|
121
134
|
workerData.serviceConfig.workers = workers
|
|
122
135
|
workerData.worker.count = workers
|
|
123
136
|
},
|
|
@@ -195,6 +208,10 @@ function setupITC (app, service, dispatcher) {
|
|
|
195
208
|
} catch (err) {
|
|
196
209
|
throw new errors.FailedToPerformCustomReadinessCheckError(service.id, err.message)
|
|
197
210
|
}
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
saveMessagingChannel (channel) {
|
|
214
|
+
messaging.addSource(channel)
|
|
198
215
|
}
|
|
199
216
|
}
|
|
200
217
|
})
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { withResolvers, executeWithTimeout, kTimeout } = require('@platformatic/utils')
|
|
4
|
+
const { ITC, generateResponse, sanitize } = require('@platformatic/itc')
|
|
5
|
+
const errors = require('../errors')
|
|
6
|
+
const { RoundRobinMap } = require('./round-robin-map')
|
|
7
|
+
const { kWorkersBroadcast, kITC } = require('./symbols')
|
|
8
|
+
|
|
9
|
+
const kPendingResponses = Symbol('plt.messaging.pendingResponses')
|
|
10
|
+
|
|
11
|
+
class MessagingITC extends ITC {
|
|
12
|
+
#timeout
|
|
13
|
+
#listener
|
|
14
|
+
#closeResolvers
|
|
15
|
+
#broadcastChannel
|
|
16
|
+
#workers
|
|
17
|
+
#sources
|
|
18
|
+
|
|
19
|
+
constructor (id, runtimeConfig) {
|
|
20
|
+
super({
|
|
21
|
+
throwOnMissingHandler: true,
|
|
22
|
+
name: `${id}-messaging`
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
this.#timeout = runtimeConfig.messagingTimeout
|
|
26
|
+
this.#workers = new RoundRobinMap()
|
|
27
|
+
this.#sources = new Set()
|
|
28
|
+
|
|
29
|
+
// Start listening on the BroadcastChannel for the list of services
|
|
30
|
+
this.#broadcastChannel = new BroadcastChannel(kWorkersBroadcast)
|
|
31
|
+
this.#broadcastChannel.onmessage = this.#updateWorkers.bind(this)
|
|
32
|
+
|
|
33
|
+
this.listen()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_setupListener (listener) {
|
|
37
|
+
this.#listener = listener
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
handle (message, handler) {
|
|
41
|
+
if (typeof message === 'object') {
|
|
42
|
+
for (const [name, fn] of Object.entries(message)) {
|
|
43
|
+
super.handle(name, fn)
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
super.handle(message, handler)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async send (service, name, message, options) {
|
|
51
|
+
// Get the next worker for the service
|
|
52
|
+
const worker = this.#workers.next(service)
|
|
53
|
+
|
|
54
|
+
if (!worker) {
|
|
55
|
+
throw new errors.MessagingError(service, 'No workers available')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!worker.channel) {
|
|
59
|
+
// Use twice the value here as a fallback measure. The target handler in the main thread is forwarding
|
|
60
|
+
// the request to the worker, using executeWithTimeout with the user set timeout value.
|
|
61
|
+
const channel = await executeWithTimeout(
|
|
62
|
+
globalThis[kITC].send('getWorkerMessagingChannel', { service: worker.service, worker: worker.worker }),
|
|
63
|
+
this.#timeout * 2
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
/* c8 ignore next 3 - Hard to test */
|
|
67
|
+
if (channel === kTimeout) {
|
|
68
|
+
throw new errors.MessagingError(service, 'Timeout while waiting for a communication channel.')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
worker.channel = channel
|
|
72
|
+
this.#setupChannel(channel)
|
|
73
|
+
|
|
74
|
+
channel[kPendingResponses] = new Map()
|
|
75
|
+
channel.on('close', this.#handlePendingResponse.bind(this, channel))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const context = { ...options }
|
|
79
|
+
context.channel = worker.channel
|
|
80
|
+
context.service = worker.service
|
|
81
|
+
context.trackResponse = true
|
|
82
|
+
|
|
83
|
+
const response = await executeWithTimeout(super.send(name, message, context), this.#timeout)
|
|
84
|
+
|
|
85
|
+
if (response === kTimeout) {
|
|
86
|
+
throw new errors.MessagingError(service, 'Timeout while waiting for a response.')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return response
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async addSource (channel) {
|
|
93
|
+
this.#sources.add(channel)
|
|
94
|
+
this.#setupChannel(channel)
|
|
95
|
+
|
|
96
|
+
// This has been closed on the other side.
|
|
97
|
+
// Pending messages will be silently discarded by Node (as postMessage does not throw) so we don't need to handle them.
|
|
98
|
+
channel.on('close', () => {
|
|
99
|
+
this.#sources.delete(channel)
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
_send (request, context) {
|
|
104
|
+
const { channel, transferList } = context
|
|
105
|
+
|
|
106
|
+
if (context.trackResponse) {
|
|
107
|
+
const service = context.service
|
|
108
|
+
channel[kPendingResponses].set(request.reqId, { service, request })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
channel.postMessage(sanitize(request, transferList), { transferList })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
_createClosePromise () {
|
|
115
|
+
const { promise, resolve, reject } = withResolvers()
|
|
116
|
+
this.#closeResolvers = { resolve, reject }
|
|
117
|
+
return promise
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
_close () {
|
|
121
|
+
this.#closeResolvers.resolve()
|
|
122
|
+
this.#broadcastChannel.close()
|
|
123
|
+
|
|
124
|
+
for (const source of this.#sources) {
|
|
125
|
+
source.close()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const worker of this.#workers.values()) {
|
|
129
|
+
worker.channel?.close()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
this.#sources.clear()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
#setupChannel (channel) {
|
|
136
|
+
// Setup the message for processing
|
|
137
|
+
channel.on('message', event => {
|
|
138
|
+
this.#listener(event, { channel })
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#updateWorkers (event) {
|
|
143
|
+
// Gather all existing channels by thread, it will make them reusable
|
|
144
|
+
const existingChannels = new Map()
|
|
145
|
+
for (const source of this.#workers.values()) {
|
|
146
|
+
existingChannels.set(source.thread, source.channel)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Create a brand new map
|
|
150
|
+
this.#workers = new RoundRobinMap()
|
|
151
|
+
|
|
152
|
+
const instances = []
|
|
153
|
+
for (const [service, workers] of event.data) {
|
|
154
|
+
const count = workers.length
|
|
155
|
+
const next = Math.floor(Math.random() * count)
|
|
156
|
+
|
|
157
|
+
instances.push({ id: service, next, workers: count })
|
|
158
|
+
|
|
159
|
+
for (let i = 0; i < count; i++) {
|
|
160
|
+
const worker = workers[i]
|
|
161
|
+
const channel = existingChannels.get(worker.thread)
|
|
162
|
+
|
|
163
|
+
// Note i is not the worker index as in runtime, but the index in the list of current alive workers for the service
|
|
164
|
+
this.#workers.set(`${service}:${i}`, { ...worker, channel })
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
this.#workers.configure(instances)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#handlePendingResponse (channel) {
|
|
172
|
+
for (const { service, request } of channel[kPendingResponses].values()) {
|
|
173
|
+
this._emitResponse(
|
|
174
|
+
generateResponse(
|
|
175
|
+
request,
|
|
176
|
+
new errors.MessagingError(service, 'The communication channel was closed before receiving a response.'),
|
|
177
|
+
null
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
channel[kPendingResponses].clear()
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = { MessagingITC }
|
|
@@ -16,7 +16,7 @@ class RoundRobinMap extends Map {
|
|
|
16
16
|
this.#instances = {}
|
|
17
17
|
|
|
18
18
|
for (const service of services) {
|
|
19
|
-
this.#instances[service.id] = { next: 0, count: service.workers }
|
|
19
|
+
this.#instances[service.id] = { next: service.next ?? 0, count: service.workers }
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
|
package/lib/worker/symbols.js
CHANGED
|
@@ -14,6 +14,9 @@ const kLastELU = Symbol.for('plt.runtime.worker.lastELU')
|
|
|
14
14
|
// This string marker should be safe to use since it belongs to Unicode private area
|
|
15
15
|
const kStderrMarker = '\ue002'
|
|
16
16
|
|
|
17
|
+
// Note that this is used to create a BroadcastChannel so it must be a string
|
|
18
|
+
const kWorkersBroadcast = 'plt.runtime.workers'
|
|
19
|
+
|
|
17
20
|
module.exports = {
|
|
18
21
|
kConfig,
|
|
19
22
|
kId,
|
|
@@ -25,5 +28,6 @@ module.exports = {
|
|
|
25
28
|
kLastELU,
|
|
26
29
|
kWorkerStatus,
|
|
27
30
|
kStderrMarker,
|
|
28
|
-
kInterceptors
|
|
31
|
+
kInterceptors,
|
|
32
|
+
kWorkersBroadcast
|
|
29
33
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@platformatic/runtime",
|
|
3
|
-
"version": "2.70.
|
|
3
|
+
"version": "2.70.1",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -37,12 +37,12 @@
|
|
|
37
37
|
"typescript": "^5.5.4",
|
|
38
38
|
"undici-oidc-interceptor": "^0.5.0",
|
|
39
39
|
"why-is-node-running": "^2.2.2",
|
|
40
|
-
"@platformatic/composer": "2.70.
|
|
41
|
-
"@platformatic/
|
|
42
|
-
"@platformatic/
|
|
43
|
-
"@platformatic/
|
|
44
|
-
"@platformatic/sql-
|
|
45
|
-
"@platformatic/
|
|
40
|
+
"@platformatic/composer": "2.70.1",
|
|
41
|
+
"@platformatic/node": "2.70.1",
|
|
42
|
+
"@platformatic/service": "2.70.1",
|
|
43
|
+
"@platformatic/sql-graphql": "2.70.1",
|
|
44
|
+
"@platformatic/sql-mapper": "2.70.1",
|
|
45
|
+
"@platformatic/db": "2.70.1"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@fastify/accepts": "^5.0.0",
|
|
@@ -76,24 +76,24 @@
|
|
|
76
76
|
"undici": "^7.0.0",
|
|
77
77
|
"undici-thread-interceptor": "^0.14.0",
|
|
78
78
|
"ws": "^8.16.0",
|
|
79
|
-
"@platformatic/
|
|
80
|
-
"@platformatic/
|
|
81
|
-
"@platformatic/
|
|
82
|
-
"@platformatic/itc": "2.70.
|
|
83
|
-
"@platformatic/
|
|
84
|
-
"@platformatic/
|
|
85
|
-
"@platformatic/
|
|
86
|
-
"@platformatic/
|
|
79
|
+
"@platformatic/config": "2.70.1",
|
|
80
|
+
"@platformatic/basic": "2.70.1",
|
|
81
|
+
"@platformatic/generators": "2.70.1",
|
|
82
|
+
"@platformatic/itc": "2.70.1",
|
|
83
|
+
"@platformatic/metrics": "2.70.1",
|
|
84
|
+
"@platformatic/telemetry": "2.70.1",
|
|
85
|
+
"@platformatic/ts-compiler": "2.70.1",
|
|
86
|
+
"@platformatic/utils": "2.70.1"
|
|
87
87
|
},
|
|
88
88
|
"scripts": {
|
|
89
89
|
"test": "pnpm run lint && borp --concurrency=1 --timeout=1200000 && tsd",
|
|
90
|
-
"test:main": "borp --concurrency=1 --timeout=
|
|
91
|
-
"test:api": "borp --concurrency=1 --timeout=
|
|
92
|
-
"test:cli": "borp --concurrency=1 --timeout=
|
|
93
|
-
"test:start": "borp --concurrency=1 --timeout=
|
|
90
|
+
"test:main": "borp --concurrency=1 --timeout=1200000 test/*.test.js test/*.test.mjs test/versions/*.test.js test/versions/*.test.mjs",
|
|
91
|
+
"test:api": "borp --concurrency=1 --timeout=1200000 test/api/*.test.js test/api/*.test.mjs test/management-api/*.test.js test/management-api/*.test.mjs",
|
|
92
|
+
"test:cli": "borp --concurrency=1 --timeout=1200000 test/cli/*.test.js test/cli/*.test.mjs test/cli/**/*.test.js test/cli/**/*.test.mjs",
|
|
93
|
+
"test:start": "borp --concurrency=1 --timeout=1200000 test/start/*.test.js test/start/*.test.mjs",
|
|
94
94
|
"test:multiple-workers": "borp --concurrency=1 --timeout=1200000 test/multiple-workers/*.test.js test/multiple-workers/*.test.mjs",
|
|
95
95
|
"test:types": "tsd",
|
|
96
|
-
"coverage": "pnpm run lint && borp -X fixtures -X test -C --concurrency=1 --timeout=
|
|
96
|
+
"coverage": "pnpm run lint && borp -X fixtures -X test -C --concurrency=1 --timeout=1200000 && tsd",
|
|
97
97
|
"gen-schema": "node lib/schema.js > schema.json",
|
|
98
98
|
"gen-types": "json2ts > config.d.ts < schema.json",
|
|
99
99
|
"build": "pnpm run gen-schema && pnpm run gen-types",
|
package/schema.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://schemas.platformatic.dev/@platformatic/runtime/2.70.
|
|
2
|
+
"$id": "https://schemas.platformatic.dev/@platformatic/runtime/2.70.1.json",
|
|
3
3
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1568,6 +1568,18 @@
|
|
|
1568
1568
|
],
|
|
1569
1569
|
"default": 300000
|
|
1570
1570
|
},
|
|
1571
|
+
"messagingTimeout": {
|
|
1572
|
+
"anyOf": [
|
|
1573
|
+
{
|
|
1574
|
+
"type": "number",
|
|
1575
|
+
"minimum": 1
|
|
1576
|
+
},
|
|
1577
|
+
{
|
|
1578
|
+
"type": "string"
|
|
1579
|
+
}
|
|
1580
|
+
],
|
|
1581
|
+
"default": 30000
|
|
1582
|
+
},
|
|
1571
1583
|
"resolvedServicesBasePath": {
|
|
1572
1584
|
"type": "string",
|
|
1573
1585
|
"default": "external"
|