@platformatic/runtime 3.0.0-alpha.6 → 3.0.0-alpha.8

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
@@ -32,6 +32,7 @@ export type PlatformaticRuntimeConfig = {
32
32
  maxYoungGeneration?: number | string;
33
33
  };
34
34
  preload?: string | string[];
35
+ dependencies?: string[];
35
36
  arguments?: string[];
36
37
  nodeOptions?: string;
37
38
  };
package/lib/config.js CHANGED
@@ -16,12 +16,59 @@ import {
16
16
  InspectAndInspectBrkError,
17
17
  InspectorHostError,
18
18
  InspectorPortError,
19
+ InvalidArgumentError,
19
20
  InvalidEntrypointError,
20
21
  MissingEntrypointError
21
22
  } from './errors.js'
22
23
  import { schema } from './schema.js'
23
24
  import { upgrade } from './upgrade.js'
24
25
 
26
+ // Validate and coerce workers values early to avoid runtime hangs when invalid
27
+ function coercePositiveInteger (value) {
28
+ if (typeof value === 'number') {
29
+ if (!Number.isInteger(value) || value < 1) return null
30
+ return value
31
+ }
32
+ if (typeof value === 'string') {
33
+ // Trim to handle accidental spaces
34
+ const trimmed = value.trim()
35
+ if (trimmed.length === 0) return null
36
+ const num = Number(trimmed)
37
+ if (!Number.isFinite(num) || !Number.isInteger(num) || num < 1) return null
38
+ return num
39
+ }
40
+ return null
41
+ }
42
+
43
+ function raiseInvalidWorkersError (location, received, hint) {
44
+ const extra = hint ? ` (${hint})` : ''
45
+ throw new InvalidArgumentError(`${location} workers must be a positive integer; received "${received}"${extra}`)
46
+ }
47
+
48
+ export function autoDetectPprofCapture (config) {
49
+ const require = createRequire(import.meta.url)
50
+
51
+ let pprofCapturePath
52
+ try {
53
+ pprofCapturePath = require.resolve('@platformatic/wattpm-pprof-capture')
54
+ } catch (err) {
55
+ // No-op
56
+ }
57
+
58
+ // Add to preload if not already present
59
+ if (!config.preload) {
60
+ config.preload = []
61
+ } else if (typeof config.preload === 'string') {
62
+ config.preload = [config.preload]
63
+ }
64
+
65
+ if (pprofCapturePath && !config.preload.includes(pprofCapturePath)) {
66
+ config.preload.push(pprofCapturePath)
67
+ }
68
+
69
+ return config
70
+ }
71
+
25
72
  export async function wrapInRuntimeConfig (config, context) {
26
73
  let applicationId = 'main'
27
74
  try {
@@ -145,7 +192,14 @@ export async function transform (config, _, context) {
145
192
  config = join(entryPath, configFilename)
146
193
  }
147
194
 
148
- const application = { id, config, path: entryPath, useHttp: !!mapping.useHttp, health: mapping.health }
195
+ const application = {
196
+ id,
197
+ config,
198
+ path: entryPath,
199
+ useHttp: !!mapping.useHttp,
200
+ health: mapping.health,
201
+ dependencies: mapping.dependencies
202
+ }
149
203
  const existingApplicationId = applications.findIndex(application => application.id === id)
150
204
 
151
205
  if (existingApplicationId !== -1) {
@@ -161,6 +215,17 @@ export async function transform (config, _, context) {
161
215
 
162
216
  let hasValidEntrypoint = false
163
217
 
218
+ // Root-level workers
219
+ if (typeof config.workers !== 'undefined') {
220
+ const coerced = coercePositiveInteger(config.workers)
221
+ if (coerced === null) {
222
+ const raw = config.workers
223
+ const hint = typeof raw === 'string' && /\{.*\}/.test(raw) ? 'check your environment variable' : ''
224
+ raiseInvalidWorkersError('Runtime', config.workers, hint)
225
+ }
226
+ config.workers = coerced
227
+ }
228
+
164
229
  for (let i = 0; i < applications.length; ++i) {
165
230
  const application = applications[i]
166
231
 
@@ -207,8 +272,19 @@ export async function transform (config, _, context) {
207
272
  application.type = 'unknown'
208
273
  }
209
274
 
275
+ // Validate and coerce per-service workers
276
+ if (typeof application.workers !== 'undefined') {
277
+ const coerced = coercePositiveInteger(application.workers)
278
+ if (coerced === null) {
279
+ const raw = config.application?.[i]?.workers
280
+ const hint = typeof raw === 'string' && /\{.*\}/.test(raw) ? 'check your environment variable' : ''
281
+ raiseInvalidWorkersError(`Service "${application.id}"`, application.workers, hint)
282
+ }
283
+ application.workers = coerced
284
+ }
285
+
210
286
  application.entrypoint = application.id === config.entrypoint
211
- application.dependencies = []
287
+ application.dependencies ??= []
212
288
  application.localUrl = `http://${application.id}.plt.local`
213
289
 
214
290
  if (typeof application.watch === 'undefined') {
@@ -277,5 +353,8 @@ export async function transform (config, _, context) {
277
353
  }
278
354
  }
279
355
 
356
+ // Auto-detect and add pprof capture if available
357
+ autoDetectPprofCapture(config)
358
+
280
359
  return config
281
360
  }
@@ -4,6 +4,7 @@ import { createDirectory, safeRemove } from '@platformatic/foundation'
4
4
  import fastify from 'fastify'
5
5
  import { platform, tmpdir } from 'node:os'
6
6
  import { join } from 'node:path'
7
+ import { setTimeout as sleep } from 'node:timers/promises'
7
8
  import { createWebSocketStream } from 'ws'
8
9
 
9
10
  const PLATFORMATIC_TMP_DIR = join(tmpdir(), 'platformatic', 'runtimes')
@@ -111,6 +112,23 @@ export async function managementApiPlugin (app, opts) {
111
112
  reply.code(res.statusCode).headers(res.headers).send(res.body)
112
113
  })
113
114
 
115
+ app.post('/applications/:id/pprof/start', async (request, reply) => {
116
+ const { id } = request.params
117
+ app.log.debug('start profiling', { id })
118
+
119
+ const options = request.body || {}
120
+ await runtime.startApplicationProfiling(id, options)
121
+ reply.code(200).send({})
122
+ })
123
+
124
+ app.post('/applications/:id/pprof/stop', async (request, reply) => {
125
+ const { id } = request.params
126
+ app.log.debug('stop profiling', { id })
127
+
128
+ const profileData = await runtime.stopApplicationProfiling(id)
129
+ reply.type('application/octet-stream').code(200).send(profileData)
130
+ })
131
+
114
132
  app.get('/metrics', { logLevel: 'debug' }, async (req, reply) => {
115
133
  const accepts = req.accepts()
116
134
 
@@ -152,40 +170,53 @@ export async function managementApiPlugin (app, opts) {
152
170
  })
153
171
  }
154
172
 
155
- export async function startManagementApi (runtime, root) {
173
+ export async function startManagementApi (runtime) {
156
174
  const runtimePID = process.pid
157
175
 
158
- try {
159
- const runtimePIDDir = join(PLATFORMATIC_TMP_DIR, runtimePID.toString())
176
+ const runtimePIDDir = join(PLATFORMATIC_TMP_DIR, runtimePID.toString())
177
+ if (platform() !== 'win32') {
178
+ await createDirectory(runtimePIDDir, true)
179
+ }
180
+
181
+ let socketPath = null
182
+ if (platform() === 'win32') {
183
+ socketPath = '\\\\.\\pipe\\platformatic-' + runtimePID.toString()
184
+ } else {
185
+ socketPath = join(runtimePIDDir, 'socket')
186
+ }
187
+
188
+ const managementApi = fastify()
189
+ managementApi.register(fastifyWebsocket)
190
+ managementApi.register(managementApiPlugin, { runtime, prefix: '/api/v1' })
191
+
192
+ managementApi.addHook('onClose', async () => {
160
193
  if (platform() !== 'win32') {
161
- await createDirectory(runtimePIDDir, true)
194
+ await safeRemove(runtimePIDDir)
162
195
  }
196
+ })
163
197
 
164
- let socketPath = null
165
- if (platform() === 'win32') {
166
- socketPath = '\\\\.\\pipe\\platformatic-' + runtimePID.toString()
167
- } else {
168
- socketPath = join(runtimePIDDir, 'socket')
169
- }
198
+ // When the runtime closes, close the management API as well
199
+ runtime.on('closed', managementApi.close.bind(managementApi))
170
200
 
171
- const managementApi = fastify()
172
- managementApi.register(fastifyWebsocket)
173
- managementApi.register(managementApiPlugin, { runtime, prefix: '/api/v1' })
201
+ /*
202
+ If runtime are started multiple times in a short
203
+ period of time (like in tests), there is a chance that the pipe is still in use
204
+ as the manament API server is closed after the runtime is closed (see event handler above).
174
205
 
175
- managementApi.addHook('onClose', async () => {
176
- if (platform() !== 'win32') {
177
- await safeRemove(runtimePIDDir)
206
+ Since it's a very rare case, we simply retry couple of times.
207
+ */
208
+ for (let i = 0; i < 5; i++) {
209
+ try {
210
+ await managementApi.listen({ path: socketPath })
211
+ break
212
+ } catch (e) {
213
+ if (i === 5) {
214
+ throw e
178
215
  }
179
- })
180
-
181
- // When the runtime closes, close the management API as well
182
- runtime.on('closed', managementApi.close.bind(managementApi))
183
216
 
184
- await managementApi.listen({ path: socketPath })
185
- return managementApi
186
- /* c8 ignore next 4 */
187
- } catch (err) {
188
- console.error(err)
189
- process.exit(1)
217
+ await sleep(100)
218
+ }
190
219
  }
220
+
221
+ return managementApi
191
222
  }
package/lib/runtime.js CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  deepmerge,
3
3
  ensureError,
4
4
  ensureLoggableError,
5
+ executeInParallel,
5
6
  executeWithTimeout,
6
7
  features,
7
8
  kMetadata,
@@ -22,7 +23,6 @@ import { Worker } from 'node:worker_threads'
22
23
  import SonicBoom from 'sonic-boom'
23
24
  import { Agent, request, interceptors as undiciInterceptors } from 'undici'
24
25
  import { createThreadInterceptor } from 'undici-thread-interceptor'
25
- import { checkDependencies, topologicalSort } from './dependencies.js'
26
26
  import {
27
27
  ApplicationAlreadyStartedError,
28
28
  ApplicationNotFoundError,
@@ -59,18 +59,20 @@ import {
59
59
 
60
60
  const kWorkerFile = join(import.meta.dirname, 'worker/main.js')
61
61
  const kInspectorOptions = Symbol('plt.runtime.worker.inspectorOptions')
62
- const kForwardEvents = Symbol('plt.runtime.worker.forwardEvents')
63
62
 
64
63
  const MAX_LISTENERS_COUNT = 100
65
64
  const MAX_METRICS_QUEUE_LENGTH = 5 * 60 // 5 minutes in seconds
66
65
  const COLLECT_METRICS_TIMEOUT = 1000
67
66
 
67
+ const MAX_CONCURRENCY = 5
68
68
  const MAX_BOOTSTRAP_ATTEMPTS = 5
69
69
  const IMMEDIATE_RESTART_MAX_THRESHOLD = 10
70
70
  const MAX_WORKERS = 100
71
71
 
72
72
  export class Runtime extends EventEmitter {
73
73
  logger
74
+ error
75
+
74
76
  #loggerDestination
75
77
  #stdio
76
78
 
@@ -81,6 +83,7 @@ export class Runtime extends EventEmitter {
81
83
  #context
82
84
  #sharedContext
83
85
  #isProduction
86
+ #concurrency
84
87
  #entrypointId
85
88
  #url
86
89
 
@@ -112,6 +115,7 @@ export class Runtime extends EventEmitter {
112
115
  this.#env = config[kMetadata].env
113
116
  this.#context = context ?? {}
114
117
  this.#isProduction = this.#context.isProduction ?? this.#context.production ?? false
118
+ this.#concurrency = this.#context.concurrency ?? MAX_CONCURRENCY
115
119
  this.#workers = new RoundRobinMap()
116
120
  this.#url = undefined
117
121
  this.#meshInterceptor = createThreadInterceptor({ domain: '.plt.local', timeout: this.#config.applicationTimeout })
@@ -150,7 +154,6 @@ export class Runtime extends EventEmitter {
150
154
  }
151
155
 
152
156
  const config = this.#config
153
- const autoloadEnabled = config.autoload
154
157
 
155
158
  if (config.managementApi) {
156
159
  this.#managementApi = await startManagementApi(this, this.#root)
@@ -190,61 +193,7 @@ export class Runtime extends EventEmitter {
190
193
  this.#env['PLT_ENVIRONMENT'] = 'development'
191
194
  }
192
195
 
193
- // Create all applications, each in is own worker thread
194
- for (const applicationConfig of config.applications) {
195
- // If there is no application path, check if the application was resolved
196
- if (!applicationConfig.path) {
197
- if (applicationConfig.url) {
198
- // Try to backfill the path for external applications
199
- applicationConfig.path = join(this.#root, config.resolvedApplicationsBasePath, applicationConfig.id)
200
-
201
- if (!existsSync(applicationConfig.path)) {
202
- const executable = globalThis.platformatic?.executable ?? 'platformatic'
203
- this.logger.error(
204
- `The path for application "%s" does not exist. Please run "${executable} resolve" and try again.`,
205
- applicationConfig.id
206
- )
207
-
208
- await this.closeAndThrow(new RuntimeAbortedError())
209
- }
210
- } else {
211
- this.logger.error(
212
- 'The application "%s" has no path defined. Please check your configuration and try again.',
213
- applicationConfig.id
214
- )
215
-
216
- await this.closeAndThrow(new RuntimeAbortedError())
217
- }
218
- }
219
-
220
- await this.#setupApplication(applicationConfig)
221
- }
222
-
223
- try {
224
- checkDependencies(config.applications)
225
-
226
- // Make sure the list exists before computing the dependencies, otherwise some applications might not be stopped
227
- if (autoloadEnabled) {
228
- this.#workers = topologicalSort(this.#workers, config)
229
- }
230
-
231
- // When autoloading is disabled, add a warning if an application is defined before its dependencies
232
- if (!autoloadEnabled) {
233
- for (let i = 0; i < config.applications.length; i++) {
234
- const current = config.applications[i]
235
-
236
- for (const dep of current.dependencies ?? []) {
237
- if (config.applications.findIndex(s => s.id === dep.id) > i) {
238
- this.logger.warn(
239
- `Application "${current.id}" depends on application "${dep.id}", but it is defined and it will be started before it. Please check your configuration file.`
240
- )
241
- }
242
- }
243
- }
244
- }
245
- } catch (e) {
246
- await this.closeAndThrow(e)
247
- }
196
+ await this.#setupApplications()
248
197
 
249
198
  await this.#setDispatcher(config.undici)
250
199
 
@@ -266,12 +215,14 @@ export class Runtime extends EventEmitter {
266
215
  this.#updateStatus('starting')
267
216
  this.#createWorkersBroadcastChannel()
268
217
 
269
- // Important: do not use Promise.all here since it won't properly manage dependencies
270
218
  try {
219
+ const startInvocations = []
271
220
  for (const application of this.getApplicationsIds()) {
272
- await this.startApplication(application, silent)
221
+ startInvocations.push([application, silent])
273
222
  }
274
223
 
224
+ await executeInParallel(this.startApplication.bind(this), startInvocations, this.#concurrency)
225
+
275
226
  if (this.#config.inspectorOptions) {
276
227
  const { port } = this.#config.inspectorOptions
277
228
 
@@ -340,16 +291,35 @@ export class Runtime extends EventEmitter {
340
291
  await this.stopApplication(this.#entrypointId, silent)
341
292
  }
342
293
 
343
- // Stop applications in reverse order to ensure applications which depend on others are stopped first
344
- for (const application of this.getApplicationsIds().reverse()) {
294
+ const stopInvocations = []
295
+
296
+ const allApplications = await this.getApplications(true)
297
+
298
+ // Construct the reverse dependency graph
299
+ const dependents = {}
300
+ for (const application of allApplications.applications) {
301
+ for (const dependency of application.dependencies ?? []) {
302
+ let applicationDependents = dependents[dependency]
303
+ if (!applicationDependents) {
304
+ applicationDependents = new Set()
305
+ dependents[dependency] = applicationDependents
306
+ }
307
+
308
+ applicationDependents.add(application.id)
309
+ }
310
+ }
311
+
312
+ for (const application of this.getApplicationsIds()) {
345
313
  // The entrypoint has been stopped above
346
314
  if (application === this.#entrypointId) {
347
315
  continue
348
316
  }
349
317
 
350
- await this.stopApplication(application, silent)
318
+ stopInvocations.push([application, silent, Array.from(dependents[application] ?? [])])
351
319
  }
352
320
 
321
+ await executeInParallel(this.stopApplication.bind(this), stopInvocations, this.#concurrency)
322
+
353
323
  await this.#meshInterceptor.close()
354
324
  this.#workersBroadcastChannel?.close()
355
325
 
@@ -397,6 +367,7 @@ export class Runtime extends EventEmitter {
397
367
 
398
368
  async closeAndThrow (error) {
399
369
  this.#updateStatus('errored', error)
370
+ this.error = error
400
371
 
401
372
  // Wait for the next tick so that any pending logging is properly flushed
402
373
  await sleep(1)
@@ -452,9 +423,7 @@ export class Runtime extends EventEmitter {
452
423
 
453
424
  emit (event, payload) {
454
425
  for (const worker of this.#workers.values()) {
455
- if (worker[kForwardEvents]) {
456
- worker[kITC].notify('runtime:event', { event, payload })
457
- }
426
+ worker[kITC].notify('runtime:event', { event, payload })
458
427
  }
459
428
 
460
429
  this.logger.trace({ event, payload }, 'Runtime event')
@@ -502,7 +471,7 @@ export class Runtime extends EventEmitter {
502
471
  this.emit('application:started', id)
503
472
  }
504
473
 
505
- async stopApplication (id, silent = false) {
474
+ async stopApplication (id, silent = false, dependents = []) {
506
475
  const config = this.#config
507
476
  const applicationConfig = config.applications.find(s => s.id === id)
508
477
 
@@ -515,9 +484,12 @@ export class Runtime extends EventEmitter {
515
484
  this.emit('application:stopping', id)
516
485
 
517
486
  if (typeof workersCount === 'number') {
487
+ const stopInvocations = []
518
488
  for (let i = 0; i < workersCount; i++) {
519
- await this.#stopWorker(workersCount, id, i, silent)
489
+ stopInvocations.push([workersCount, id, i, silent, undefined, dependents])
520
490
  }
491
+
492
+ await executeInParallel(this.#stopWorker.bind(this), stopInvocations, this.#concurrency)
521
493
  }
522
494
 
523
495
  this.emit('application:stopped', id)
@@ -540,6 +512,18 @@ export class Runtime extends EventEmitter {
540
512
  }
541
513
  }
542
514
 
515
+ async startApplicationProfiling (id, options = {}, ensureStarted = true) {
516
+ const service = await this.#getApplicationById(id, ensureStarted)
517
+
518
+ return sendViaITC(service, 'startProfiling', options)
519
+ }
520
+
521
+ async stopApplicationProfiling (id, ensureStarted = true) {
522
+ const service = await this.#getApplicationById(id, ensureStarted)
523
+
524
+ return sendViaITC(service, 'stopProfiling')
525
+ }
526
+
543
527
  async updateUndiciInterceptors (undiciConfig) {
544
528
  this.#config.undici = undiciConfig
545
529
 
@@ -754,6 +738,10 @@ export class Runtime extends EventEmitter {
754
738
  return report
755
739
  }
756
740
 
741
+ setConcurrency (concurrency) {
742
+ this.#concurrency = concurrency
743
+ }
744
+
757
745
  async getUrl () {
758
746
  return this.#url
759
747
  }
@@ -1014,11 +1002,13 @@ export class Runtime extends EventEmitter {
1014
1002
  return this.#config.applications.map(application => application.id)
1015
1003
  }
1016
1004
 
1017
- async getApplications () {
1005
+ async getApplications (allowUnloaded = false) {
1018
1006
  return {
1019
1007
  entrypoint: this.#entrypointId,
1020
1008
  production: this.#isProduction,
1021
- applications: await Promise.all(this.getApplicationsIds().map(id => this.getApplicationDetails(id)))
1009
+ applications: await Promise.all(
1010
+ this.getApplicationsIds().map(id => this.getApplicationDetails(id, allowUnloaded))
1011
+ )
1022
1012
  }
1023
1013
  }
1024
1014
 
@@ -1070,19 +1060,19 @@ export class Runtime extends EventEmitter {
1070
1060
  throw e
1071
1061
  }
1072
1062
 
1073
- const { entrypoint, dependencies, localUrl } = application[kConfig]
1063
+ const { entrypoint, localUrl } = application[kConfig]
1074
1064
 
1075
1065
  const status = await sendViaITC(application, 'getStatus')
1076
- const { type, version } = await sendViaITC(application, 'getApplicationInfo')
1066
+ const { type, version, dependencies } = await sendViaITC(application, 'getApplicationInfo')
1077
1067
 
1078
1068
  const applicationDetails = {
1079
1069
  id,
1080
1070
  type,
1081
1071
  status,
1072
+ dependencies,
1082
1073
  version,
1083
1074
  localUrl,
1084
- entrypoint,
1085
- dependencies
1075
+ entrypoint
1086
1076
  }
1087
1077
 
1088
1078
  if (this.#isProduction) {
@@ -1175,17 +1165,59 @@ export class Runtime extends EventEmitter {
1175
1165
  this.logger.info(`Platformatic is now listening at ${this.#url}`)
1176
1166
  }
1177
1167
 
1168
+ async #setupApplications () {
1169
+ const config = this.#config
1170
+ const setupInvocations = []
1171
+
1172
+ // Parse all applications and verify we're not missing any path or resolved application
1173
+ for (const applicationConfig of config.applications) {
1174
+ // If there is no application path, check if the application was resolved
1175
+ if (!applicationConfig.path) {
1176
+ if (applicationConfig.url) {
1177
+ // Try to backfill the path for external applications
1178
+ applicationConfig.path = join(this.#root, config.resolvedApplicationsBasePath, applicationConfig.id)
1179
+
1180
+ if (!existsSync(applicationConfig.path)) {
1181
+ const executable = globalThis.platformatic?.executable ?? 'platformatic'
1182
+ this.logger.error(
1183
+ `The path for application "%s" does not exist. Please run "${executable} resolve" and try again.`,
1184
+ applicationConfig.id
1185
+ )
1186
+
1187
+ await this.closeAndThrow(new RuntimeAbortedError())
1188
+ }
1189
+ } else {
1190
+ this.logger.error(
1191
+ 'The application "%s" has no path defined. Please check your configuration and try again.',
1192
+ applicationConfig.id
1193
+ )
1194
+
1195
+ await this.closeAndThrow(new RuntimeAbortedError())
1196
+ }
1197
+ }
1198
+
1199
+ setupInvocations.push([applicationConfig])
1200
+ }
1201
+
1202
+ await executeInParallel(this.#setupApplication.bind(this), setupInvocations, this.#concurrency)
1203
+ }
1204
+
1178
1205
  async #setupApplication (applicationConfig) {
1179
- if (this.#status === 'stopping' || this.#status === 'closed') return
1206
+ if (this.#status === 'stopping' || this.#status === 'closed') {
1207
+ return
1208
+ }
1180
1209
 
1181
1210
  const config = this.#config
1182
1211
  const workersCount = await this.#workers.getCount(applicationConfig.id)
1183
1212
  const id = applicationConfig.id
1213
+ const setupInvocations = []
1184
1214
 
1185
1215
  for (let i = 0; i < workersCount; i++) {
1186
- await this.#setupWorker(config, applicationConfig, workersCount, id, i)
1216
+ setupInvocations.push([config, applicationConfig, workersCount, id, i])
1187
1217
  }
1188
1218
 
1219
+ await executeInParallel(this.#setupWorker.bind(this), setupInvocations, this.#concurrency)
1220
+
1189
1221
  this.emit('application:init', id)
1190
1222
  }
1191
1223
 
@@ -1287,6 +1319,7 @@ export class Runtime extends EventEmitter {
1287
1319
 
1288
1320
  // Track application exiting
1289
1321
  const eventPayload = { application: applicationId, worker: index, workersCount }
1322
+
1290
1323
  worker.once('exit', code => {
1291
1324
  if (worker[kWorkerStatus] === 'exited') {
1292
1325
  return
@@ -1338,7 +1371,6 @@ export class Runtime extends EventEmitter {
1338
1371
  worker[kApplicationId] = applicationId
1339
1372
  worker[kWorkerId] = workersCount > 1 ? index : undefined
1340
1373
  worker[kWorkerStatus] = 'boot'
1341
- worker[kForwardEvents] = false
1342
1374
 
1343
1375
  if (inspectorOptions) {
1344
1376
  worker[kInspectorOptions] = {
@@ -1352,12 +1384,7 @@ export class Runtime extends EventEmitter {
1352
1384
  worker[kITC] = new ITC({
1353
1385
  name: workerId + '-runtime',
1354
1386
  port: worker,
1355
- handlers: {
1356
- ...this.#workerITCHandlers,
1357
- setEventsForwarding (value) {
1358
- worker[kForwardEvents] = value
1359
- }
1360
- }
1387
+ handlers: this.#workerITCHandlers
1361
1388
  })
1362
1389
  worker[kITC].listen()
1363
1390
 
@@ -1403,15 +1430,13 @@ export class Runtime extends EventEmitter {
1403
1430
  this.#meshInterceptor.route(applicationId, worker)
1404
1431
  }
1405
1432
 
1406
- // Store dependencies
1407
- const [{ dependencies }] = await waitEventFromITC(worker, 'init')
1408
- applicationConfig.dependencies = dependencies
1433
+ // Wait for initialization
1434
+ await waitEventFromITC(worker, 'init')
1409
1435
 
1410
1436
  if (applicationConfig.entrypoint) {
1411
1437
  this.#entrypointId = applicationId
1412
1438
  }
1413
1439
 
1414
- // This must be done here as the dependencies are filled above
1415
1440
  worker[kConfig] = { ...applicationConfig, health, workers: workersCount }
1416
1441
  worker[kWorkerStatus] = 'init'
1417
1442
  this.emit('application:worker:init', eventPayload)
@@ -1587,6 +1612,7 @@ export class Runtime extends EventEmitter {
1587
1612
  }
1588
1613
  } catch (err) {
1589
1614
  const error = ensureError(err)
1615
+ worker[kITC].notify('application:worker:start:processed')
1590
1616
 
1591
1617
  // TODO: handle port allocation error here
1592
1618
  if (error.code === 'EADDRINUSE' || error.code === 'EACCES') throw error
@@ -1608,7 +1634,7 @@ export class Runtime extends EventEmitter {
1608
1634
  this.emit('application:worker:start:error', { ...eventPayload, error })
1609
1635
 
1610
1636
  if (error.code !== 'PLT_RUNTIME_APPLICATION_START_TIMEOUT') {
1611
- this.logger.error({ err: ensureLoggableError(error) }, `Failed to start ${label}.`)
1637
+ this.logger.error({ err: ensureLoggableError(error) }, `Failed to start ${label}: ${error.message}`)
1612
1638
  }
1613
1639
 
1614
1640
  const restartOnError = config.restartOnError
@@ -1619,6 +1645,7 @@ export class Runtime extends EventEmitter {
1619
1645
 
1620
1646
  if (bootstrapAttempt++ >= MAX_BOOTSTRAP_ATTEMPTS || restartOnError === 0) {
1621
1647
  this.logger.error(`Failed to start ${label} after ${MAX_BOOTSTRAP_ATTEMPTS} attempts.`)
1648
+ this.emit('application:worker:start:failed', { ...eventPayload, error })
1622
1649
  throw error
1623
1650
  }
1624
1651
 
@@ -1636,7 +1663,7 @@ export class Runtime extends EventEmitter {
1636
1663
  }
1637
1664
  }
1638
1665
 
1639
- async #stopWorker (workersCount, id, index, silent, worker = undefined) {
1666
+ async #stopWorker (workersCount, id, index, silent, worker, dependents) {
1640
1667
  if (!worker) {
1641
1668
  worker = await this.#getWorkerById(id, index, false, false)
1642
1669
  }
@@ -1667,11 +1694,15 @@ export class Runtime extends EventEmitter {
1667
1694
 
1668
1695
  // Always send the stop message, it will shut down workers that only had ITC and interceptors setup
1669
1696
  try {
1670
- await executeWithTimeout(sendViaITC(worker, 'stop'), exitTimeout)
1697
+ await executeWithTimeout(sendViaITC(worker, 'stop', { force: !!this.error, dependents }), exitTimeout)
1671
1698
  } catch (error) {
1672
- this.emit('application:worker:stop:timeout', eventPayload)
1699
+ this.emit('application:worker:stop:error', eventPayload)
1673
1700
  this.logger.info({ error: ensureLoggableError(error) }, `Failed to stop ${label}. Killing a worker thread.`)
1674
1701
  } finally {
1702
+ worker[kITC].notify('application:worker:stop:processed')
1703
+ // Wait for the processed message to be received
1704
+ await sleep(1)
1705
+
1675
1706
  worker[kITC].close()
1676
1707
  }
1677
1708
 
@@ -1800,7 +1831,7 @@ export class Runtime extends EventEmitter {
1800
1831
  throw e
1801
1832
  }
1802
1833
 
1803
- await this.#stopWorker(workersCount, applicationId, index, false, worker)
1834
+ await this.#stopWorker(workersCount, applicationId, index, false, worker, [])
1804
1835
  }
1805
1836
 
1806
1837
  async #getApplicationById (applicationId, ensureStarted = false, mustExist = true) {
@@ -2323,7 +2354,7 @@ export class Runtime extends EventEmitter {
2323
2354
  for (let i = currentWorkers - 1; i >= workers; i--) {
2324
2355
  const worker = await this.#getWorkerById(applicationId, i, false, false)
2325
2356
  await sendViaITC(worker, 'removeFromMesh')
2326
- await this.#stopWorker(currentWorkers, applicationId, i, false, worker)
2357
+ await this.#stopWorker(currentWorkers, applicationId, i, false, worker, [])
2327
2358
  report.stopped.push(i)
2328
2359
  }
2329
2360
  await this.#updateApplicationConfigWorkers(applicationId, workers)
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  ensureLoggableError,
3
3
  FileWatcher,
4
+ kHandledError,
4
5
  listRecognizedConfigurationFiles,
5
6
  loadConfiguration,
6
7
  loadConfigurationModule
@@ -9,6 +10,7 @@ import debounce from 'debounce'
9
10
  import { EventEmitter } from 'node:events'
10
11
  import { existsSync } from 'node:fs'
11
12
  import { resolve } from 'node:path'
13
+ import { getActiveResourcesInfo } from 'node:process'
12
14
  import { workerData } from 'node:worker_threads'
13
15
  import { getGlobalDispatcher, setGlobalDispatcher } from 'undici'
14
16
  import { ApplicationAlreadyStartedError, RuntimeNotStartedError } from '../errors.js'
@@ -55,9 +57,11 @@ export class Controller extends EventEmitter {
55
57
  this.#lastELU = performance.eventLoopUtilization()
56
58
 
57
59
  this.#context = {
60
+ controller: this,
58
61
  applicationId: this.applicationId,
59
62
  workerId: this.workerId,
60
63
  directory: this.appConfig.path,
64
+ dependencies: this.appConfig.dependencies,
61
65
  isEntrypoint: this.appConfig.entrypoint,
62
66
  isProduction: this.appConfig.isProduction,
63
67
  telemetryConfig,
@@ -83,10 +87,7 @@ export class Controller extends EventEmitter {
83
87
  }
84
88
  }
85
89
 
86
- async getBootstrapDependencies () {
87
- return this.capability.getBootstrapDependencies?.() ?? []
88
- }
89
-
90
+ // Note: capability's init() is executed within start
90
91
  async init () {
91
92
  try {
92
93
  const appConfig = this.appConfig
@@ -142,10 +143,17 @@ export class Controller extends EventEmitter {
142
143
 
143
144
  try {
144
145
  await this.capability.init?.()
146
+ this.emit('init')
145
147
  } catch (err) {
146
148
  this.#logAndThrow(err)
147
149
  }
148
150
 
151
+ this.emit('starting')
152
+
153
+ if (this.capability.status === 'stopped') {
154
+ return
155
+ }
156
+
149
157
  if (this.#watch) {
150
158
  const watchConfig = await this.capability.getWatchConfig()
151
159
 
@@ -174,21 +182,23 @@ export class Controller extends EventEmitter {
174
182
 
175
183
  this.#started = true
176
184
  this.#starting = false
177
- this.emit('start')
185
+ this.emit('started')
178
186
  }
179
187
 
180
- async stop (force = false) {
188
+ async stop (force = false, dependents = []) {
181
189
  if (!force && (!this.#started || this.#starting)) {
182
190
  throw new RuntimeNotStartedError()
183
191
  }
184
192
 
193
+ this.emit('stopping')
185
194
  await this.#stopFileWatching()
195
+ await this.capability.waitForDependentsStop(dependents)
186
196
  await this.capability.stop()
187
197
 
188
198
  this.#started = false
189
199
  this.#starting = false
190
200
  this.#listening = false
191
- this.emit('stop')
201
+ this.emit('stopped')
192
202
  }
193
203
 
194
204
  async listen () {
@@ -213,6 +223,7 @@ export class Controller extends EventEmitter {
213
223
  globalThis.platformatic.onHttpStatsSize(url, size || 0)
214
224
  }
215
225
  }
226
+ globalThis.platformatic.onActiveResourcesEventLoop(getActiveResourcesInfo().length)
216
227
  return this.capability.getMetrics({ format })
217
228
  }
218
229
 
@@ -260,7 +271,11 @@ export class Controller extends EventEmitter {
260
271
  }
261
272
 
262
273
  #logAndThrow (err) {
263
- globalThis.platformatic.logger.error({ err: ensureLoggableError(err) }, 'The application threw an error.')
274
+ globalThis.platformatic.logger.error(
275
+ { err: ensureLoggableError(err) },
276
+ err[kHandledError] ? err.message : 'The application threw an error.'
277
+ )
278
+
264
279
  throw err
265
280
  }
266
281
 
package/lib/worker/itc.js CHANGED
@@ -69,8 +69,8 @@ export async function waitEventFromITC (worker, event) {
69
69
  return safeHandleInITC(worker, () => once(worker[kITC], event))
70
70
  }
71
71
 
72
- export function setupITC (instance, application, dispatcher, sharedContext) {
73
- const messaging = new MessagingITC(instance.appConfig.id, workerData.config)
72
+ export function setupITC (controller, application, dispatcher, sharedContext) {
73
+ const messaging = new MessagingITC(controller.appConfig.id, workerData.config)
74
74
 
75
75
  Object.assign(globalThis.platformatic ?? {}, {
76
76
  messaging: {
@@ -80,55 +80,61 @@ export function setupITC (instance, application, dispatcher, sharedContext) {
80
80
  })
81
81
 
82
82
  const itc = new ITC({
83
- name: instance.appConfig.id + '-worker',
83
+ name: controller.appConfig.id + '-worker',
84
84
  port: parentPort,
85
85
  handlers: {
86
86
  async start () {
87
- const status = instance.getStatus()
87
+ const status = controller.getStatus()
88
88
 
89
89
  if (status === 'starting') {
90
- await once(instance, 'start')
90
+ await once(controller, 'start')
91
91
  } else {
92
92
  // This gives a chance to a capability to perform custom logic
93
93
  globalThis.platformatic.events.emit('start')
94
94
 
95
95
  try {
96
- await instance.start()
96
+ await controller.start()
97
97
  } catch (e) {
98
- await instance.stop(true)
99
- await closeITC(dispatcher, itc, messaging)
98
+ await controller.stop(true)
99
+
100
+ // Reply to the runtime that the start failed, so it can cleanup
101
+ once(itc, 'application:worker:start:processed').then(() => {
102
+ closeITC(dispatcher, itc, messaging).catch(() => {})
103
+ })
100
104
 
101
105
  throw ensureLoggableError(e)
102
106
  }
103
107
  }
104
108
 
105
109
  if (application.entrypoint) {
106
- await instance.listen()
110
+ await controller.listen()
107
111
  }
108
112
 
109
- dispatcher.replaceServer(await instance.capability.getDispatchTarget())
110
- return application.entrypoint ? instance.capability.getUrl() : null
113
+ dispatcher.replaceServer(await controller.capability.getDispatchTarget())
114
+ return application.entrypoint ? controller.capability.getUrl() : null
111
115
  },
112
116
 
113
- async stop () {
114
- const status = instance.getStatus()
117
+ async stop ({ force, dependents }) {
118
+ const status = controller.getStatus()
115
119
 
116
- if (status === 'starting') {
117
- await once(instance, 'start')
120
+ if (!force && status === 'starting') {
121
+ await once(controller, 'start')
118
122
  }
119
123
 
120
- if (status.startsWith('start')) {
124
+ if (force || status.startsWith('start')) {
121
125
  // This gives a chance to a capability to perform custom logic
122
126
  globalThis.platformatic.events.emit('stop')
123
127
 
124
- await instance.stop()
128
+ await controller.stop(force, dependents)
125
129
  }
126
130
 
127
- await closeITC(dispatcher, itc, messaging)
131
+ once(itc, 'application:worker:stop:processed').then(() => {
132
+ closeITC(dispatcher, itc, messaging).catch(() => {})
133
+ })
128
134
  },
129
135
 
130
136
  async build () {
131
- return instance.capability.build()
137
+ return controller.capability.build()
132
138
  },
133
139
 
134
140
  async removeFromMesh () {
@@ -136,7 +142,7 @@ export function setupITC (instance, application, dispatcher, sharedContext) {
136
142
  },
137
143
 
138
144
  inject (injectParams) {
139
- return instance.capability.inject(injectParams)
145
+ return controller.capability.inject(injectParams)
140
146
  },
141
147
 
142
148
  async updateUndiciInterceptors (undiciConfig) {
@@ -150,27 +156,27 @@ export function setupITC (instance, application, dispatcher, sharedContext) {
150
156
  },
151
157
 
152
158
  getStatus () {
153
- return instance.getStatus()
159
+ return controller.getStatus()
154
160
  },
155
161
 
156
162
  getApplicationInfo () {
157
- return instance.capability.getInfo()
163
+ return controller.capability.getInfo()
158
164
  },
159
165
 
160
166
  async getApplicationConfig () {
161
- const current = await instance.capability.getConfig()
167
+ const current = await controller.capability.getConfig()
162
168
  // Remove all undefined keys from the config
163
169
  return JSON.parse(JSON.stringify(current))
164
170
  },
165
171
 
166
172
  async getApplicationEnv () {
167
173
  // Remove all undefined keys from the config
168
- return JSON.parse(JSON.stringify({ ...process.env, ...(await instance.capability.getEnv()) }))
174
+ return JSON.parse(JSON.stringify({ ...process.env, ...(await controller.capability.getEnv()) }))
169
175
  },
170
176
 
171
177
  async getApplicationOpenAPISchema () {
172
178
  try {
173
- return await instance.capability.getOpenapiSchema()
179
+ return await controller.capability.getOpenapiSchema()
174
180
  } catch (err) {
175
181
  throw new FailedToRetrieveOpenAPISchemaError(application.id, err.message)
176
182
  }
@@ -178,7 +184,7 @@ export function setupITC (instance, application, dispatcher, sharedContext) {
178
184
 
179
185
  async getApplicationGraphQLSchema () {
180
186
  try {
181
- return await instance.capability.getGraphqlSchema()
187
+ return await controller.capability.getGraphqlSchema()
182
188
  } catch (err) {
183
189
  throw new FailedToRetrieveGraphQLSchemaError(application.id, err.message)
184
190
  }
@@ -186,7 +192,7 @@ export function setupITC (instance, application, dispatcher, sharedContext) {
186
192
 
187
193
  async getApplicationMeta () {
188
194
  try {
189
- return await instance.capability.getMeta()
195
+ return await controller.capability.getMeta()
190
196
  } catch (err) {
191
197
  throw new FailedToRetrieveMetaError(application.id, err.message)
192
198
  }
@@ -194,7 +200,7 @@ export function setupITC (instance, application, dispatcher, sharedContext) {
194
200
 
195
201
  async getMetrics (format) {
196
202
  try {
197
- return await instance.getMetrics({ format })
203
+ return await controller.getMetrics({ format })
198
204
  } catch (err) {
199
205
  throw new FailedToRetrieveMetricsError(application.id, err.message)
200
206
  }
@@ -202,7 +208,7 @@ export function setupITC (instance, application, dispatcher, sharedContext) {
202
208
 
203
209
  async getHealth () {
204
210
  try {
205
- return await instance.getHealth()
211
+ return await controller.getHealth()
206
212
  } catch (err) {
207
213
  throw new FailedToRetrieveHealthError(application.id, err.message)
208
214
  }
@@ -210,7 +216,7 @@ export function setupITC (instance, application, dispatcher, sharedContext) {
210
216
 
211
217
  async getCustomHealthCheck () {
212
218
  try {
213
- return await instance.capability.getCustomHealthCheck()
219
+ return await controller.capability.getCustomHealthCheck()
214
220
  } catch (err) {
215
221
  throw new FailedToPerformCustomHealthCheckError(application.id, err.message)
216
222
  }
@@ -218,7 +224,7 @@ export function setupITC (instance, application, dispatcher, sharedContext) {
218
224
 
219
225
  async getCustomReadinessCheck () {
220
226
  try {
221
- return await instance.capability.getCustomReadinessCheck()
227
+ return await controller.capability.getCustomReadinessCheck()
222
228
  } catch (err) {
223
229
  throw new FailedToPerformCustomReadinessCheckError(application.id, err.message)
224
230
  }
@@ -234,7 +240,7 @@ export function setupITC (instance, application, dispatcher, sharedContext) {
234
240
  }
235
241
  })
236
242
 
237
- instance.on('changed', () => {
243
+ controller.on('changed', () => {
238
244
  itc.notify('changed')
239
245
  })
240
246
 
@@ -166,7 +166,7 @@ async function main () {
166
166
  }
167
167
 
168
168
  // Create the application
169
- const app = new Controller(
169
+ const controller = new Controller(
170
170
  application,
171
171
  workerData.worker.count > 1 ? workerData.worker.index : undefined,
172
172
  application.telemetry,
@@ -177,13 +177,13 @@ async function main () {
177
177
  !!config.watch
178
178
  )
179
179
 
180
- process.on('uncaughtException', handleUnhandled.bind(null, app, 'uncaught exception'))
181
- process.on('unhandledRejection', handleUnhandled.bind(null, app, 'unhandled rejection'))
180
+ process.on('uncaughtException', handleUnhandled.bind(null, controller, 'uncaught exception'))
181
+ process.on('unhandledRejection', handleUnhandled.bind(null, controller, 'unhandled rejection'))
182
182
 
183
- await app.init()
183
+ await controller.init()
184
184
 
185
185
  if (application.entrypoint && config.basePath) {
186
- const meta = await app.capability.getMeta()
186
+ const meta = await controller.capability.getMeta()
187
187
  if (!meta.gateway.wantsAbsoluteUrls) {
188
188
  stripBasePath(config.basePath)
189
189
  }
@@ -197,12 +197,10 @@ async function main () {
197
197
  }
198
198
 
199
199
  // Setup interaction with parent port
200
- const itc = setupITC(app, application, threadDispatcher, sharedContext)
200
+ const itc = setupITC(controller, application, threadDispatcher, sharedContext)
201
201
  globalThis[kITC] = itc
202
202
 
203
- // Get the dependencies
204
- const dependencies = await app.getBootstrapDependencies()
205
- itc.notify('init', { dependencies })
203
+ itc.notify('init')
206
204
  }
207
205
 
208
206
  function stripBasePath (basePath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/runtime",
3
- "version": "3.0.0-alpha.6",
3
+ "version": "3.0.0-alpha.8",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -34,18 +34,19 @@
34
34
  "typescript": "^5.5.4",
35
35
  "undici-oidc-interceptor": "^0.5.0",
36
36
  "why-is-node-running": "^2.2.2",
37
- "@platformatic/gateway": "3.0.0-alpha.6",
38
- "@platformatic/db": "3.0.0-alpha.6",
39
- "@platformatic/node": "3.0.0-alpha.6",
40
- "@platformatic/sql-mapper": "3.0.0-alpha.6",
41
- "@platformatic/sql-graphql": "3.0.0-alpha.6",
42
- "@platformatic/service": "3.0.0-alpha.6"
37
+ "@platformatic/composer": "3.0.0-alpha.8",
38
+ "@platformatic/gateway": "3.0.0-alpha.8",
39
+ "@platformatic/db": "3.0.0-alpha.8",
40
+ "@platformatic/node": "3.0.0-alpha.8",
41
+ "@platformatic/service": "3.0.0-alpha.8",
42
+ "@platformatic/sql-graphql": "3.0.0-alpha.8",
43
+ "@platformatic/sql-mapper": "3.0.0-alpha.8",
44
+ "@platformatic/wattpm-pprof-capture": "3.0.0-alpha.8"
43
45
  },
44
46
  "dependencies": {
45
47
  "@fastify/accepts": "^5.0.0",
46
48
  "@fastify/error": "^4.0.0",
47
49
  "@fastify/websocket": "^11.0.0",
48
- "@hapi/topo": "^6.0.2",
49
50
  "@opentelemetry/api": "^1.9.0",
50
51
  "@platformatic/undici-cache-memory": "^0.8.1",
51
52
  "@watchable/unpromise": "^1.0.2",
@@ -69,18 +70,18 @@
69
70
  "undici": "^7.0.0",
70
71
  "undici-thread-interceptor": "^0.14.0",
71
72
  "ws": "^8.16.0",
72
- "@platformatic/basic": "3.0.0-alpha.6",
73
- "@platformatic/itc": "3.0.0-alpha.6",
74
- "@platformatic/foundation": "3.0.0-alpha.6",
75
- "@platformatic/metrics": "3.0.0-alpha.6",
76
- "@platformatic/generators": "3.0.0-alpha.6",
77
- "@platformatic/telemetry": "3.0.0-alpha.6"
73
+ "@platformatic/basic": "3.0.0-alpha.8",
74
+ "@platformatic/generators": "3.0.0-alpha.8",
75
+ "@platformatic/itc": "3.0.0-alpha.8",
76
+ "@platformatic/telemetry": "3.0.0-alpha.8",
77
+ "@platformatic/metrics": "3.0.0-alpha.8",
78
+ "@platformatic/foundation": "3.0.0-alpha.8"
78
79
  },
79
80
  "engines": {
80
81
  "node": ">=22.18.0"
81
82
  },
82
83
  "scripts": {
83
- "test": "node --test --test-reporter=cleaner-spec-reporter --test-concurrency=1 --test-timeout=2000000 test/*.test.js test/**/*.test.js",
84
+ "test": "npm run test:main && npm run test:api && npm run test:cli && npm run test:start && npm run test:multiple-workers",
84
85
  "test:main": "node --test --test-reporter=cleaner-spec-reporter --test-concurrency=1 --test-timeout=2000000 test/*.test.js test/versions/*.test.js",
85
86
  "test:api": "node --test --test-reporter=cleaner-spec-reporter --test-concurrency=1 --test-timeout=2000000 test/api/*.test.js test/management-api/*.test.js",
86
87
  "test:cli": "node --test --test-reporter=cleaner-spec-reporter --test-concurrency=1 --test-timeout=2000000 test/cli/*.test.js test/cli/**/*.test.js",
@@ -88,7 +89,7 @@
88
89
  "test:multiple-workers": "node --test --test-reporter=cleaner-spec-reporter --test-concurrency=1 --test-timeout=2000000 test/multiple-workers/*.test.js",
89
90
  "gen-schema": "node lib/schema.js > schema.json",
90
91
  "gen-types": "json2ts > config.d.ts < schema.json",
91
- "build": "pnpm run gen-schema && pnpm run gen-types",
92
+ "build": "npm run gen-schema && npm run gen-types",
92
93
  "lint": "eslint"
93
94
  }
94
95
  }
package/schema.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.0.0-alpha.6.json",
2
+ "$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.0.0-alpha.8.json",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "title": "Platformatic Runtime Config",
5
5
  "type": "object",
@@ -186,6 +186,12 @@
186
186
  }
187
187
  ]
188
188
  },
189
+ "dependencies": {
190
+ "type": "array",
191
+ "items": {
192
+ "type": "string"
193
+ }
194
+ },
189
195
  "arguments": {
190
196
  "type": "array",
191
197
  "items": {
@@ -347,6 +353,13 @@
347
353
  },
348
354
  "additionalProperties": false
349
355
  },
356
+ "dependencies": {
357
+ "type": "array",
358
+ "items": {
359
+ "type": "string"
360
+ },
361
+ "default": []
362
+ },
350
363
  "arguments": {
351
364
  "type": "array",
352
365
  "items": {
@@ -576,6 +589,13 @@
576
589
  },
577
590
  "additionalProperties": false
578
591
  },
592
+ "dependencies": {
593
+ "type": "array",
594
+ "items": {
595
+ "type": "string"
596
+ },
597
+ "default": []
598
+ },
579
599
  "arguments": {
580
600
  "type": "array",
581
601
  "items": {
@@ -805,6 +825,13 @@
805
825
  },
806
826
  "additionalProperties": false
807
827
  },
828
+ "dependencies": {
829
+ "type": "array",
830
+ "items": {
831
+ "type": "string"
832
+ },
833
+ "default": []
834
+ },
808
835
  "arguments": {
809
836
  "type": "array",
810
837
  "items": {
@@ -1,63 +0,0 @@
1
- import { Sorter } from '@hapi/topo'
2
- import { closest } from 'fastest-levenshtein'
3
- import { MissingDependencyError } from './errors.js'
4
- import { RoundRobinMap } from './worker/round-robin-map.js'
5
-
6
- function missingDependencyErrorMessage (clientName, application, applications) {
7
- const allNames = applications
8
- .map(s => s.id)
9
- .filter(id => id !== application.id)
10
- .sort()
11
- const closestName = closest(clientName, allNames)
12
- let errorMsg = `application '${application.id}' has unknown dependency: '${clientName}'.`
13
- if (closestName) {
14
- errorMsg += ` Did you mean '${closestName}'?`
15
- }
16
- if (allNames.length) {
17
- errorMsg += ` Known applications are: ${allNames.join(', ')}.`
18
- }
19
- return errorMsg
20
- }
21
-
22
- export function checkDependencies (applications) {
23
- const allApplications = new Set(applications.map(s => s.id))
24
-
25
- for (const application of applications) {
26
- for (const dependency of application.dependencies) {
27
- if (dependency.local && !allApplications.has(dependency.id)) {
28
- throw new MissingDependencyError(missingDependencyErrorMessage(dependency.id, application, applications))
29
- }
30
- }
31
- }
32
- }
33
-
34
- export function topologicalSort (workers, config) {
35
- const topo = new Sorter()
36
-
37
- for (const application of config.applications) {
38
- const localDependencyIds = Array.from(application.dependencies)
39
- .filter(dep => dep.local)
40
- .map(dep => dep.id)
41
-
42
- topo.add(application, {
43
- group: application.id,
44
- after: localDependencyIds,
45
- manual: true
46
- })
47
- }
48
-
49
- config.applications = topo.sort()
50
-
51
- return new RoundRobinMap(
52
- Array.from(workers.entries()).sort((a, b) => {
53
- if (a[0] === b[0]) {
54
- return 0
55
- }
56
-
57
- const aIndex = config.applications.findIndex(s => s.id === a[0])
58
- const bIndex = config.applications.findIndex(s => s.id === b[0])
59
- return aIndex - bIndex
60
- }),
61
- workers.configuration
62
- )
63
- }