@platformatic/runtime 2.6.1 → 2.8.0-alpha.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/lib/runtime.js CHANGED
@@ -18,13 +18,25 @@ const { startManagementApi } = require('./management-api')
18
18
  const { startPrometheusServer } = require('./prom-server')
19
19
  const { getRuntimeTmpDir } = require('./utils')
20
20
  const { sendViaITC, waitEventFromITC } = require('./worker/itc')
21
- const { kId, kITC, kConfig } = require('./worker/symbols')
22
-
23
- const Fastify = require('fastify')
21
+ const { RoundRobinMap } = require('./worker/round-robin-map.js')
22
+ const {
23
+ kId,
24
+ kServiceId,
25
+ kWorkerId,
26
+ kITC,
27
+ kConfig,
28
+ kLoggerDestination,
29
+ kLoggingPort,
30
+ kWorkerStatus
31
+ } = require('./worker/symbols')
32
+
33
+ const fastify = require('fastify')
24
34
 
25
35
  const platformaticVersion = require('../package.json').version
26
36
  const kWorkerFile = join(__dirname, 'worker/main.js')
27
37
 
38
+ const kInspectorOptions = Symbol('plt.runtime.worker.inspectorOptions')
39
+
28
40
  const MAX_LISTENERS_COUNT = 100
29
41
  const MAX_METRICS_QUEUE_LENGTH = 5 * 60 // 5 minutes in seconds
30
42
  const COLLECT_METRICS_TIMEOUT = 1000
@@ -33,12 +45,11 @@ const MAX_BOOTSTRAP_ATTEMPTS = 5
33
45
 
