@platformatic/runtime 2.70.0 → 2.71.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/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 HttpsSchemasPlatformaticDevPlatformaticRuntime2700Json = {
8
+ export type HttpsSchemasPlatformaticDevPlatformaticRuntime2710Alpha1Json = {
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(`${ERROR_PREFIX}_SERVICE_EXIT`, 'The service "%s" exited prematurely with error code %d'),
13
- WorkerExitedError: createError(`${ERROR_PREFIX}_SERVICE_EXIT`, 'The worker %s of the service "%s" exited prematurely with error code %d'),
14
- UnknownRuntimeAPICommandError: createError(`${ERROR_PREFIX}_UNKNOWN_RUNTIME_API_COMMAND`, 'Unknown Runtime API command "%s"'),
15
- ServiceNotFoundError: createError(`${ERROR_PREFIX}_SERVICE_NOT_FOUND`, 'Service %s not found. Available services are: %s'),
16
- ServiceWorkerNotFoundError: createError(`${ERROR_PREFIX}_SERVICE_WORKER_NOT_FOUND`, 'Service %s worker %s not found'),
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(`${ERROR_PREFIX}_SERVICE_START_TIMEOUT`, "Service with id '%s' failed to start in %dms."),
19
- FailedToRetrieveOpenAPISchemaError: createError(`${ERROR_PREFIX}_FAILED_TO_RETRIEVE_OPENAPI_SCHEMA`, 'Failed to retrieve OpenAPI schema for service with id "%s": %s'),
20
- FailedToRetrieveGraphQLSchemaError: createError(`${ERROR_PREFIX}_FAILED_TO_RETRIEVE_GRAPHQL_SCHEMA`, 'Failed to retrieve GraphQL schema for service with id "%s": %s'),
21
- FailedToRetrieveMetaError: createError(`${ERROR_PREFIX}_FAILED_TO_RETRIEVE_META`, 'Failed to retrieve metadata for service with id "%s": %s'),
22
- FailedToRetrieveMetricsError: createError(`${ERROR_PREFIX}_FAILED_TO_RETRIEVE_METRICS`, 'Failed to retrieve metrics for service with id "%s": %s'),
23
- FailedToRetrieveHealthError: createError(`${ERROR_PREFIX}_FAILED_TO_RETRIEVE_HEALTH`, 'Failed to retrieve health for service with id "%s": %s'),
24
- FailedToPerformCustomHealthCheckError: createError(`${ERROR_PREFIX}_FAILED_TO_PERFORM_CUSTOM_HEALTH_CHECK`, 'Failed to perform custom healthcheck for service with id "%s": %s'),
25
- FailedToPerformCustomReadinessCheckError: createError(`${ERROR_PREFIX}_FAILED_TO_PERFORM_CUSTOM_READINESS_CHECK`, 'Failed to perform custom readiness check for service with id "%s": %s'),
26
- ApplicationAlreadyStartedError: createError(`${ERROR_PREFIX}_APPLICATION_ALREADY_STARTED`, 'Application is already started'),
27
- ApplicationNotStartedError: createError(`${ERROR_PREFIX}_APPLICATION_NOT_STARTED`, 'Application has not been started'),
28
- ConfigPathMustBeStringError: createError(`${ERROR_PREFIX}_CONFIG_PATH_MUST_BE_STRING`, 'Config path must be a string'),
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(`${ERROR_PREFIX}_INVALID_SERVICES_WITH_WEB`, 'The "services" property cannot be used when the "web" property is also defined'),
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(`${ERROR_PREFIX}_INSPECT_AND_INSPECT_BRK`, '--inspect and --inspect-brk cannot be used together'),
35
- InspectorPortError: createError(`${ERROR_PREFIX}_INSPECTOR_PORT`, 'Inspector port must be 0 or in range 1024 to 65535'),
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(`${ERROR_PREFIX}_CANNOT_MAP_SPECIFIER_TO_ABSOLUTE_PATH`, 'Cannot map "%s" to an absolute path'),
38
- NodeInspectorFlagsNotSupportedError: createError(`${ERROR_PREFIX}_NODE_INSPECTOR_FLAGS_NOT_SUPPORTED`, 'The Node.js inspector flags are not supported. Please use \'platformatic start --inspect\' instead.'),
39
- FailedToUnlinkManagementApiSocket: createError(`${ERROR_PREFIX}_FAILED_TO_UNLINK_MANAGEMENT_API_SOCKET`, 'Failed to unlink management API socket "%s"'),
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(`${ERROR_PREFIX}_CANNOT_REMOVE_SERVICE_ON_UPDATE`, 'Cannot remove service "%s" when updating a Runtime'),
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(`"${service.id}" is set as the entrypoint, but reusePort is not available in your OS; setting workers to 1 instead of ${count}`)
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
- getServiceMeta: this.getServiceMeta.bind(this),
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(`Service "${serviceId}" has been successfully reloaded ...`)
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.performance.eventLoopUtilization(currentELU, worker[kLastELU]).utilization
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 = 0
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 === 'timeout') {
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
- worker[kHealthCheckTimer] = setTimeout(
1477
- () => {
1478
- this.#setupHealthCheck(config, serviceConfig, workersCount, id, index, worker, label)
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 === 'timeout') {
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
- throw new errors.ServiceWorkerNotFoundError(serviceId, workerId)
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 = typeof message?.level === 'number' && typeof message?.time === 'number' && typeof message?.msg === 'string'
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 w = workerData.config.serviceMap.get(serviceId)
120
- if (w) { w.workers = workers }
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
 
@@ -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.0",
3
+ "version": "2.71.0-alpha.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.0",
41
- "@platformatic/db": "2.70.0",
42
- "@platformatic/node": "2.70.0",
43
- "@platformatic/service": "2.70.0",
44
- "@platformatic/sql-graphql": "2.70.0",
45
- "@platformatic/sql-mapper": "2.70.0"
40
+ "@platformatic/db": "2.71.0-alpha.1",
41
+ "@platformatic/node": "2.71.0-alpha.1",
42
+ "@platformatic/service": "2.71.0-alpha.1",
43
+ "@platformatic/sql-graphql": "2.71.0-alpha.1",
44
+ "@platformatic/composer": "2.71.0-alpha.1",
45
+ "@platformatic/sql-mapper": "2.71.0-alpha.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/basic": "2.70.0",
80
- "@platformatic/generators": "2.70.0",
81
- "@platformatic/metrics": "2.70.0",
82
- "@platformatic/itc": "2.70.0",
83
- "@platformatic/telemetry": "2.70.0",
84
- "@platformatic/ts-compiler": "2.70.0",
85
- "@platformatic/utils": "2.70.0",
86
- "@platformatic/config": "2.70.0"
79
+ "@platformatic/basic": "2.71.0-alpha.1",
80
+ "@platformatic/config": "2.71.0-alpha.1",
81
+ "@platformatic/generators": "2.71.0-alpha.1",
82
+ "@platformatic/metrics": "2.71.0-alpha.1",
83
+ "@platformatic/telemetry": "2.71.0-alpha.1",
84
+ "@platformatic/itc": "2.71.0-alpha.1",
85
+ "@platformatic/ts-compiler": "2.71.0-alpha.1",
86
+ "@platformatic/utils": "2.71.0-alpha.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=300000 test/*.test.js test/*.test.mjs test/versions/*.test.js test/versions/*.test.mjs",
91
- "test:api": "borp --concurrency=1 --timeout=300000 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=300000 test/cli/*.test.js test/cli/*.test.mjs test/cli/**/*.test.js test/cli/**/*.test.mjs",
93
- "test:start": "borp --concurrency=1 --timeout=300000 test/start/*.test.js test/start/*.test.mjs",
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=300000 && tsd",
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.0.json",
2
+ "$id": "https://schemas.platformatic.dev/@platformatic/runtime/2.71.0-alpha.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"