34
46
  class Runtime extends EventEmitter {
35
47
  #configManager
48
+ #isProduction
36
49
  #runtimeTmpDir
37
50
  #runtimeLogsDir
38
51
  #env
39
- #services
40
52
  #servicesIds
41
- #entrypoint
42
53
  #entrypointId
43
54
  #url
44
55
  #loggerDestination
@@ -48,11 +59,9 @@ class Runtime extends EventEmitter {
48
59
  #interceptor
49
60
  #managementApi
50
61
  #prometheusServer
51
- #startedServices
52
- #restartPromises
53
- #bootstrapAttempts
54
- #inspectors
55
62
  #inspectorServer
63
+ #workers
64
+ #restartingWorkers
56
65
 
57
66
  constructor (configManager, runtimeLogsDir, env) {
58
67
  super()
@@ -62,16 +71,13 @@ class Runtime extends EventEmitter {
62
71
  this.#runtimeTmpDir = getRuntimeTmpDir(configManager.dirname)
63
72
  this.#runtimeLogsDir = runtimeLogsDir
64
73
  this.#env = env
65
- this.#services = new Map()
74
+ this.#workers = new RoundRobinMap()
66
75
  this.#servicesIds = []
67
76
  this.#url = undefined
68
77
  // Note: nothing hits the main thread so there is no reason to set the globalDispatcher here
69
78
  this.#interceptor = createThreadInterceptor({ domain: '.plt.local', timeout: true })
70
79
  this.#status = undefined
71
- this.#startedServices = new Map()
72
- this.#restartPromises = new Map()
73
- this.#bootstrapAttempts = new Map()
74
- this.#inspectors = []
80
+ this.#restartingWorkers = new Map()
75
81
  }
76
82
 
77
83
  async init () {
@@ -94,19 +100,21 @@ class Runtime extends EventEmitter {
94
100
  this.logger = logger
95
101
  this.#loggerDestination = destination
96
102
 
103
+ this.#isProduction = this.#configManager.args?.production ?? false
104
+ this.#servicesIds = config.services.map(service => service.id)
105
+ this.#workers.configure(config.services, this.#configManager.current.workers, this.#isProduction)
106
+
97
107
  // Create all services, each in is own worker thread
98
108
  for (const serviceConfig of config.services) {
99
- // Setup forwarding of logs from the worker threads to the main thread
100
109
  await this.#setupService(serviceConfig)
101
110
  }
102
111
 
103
112
  try {
104
113
  // Make sure the list exists before computing the dependencies, otherwise some services might not be stopped
105
- this.#servicesIds = config.services.map(service => service.id)
106
114
 
107
115
  if (autoloadEnabled) {
108
116
  checkDependencies(config.services)
109
- this.#services = topologicalSort(this.#services, config)
117
+ this.#workers = topologicalSort(this.#workers, config)
110
118
  }
111
119
 
112
120
  // Recompute the list of services after sorting
@@ -119,7 +127,7 @@ class Runtime extends EventEmitter {
119
127
  this.#updateStatus('init')
120
128
  }
121
129
 
122
- async start () {
130
+ async start (silent = false) {
123
131
  if (typeof this.#configManager.current.entrypoint === 'undefined') {
124
132
  throw new errors.MissingEntrypointError()
125
133
  }
@@ -128,33 +136,41 @@ class Runtime extends EventEmitter {
128
136
  // Important: do not use Promise.all here since it won't properly manage dependencies
129
137
  try {
130
138
  for (const service of this.#servicesIds) {
131
- await this.startService(service)
139
+ await this.startService(service, silent)
132
140
  }
133
141
 
134
142
  if (this.#configManager.current.inspectorOptions) {
135
143
  const { port } = this.#configManager.current.inspectorOptions
136
144
 
137
- const server = Fastify({
145
+ const server = fastify({
138
146
  loggerInstance: this.logger.child({ name: 'inspector' }, { level: 'warn' })
139
147
  })
140
148
 
141
- const version = await fetch(`http://127.0.0.1:${this.#configManager.current.inspectorOptions.port + 1}/json/version`).then((res) => res.json())
142
-
143
- const data = (await Promise.all(this.#inspectors.map(async (data) => {
144
- const res = await fetch(`http://127.0.0.1:${data.port}/json/list`)
145
- const details = await res.json()
146
- return {
147
- ...details[0],
148
- title: data.id
149
- }
150
- })))
149
+ const version = await fetch(
150
+ `http://127.0.0.1:${this.#configManager.current.inspectorOptions.port + 1}/json/version`
151
+ ).then(res => res.json())
152
+
153
+ const data = await Promise.all(
154
+ Array.from(this.#workers.values()).map(async worker => {
155
+ const data = worker[kInspectorOptions]
156
+
157
+ const res = await fetch(`http://127.0.0.1:${data.port}/json/list`)
158
+ const details = await res.json()
159
+ return {
160
+ ...details[0],
161
+ title: data.id
162
+ }
163
+ })
164
+ )
151
165
 
152
166
  server.get('/json/list', () => data)
153
167
  server.get('/json', () => data)
154
168
  server.get('/json/version', () => version)
155
169
 
156
170
  await server.listen({ port })
157
- this.logger.info('The inspector server is now listening for all services. Open `chrome://inspect` in Google Chrome to connect.')
171
+ this.logger.info(
172
+ 'The inspector server is now listening for all services. Open `chrome://inspect` in Google Chrome to connect.'
173
+ )
158
174
  this.#inspectorServer = server
159
175
  }
160
176
  } catch (error) {
@@ -180,13 +196,14 @@ class Runtime extends EventEmitter {
180
196
  }
181
197
 
182
198
  this.#updateStatus('stopping')
183
- this.#startedServices.clear()
184
199
 
185
200
  if (this.#inspectorServer) {
186
201
  await this.#inspectorServer.close()
187
202
  }
188
203
 
189
- await Promise.all(this.#servicesIds.map(service => this._stopService(service, silent)))
204
+ for (const service of this.#servicesIds) {
205
+ await this.stopService(service, silent)
206
+ }
190
207
 
191
208
  this.#updateStatus('stopped')
192
209
  }
@@ -238,102 +255,45 @@ class Runtime extends EventEmitter {
238
255
  this.#updateStatus('closed')
239
256
  }
240
257
 
241
- async startService (id) {
242
- if (this.#startedServices.get(id)) {
258
+ async startService (id, silent) {
259
+ // Since when a service is stopped the worker is deleted, we consider a service start if its first service
260
+ // is no longer in the init phase
261
+ const firstWorker = this.#workers.get(`${id}:0`)
262
+ if (firstWorker && firstWorker[kWorkerStatus] !== 'boot' && firstWorker[kWorkerStatus] !== 'init') {
243
263
  throw new errors.ApplicationAlreadyStartedError()
244
264
  }
245
265
 
246
- // This is set here so that if the service fails while starting we track the status
247
- this.#startedServices.set(id, true)
248
-
249
- let service = await this.#getServiceById(id, false, false)
250
-
251
- // The service was stopped, recreate the thread
252
- if (!service) {
253
- const config = this.#configManager.current
254
- const serviceConfig = config.services.find(s => s.id === id)
266
+ const config = this.#configManager.current
267
+ const serviceConfig = config.services.find(s => s.id === id)
255
268
 
256
- await this.#setupService(serviceConfig)
257
- service = await this.#getServiceById(id)
269
+ if (!serviceConfig) {
270
+ throw new errors.ServiceNotFoundError(id, Array.from(this.#servicesIds).join(', '))
258
271
  }
259
272
 
260
- try {
261
- const serviceUrl = await sendViaITC(service, 'start')
262
- if (serviceUrl) {
263
- this.#url = serviceUrl
264
- }
265
- this.#bootstrapAttempts.set(id, 0)
266
- } catch (error) {
267
- // TODO: handle port allocation error here
268
- if (error.code === 'EADDRINUSE') throw error
273
+ const workersCount = await this.#workers.getCount(serviceConfig.id)
269
274
 
270
- this.logger.error({ err: ensureLoggableError(error) }, `Failed to start service "${id}".`)
271
-
272
- const config = this.#configManager.current
273
- const restartOnError = config.restartOnError
274
-
275
- if (!restartOnError) {
276
- this.logger.error(`Failed to start service "${id}".`)
277
- throw error
278
- }
279
-
280
- let bootstrapAttempt = this.#bootstrapAttempts.get(id)
281
- if (bootstrapAttempt++ >= MAX_BOOTSTRAP_ATTEMPTS || restartOnError === 0) {
282
- this.logger.error(`Failed to start service "${id}" after ${MAX_BOOTSTRAP_ATTEMPTS} attempts.`)
283
- throw error
284
- }
285
-
286
- this.logger.warn(
287
- `Starting a service "${id}" in ${restartOnError}ms. ` +
288
- `Attempt ${bootstrapAttempt} of ${MAX_BOOTSTRAP_ATTEMPTS}...`
289
- )
290
-
291
- this.#bootstrapAttempts.set(id, bootstrapAttempt)
292
- await this.#restartCrashedService(id)
275
+ for (let i = 0; i < workersCount; i++) {
276
+ await this.#startWorker(config, serviceConfig, workersCount, id, i, silent)
293
277
  }
294
278
  }
295
279
 
296
- // Do not rename to #stopService as this is used in tests
297
- async _stopService (id, silent) {
298
- const service = await this.#getServiceById(id, false, false)
299
-
300
- if (!service) {
301
- return
302
- }
303
-
304
- this.#startedServices.set(id, false)
305
-
306
- if (!silent) {
307
- this.logger?.info(`Stopping service "${id}"...`)
308
- }
280
+ async stopService (id, silent) {
281
+ const config = this.#configManager.current
282
+ const serviceConfig = config.services.find(s => s.id === id)
309
283
 
310
- // Always send the stop message, it will shut down workers that only had ITC and interceptors setup
311
- try {
312
- await executeWithTimeout(sendViaITC(service, 'stop'), 10000)
313
- } catch (error) {
314
- this.logger?.info(
315
- { error: ensureLoggableError(error) },
316
- `Failed to stop service "${id}". Killing a worker thread.`
317
- )
318
- } finally {
319
- service[kITC].close()
284
+ if (!serviceConfig) {
285
+ throw new errors.ServiceNotFoundError(id, Array.from(this.#servicesIds).join(', '))
320
286
  }
321
287
 
322
- // Wait for the worker thread to finish, we're going to create a new one if the service is ever restarted
323
- const res = await executeWithTimeout(once(service, 'exit'), 10000)
288
+ const workersCount = await this.#workers.getCount(serviceConfig.id)
324
289
 
325
- // If the worker didn't exit in time, kill it
326
- if (res === 'timeout') {
327
- await service.terminate()
290
+ for (let i = 0; i < workersCount; i++) {
291
+ await this.#stopWorker(workersCount, id, i, silent)
328
292
  }
329
293
  }
330
294
 
331
295
  async buildService (id) {
332
- const service = this.#services.get(id)
333
-
334
- if (!service) {
335
- throw new errors.ServiceNotFoundError(id, Array.from(this.#services.keys()).join(', '))
336
- }
296
+ const service = await this.#getServiceById(id)
337
297
 
338
298
  try {
339
299
  return await sendViaITC(service, 'build')
@@ -511,6 +471,7 @@ class Runtime extends EventEmitter {
511
471
  async getServices () {
512
472
  return {
513
473
  entrypoint: this.#entrypointId,
474
+ production: this.#isProduction,
514
475
  services: await Promise.all(this.#servicesIds.map(id => this.getServiceDetails(id)))
515
476
  }
516
477
  }
@@ -543,6 +504,10 @@ class Runtime extends EventEmitter {
543
504
  dependencies
544
505
  }
545
506
 
507
+ if (this.#isProduction) {
508
+ serviceDetails.workers = this.#workers.getCount(id)
509
+ }
510
+
546
511
  if (entrypoint) {
547
512
  serviceDetails.url = status === 'started' ? this.#url : null
548
513
  }
@@ -581,16 +546,14 @@ class Runtime extends EventEmitter {
581
546
  async getMetrics (format = 'json') {
582
547
  let metrics = null
583
548
 
584
- for (const id of this.#servicesIds) {
549
+ for (const worker of this.#workers.values()) {
585
550
  try {
586
- const service = await this.#getServiceById(id, true, false)
587
-
588
551
  // The service might be temporarily unavailable
589
- if (!service) {
552
+ if (worker[kWorkerStatus] !== 'started') {
590
553
  continue
591
554
  }
592
555
 
593
- const serviceMetrics = await sendViaITC(service, 'getMetrics', format)
556
+ const serviceMetrics = await sendViaITC(worker, 'getMetrics', format)
594
557
  if (serviceMetrics) {
595
558
  if (metrics === null) {
596
559
  metrics = format === 'json' ? [] : ''
@@ -699,11 +662,7 @@ class Runtime extends EventEmitter {
699
662
  }
700
663
 
701
664
  async getServiceMeta (id) {
702
- const service = this.#services.get(id)
703
-
704
- if (!service) {
705
- throw new errors.ServiceNotFoundError(id, Array.from(this.#services.keys()).join(', '))
706
- }
665
+ const service = await this.#getServiceById(id)
707
666
 
708
667
  try {
709
668
  return await sendViaITC(service, 'getServiceMeta')
@@ -768,16 +727,21 @@ class Runtime extends EventEmitter {
768
727
  if (this.#status === 'stopping' || this.#status === 'closed') return
769
728
 
770
729
  const config = this.#configManager.current
730
+ const workersCount = await this.#workers.getCount(serviceConfig.id)
731
+ const id = serviceConfig.id
732
+
733
+ for (let i = 0; i < workersCount; i++) {
734
+ await this.#setupWorker(config, serviceConfig, workersCount, id, i)
735
+ }
736
+ }
737
+
738
+ async #setupWorker (config, serviceConfig, workersCount, serviceId, index) {
771
739
  const { autoload, restartOnError } = config
740
+ const workerId = `${serviceId}:${index}`
772
741
 
773
- const id = serviceConfig.id
774
742
  const { port1: loggerDestination, port2: loggingPort } = new MessageChannel()
775
743
  loggerDestination.on('message', this.#forwardThreadLog.bind(this))
776
744
 
777
- if (!this.#bootstrapAttempts.has(id)) {
778
- this.#bootstrapAttempts.set(id, 0)
779
- }
780
-
781
745
  // Handle inspector
782
746
  let inspectorOptions
783
747
 
@@ -786,23 +750,20 @@ class Runtime extends EventEmitter {
786
750
  ...this.#configManager.current.inspectorOptions
787
751
  }
788
752
 
789
- inspectorOptions.port = inspectorOptions.port + this.#inspectors.length + 1
790
-
791
- const inspectorData = {
792
- port: inspectorOptions.port,
793
- id,
794
- dirname: this.#configManager.dirname
795
- }
796
-
797
- this.#inspectors.push(inspectorData)
753
+ inspectorOptions.port = inspectorOptions.port + this.#workers.size + 1
798
754
  }
799
755
 
800
- const service = new Worker(kWorkerFile, {
756
+ const worker = new Worker(kWorkerFile, {
801
757
  workerData: {
802
758
  config,
803
759
  serviceConfig: {
804
760
  ...serviceConfig,
805
- isProduction: this.#configManager.args?.production ?? false
761
+ isProduction: this.#isProduction
762
+ },
763
+ worker: {
764
+ id: workerId,
765
+ index,
766
+ count: workersCount
806
767
  },
807
768
  inspectorOptions,
808
769
  dirname: this.#configManager.dirname,
@@ -824,89 +785,112 @@ class Runtime extends EventEmitter {
824
785
  })
825
786
 
826
787
  // Make sure the listener can handle a lot of API requests at once before raising a warning
827
- service.setMaxListeners(1e3)
788
+ worker.setMaxListeners(1e3)
828
789
 
829
790
  // Track service exiting
830
- service.once('exit', code => {
831
- const started = this.#startedServices.get(id)
832
- this.#services.delete(id)
833
- loggerDestination.close()
834
- service[kITC].close()
835
- loggingPort.close()
791
+ worker.once('exit', code => {
792
+ if (worker[kWorkerStatus] === 'exited') {
793
+ return
794
+ }
795
+
796
+ const started = worker[kWorkerStatus] === 'started'
797
+ worker[kWorkerStatus] = 'exited'
798
+
799
+ this.#cleanupWorker(workerId, worker)
836
800
 
837
- if (this.#status === 'stopping') return
801
+ if (this.#status === 'stopping') {
802
+ return
803
+ }
838
804
 
839
805
  // Wait for the next tick so that crashed from the thread are logged first
840
806
  setImmediate(() => {
841
- if (!config.watch || code !== 0) {
842
- this.logger.warn(`Service "${id}" unexpectedly exited with code ${code}.`)
807
+ const errorLabel = workersCount > 1 ? `worker ${index} of the service "${serviceId}"` : `service "${serviceId}"`
808
+
809
+ if (started && (!config.watch || code !== 0)) {
810
+ this.logger.warn(`The ${errorLabel} unexpectedly exited with code ${code}.`)
843
811
  }
844
812
 
845
813
  // Restart the service if it was started
846
814
  if (started && this.#status === 'started') {
847
815
  if (restartOnError > 0) {
848
- this.logger.warn(`Restarting a service "${id}" in ${restartOnError}ms...`)
849
- this.#restartCrashedService(id).catch(err => {
850
- this.logger.error({ err: ensureLoggableError(err) }, `Failed to restart service "${id}".`)
816
+ this.logger.warn(`The ${errorLabel} will be restarted in ${restartOnError}ms...`)
817
+ this.#restartCrashedWorker(config, serviceConfig, workersCount, serviceId, index, false, 0).catch(err => {
818
+ this.logger.error({ err: ensureLoggableError(err) }, `${errorLabel} could not be restarted.`)
851
819
  })
852
820
  } else {
853
- this.logger.warn(`The "${id}" service is no longer available.`)
821
+ this.logger.warn(`The ${errorLabel} is no longer available.`)
854
822
  }
855
823
  }
856
824
  })
857
825
  })
858
826
 
859
- service[kId] = id
860
- service[kConfig] = serviceConfig
827
+ worker[kId] = workersCount > 1 ? workerId : serviceId
828
+ worker[kServiceId] = serviceId
829
+ worker[kWorkerId] = workersCount > 1 ? index : undefined
830
+ worker[kConfig] = serviceConfig
831
+ worker[kLoggerDestination] = loggerDestination
832
+ worker[kLoggingPort] = loggingPort
833
+
834
+ if (inspectorOptions) {
835
+ worker[kInspectorOptions] = {
836
+ port: inspectorOptions.port,
837
+ id: serviceId,
838
+ dirname: this.#configManager.dirname
839
+ }
840
+ }
861
841
 
862
842
  // Setup ITC
863
- service[kITC] = new ITC({
864
- name: id + '-runtime',
865
- port: service,
843
+ worker[kITC] = new ITC({
844
+ name: workerId + '-runtime',
845
+ port: worker,
866
846
  handlers: {
867
847
  getServiceMeta: this.getServiceMeta.bind(this),
868
- getServices: this.getServices.bind(this)
848
+ listServices: () => {
849
+ return this.#servicesIds
850
+ }
869
851
  }
870
852
  })
871
- service[kITC].listen()
872
-
873
- // Handle services changes
874
- // This is not purposely activated on when this.#configManager.current.watch === true
875
- // so that services can eventually manually trigger a restart. This mechanism is current
876
- // used by the composer
877
- service[kITC].on('changed', async () => {
878
- try {
879
- const wasStarted = this.#startedServices.get(id)
880
-
881
- await this._stopService(id)
853
+ worker[kITC].listen()
854
+
855
+ // Only activate watch for the first instance
856
+ if (index === 0) {
857
+ // Handle services changes
858
+ // This is not purposely activated on when this.#configManager.current.watch === true
859
+ // so that services can eventually manually trigger a restart. This mechanism is current
860
+ // used by the composer.
861
+ worker[kITC].on('changed', async () => {
862
+ try {
863
+ const wasStarted = worker[kWorkerStatus].startsWith('start')
864
+ await this.stopService(serviceId)
882
865
 
883
- if (wasStarted) {
884
- await this.startService(id)
885
- }
866
+ if (wasStarted) {
867
+ await this.startService(serviceId)
868
+ }
886
869
 
887
- this.logger?.info(`Service ${id} has been successfully reloaded ...`)
870
+ this.logger?.info(`Service "${serviceId}" has been successfully reloaded ...`)
888
871
 
889
- if (serviceConfig.entrypoint) {
890
- this.#showUrl()
872
+ if (serviceConfig.entrypoint) {
873
+ this.#showUrl()
874
+ }
875
+ } catch (e) {
876
+ this.logger?.error(e)
891
877
  }
892
- } catch (e) {
893
- this.logger?.error(e)
894
- }
895
- })
878
+ })
879
+ }
896
880
 
897
881
  // Store locally
898
- this.#services.set(id, service)
882
+ this.#workers.set(workerId, worker)
899
883
 
900
884
  if (serviceConfig.entrypoint) {
901
- this.#entrypoint = service
902
- this.#entrypointId = id
885
+ this.#entrypointId = serviceId
903
886
  }
904
887
 
905
888
  // Setup the interceptor
906
- this.#interceptor.route(id, service)
889
+ this.#interceptor.route(serviceId, worker)
907
890
 
908
891
  // Store dependencies
909
- const [{ dependencies }] = await waitEventFromITC(service, 'init')
892
+ const [{ dependencies }] = await waitEventFromITC(worker, 'init')
893
+ worker[kWorkerStatus] = 'boot'
910
894
 
911
895
  if (autoload) {
912
896
  serviceConfig.dependencies = dependencies
@@ -918,11 +902,121 @@ class Runtime extends EventEmitter {
918
902
  }
919
903
  }
920
904
 
921
- async #restartCrashedService (id) {
922
- const config = this.#configManager.current
923
- const serviceConfig = config.services.find(s => s.id === id)
905
+ async #startWorker (config, serviceConfig, workersCount, id, index, silent, bootstrapAttempt = 0) {
906
+ const workerId = `${id}:${index}`
907
+ const label = workersCount > 1 ? `worker ${index} of the service "${id}"` : `service "${id}"`
924
908
 
925
- let restartPromise = this.#restartPromises.get(id)
909
+ if (!silent) {
910
+ this.logger?.info(`Starting the ${label}...`)
911
+ }
912
+
913
+ let worker = await this.#getWorkerById(id, index, false, false)
914
+
915
+ // The service was stopped, recreate the thread
916
+ if (!worker) {
917
+ await this.#setupService(serviceConfig, index)
918
+ worker = await this.#getWorkerById(id, index)
919
+ }
920
+
921
+ worker[kWorkerStatus] = 'starting'
922
+
923
+ try {
924
+ const workerUrl = await sendViaITC(worker, 'start')
925
+ if (workerUrl) {
926
+ this.#url = workerUrl
927
+ }
928
+
929
+ worker[kWorkerStatus] = 'started'
930
+
931
+ if (!silent) {
932
+ this.logger?.info(`Started the ${label}...`)
933
+ }
934
+ } catch (error) {
935
+ // TODO: handle port allocation error here
936
+ if (error.code === 'EADDRINUSE') throw error
937
+
938
+ this.#cleanupWorker(workerId, worker)
939
+
940
+ if (worker[kWorkerStatus] !== 'exited') {
941
+ // This prevent the exit handler to restart service
942
+ worker[kWorkerStatus] = 'exited'
943
+ await worker.terminate()
944
+ }
945
+
946
+ this.logger.error({ err: ensureLoggableError(error) }, `Failed to start ${label}.`)
947
+
948
+ const restartOnError = config.restartOnError
949
+
950
+ if (!restartOnError) {
951
+ throw error
952
+ }
953
+
954
+ if (bootstrapAttempt++ >= MAX_BOOTSTRAP_ATTEMPTS || restartOnError === 0) {
955
+ this.logger.error(`Failed to start ${label} after ${MAX_BOOTSTRAP_ATTEMPTS} attempts.`)
956
+ throw error
957
+ }
958
+
959
+ this.logger.warn(
960
+ `Attempt ${bootstrapAttempt} of ${MAX_BOOTSTRAP_ATTEMPTS} to start the ${label} again will be performed in ${restartOnError}ms ...`
961
+ )
962
+
963
+ await this.#restartCrashedWorker(config, serviceConfig, workersCount, id, index, silent, bootstrapAttempt)
964
+ }
965
+ }
966
+
967
+ async #stopWorker (workersCount, id, index, silent) {
968
+ const worker = await this.#getWorkerById(id, index, false, false)
969
+
970
+ if (!worker) {
971
+ return
972
+ }
973
+
974
+ worker[kWorkerStatus] = 'stopping'
975
+
976
+ const label = workersCount > 1 ? `worker ${index} of the service "${id}"` : `service "${id}"`
977
+
978
+ if (!silent) {
979
+ this.logger?.info(`Stopping the ${label}...`)
980
+ }
981
+
982
+ const exitPromise = once(worker, 'exit')
983
+
984
+ // Always send the stop message, it will shut down workers that only had ITC and interceptors setup
985
+ try {
986
+ await executeWithTimeout(sendViaITC(worker, 'stop'), 10000)
987
+ } catch (error) {
988
+ this.logger?.info({ error: ensureLoggableError(error) }, `Failed to stop ${label}. Killing a worker thread.`)
989
+ } finally {
990
+ worker[kITC].close()
991
+ }
992
+
993
+ if (!silent) {
994
+ this.logger?.info(`Stopped the ${label}...`)
995
+ }
996
+
997
+ // Wait for the worker thread to finish, we're going to create a new one if the service is ever restarted
998
+ const res = await executeWithTimeout(exitPromise, 10000)
999
+
1000
+ // If the worker didn't exit in time, kill it
1001
+ if (res === 'timeout') {
1002
+ await worker.terminate()
1003
+ }
1004
+
1005
+ worker[kWorkerStatus] = 'stopped'
1006
+ }
1007
+
1008
+ #cleanupWorker (workerId, worker) {
1009
+ this.#workers.delete(workerId)
1010
+
1011
+ worker[kITC].close()
1012
+ worker[kLoggerDestination].close()
1013
+ worker[kLoggingPort].close()
1014
+ }
1015
+
1016
+ async #restartCrashedWorker (config, serviceConfig, workersCount, id, index, silent, bootstrapAttempt) {
1017
+ const workerId = `${id}:${index}`
1018
+
1019
+ let restartPromise = this.#restartingWorkers.get(workerId)
926
1020
  if (restartPromise) {
927
1021
  await restartPromise
928
1022
  return
@@ -930,48 +1024,67 @@ class Runtime extends EventEmitter {
930
1024
 
931
1025
  restartPromise = new Promise((resolve, reject) => {
932
1026
  setTimeout(async () => {
933
- this.#restartPromises.delete(id)
1027
+ this.#restartingWorkers.delete(workerId)
934
1028
 
935
1029
  try {
936
- await this.#setupService(serviceConfig)
937
-
938
- const started = this.#startedServices.get(id)
939
- if (started) {
940
- this.#startedServices.set(id, false)
941
- await this.startService(id)
942
- }
1030
+ await this.#setupWorker(config, serviceConfig, workersCount, id, index)
1031
+ await this.#startWorker(config, serviceConfig, workersCount, id, index, silent, bootstrapAttempt)
943
1032
 
944
1033
  resolve()
945
1034
  } catch (err) {
1035
+ // The runtime was stopped while the restart was happening, ignore any error.
1036
+ if (!this.#status.startsWith('start')) {
1037
+ resolve()
1038
+ }
1039
+
946
1040
  reject(err)
947
1041
  }
948
1042
  }, config.restartOnError)
949
1043
  })
950
1044
 
951
- this.#restartPromises.set(id, restartPromise)
1045
+ this.#restartingWorkers.set(workerId, restartPromise)
952
1046
  await restartPromise
953
1047
  }
954
1048
 
955
- async #getServiceById (id, ensureStarted = false, mustExist = true) {
956
- const service = this.#services.get(id)
1049
+ async #getServiceById (serviceId, ensureStarted = false, mustExist = true) {
1050
+ // If the serviceId includes the worker, properly split
1051
+ let workerId
1052
+ const matched = serviceId.match(/^(.+):(\d+)$/)
1053
+
1054
+ if (matched) {
1055
+ serviceId = matched[1]
1056
+ workerId = matched[2]
1057
+ }
1058
+
1059
+ return this.#getWorkerById(serviceId, workerId, ensureStarted, mustExist)
1060
+ }
1061
+
1062
+ async #getWorkerById (serviceId, workerId, ensureStarted = false, mustExist = true) {
1063
+ let worker
1064
+
1065
+ if (typeof workerId !== 'undefined') {
1066
+ worker = this.#workers.get(`${serviceId}:${workerId}`)
1067
+ } else {
1068
+ worker = this.#workers.next(serviceId)
1069
+ }
957
1070
 
958
- if (!service) {
959
- if (!mustExist && this.#servicesIds.includes(id)) {
1071
+ if (!worker) {
1072
+ if (!mustExist && this.#servicesIds.includes(serviceId)) {
960
1073
  return null
961
1074
  }
962
1075
 
963
- throw new errors.ServiceNotFoundError(id, Array.from(this.#services.keys()).join(', '))
1076
+ throw new errors.ServiceNotFoundError(serviceId, Array.from(this.#servicesIds).join(', '))
964
1077
  }
965
1078
 
966
1079
  if (ensureStarted) {
967
- const serviceStatus = await sendViaITC(service, 'getStatus')
1080
+ const serviceStatus = await sendViaITC(worker, 'getStatus')
968
1081
 
969
1082
  if (serviceStatus !== 'started') {
970
- throw new errors.ServiceNotStartedError(id)
1083
+ throw new errors.ServiceNotStartedError(serviceId)
971
1084
  }
972
1085
  }
973
1086
 
974
- return service
1087
+ return worker
975
1088
  }
976
1089
 
977
1090
  async #getRuntimePackageJson () {