@platformatic/runtime 3.62.1 → 3.62.3

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
@@ -696,6 +696,10 @@ export type PlatformaticRuntimeConfig = {
696
696
  [k: string]: string;
697
697
  };
698
698
  envfile?: string;
699
+ /**
700
+ * When set to true, the configuration loading fails if a {PLT_*} placeholder references an environment variable which is not set. When set to "warn", a warning listing the missing variables is logged but the placeholders are still replaced with an empty string. Defaults to false.
701
+ */
702
+ strictEnv?: boolean | string;
699
703
  sourceMaps?: boolean;
700
704
  nodeModulesSourceMaps?: string[];
701
705
  scheduler?: {
package/lib/runtime.js CHANGED
@@ -1421,20 +1421,6 @@ export class Runtime extends EventEmitter {
1421
1421
  if (applicationMetrics && applicationMetrics !== kTimeout) {
1422
1422
  metrics ??= []
1423
1423
 
1424
- // Build worker labels including custom labels from metrics config
1425
- const workerLabels = {
1426
- ...this.#config.metrics?.labels,
1427
- [this.#metricsLabelName]: worker[kApplicationId]
1428
- }
1429
- const workerId = worker[kWorkerId]
1430
- if (workerId >= 0) {
1431
- workerLabels.workerId = workerId
1432
- }
1433
-
1434
- // Duplicate process metrics with worker labels and add to output
1435
- if (processMetricsJson) {
1436
- this.#applyLabelsToMetrics(processMetricsJson, workerLabels, metrics)
1437
- }
1438
1424
  // Add worker's thread-specific metrics
1439
1425
  for (let i = 0; i < applicationMetrics.length; i++) {
1440
1426
  metrics.push(applicationMetrics[i])
@@ -1454,6 +1440,24 @@ export class Runtime extends EventEmitter {
1454
1440
  }
1455
1441
  }
1456
1442
 
1443
+ // Report process-level metrics (e.g. process_resident_memory_bytes) only once:
1444
+ // they describe the whole runtime process, so replicating them for each
1445
+ // application running in a worker thread would just duplicate the same value.
1446
+ // Applications running as separate OS processes report their own process-level
1447
+ // metrics, with their own labels, as part of their thread metrics above.
1448
+ // See https://github.com/platformatic/platformatic/issues/3332.
1449
+ if (metrics !== null && processMetricsJson) {
1450
+ const processMetrics = []
1451
+ // Drop any configured custom label that shares the name of the application
1452
+ // label (a config can set both `applicationLabel: 'serviceId'` and a static
1453
+ // `serviceId` label): keeping it would make these runtime-wide metrics look
1454
+ // like they belong to an application, both here and in getFormattedMetrics().
1455
+ const processLabels = { ...this.#config.metrics?.labels }
1456
+ delete processLabels[this.#metricsLabelName]
1457
+ this.#applyLabelsToMetrics(processMetricsJson, processLabels, processMetrics)
1458
+ metrics = [...processMetrics, ...metrics]
1459
+ }
1460
+
1457
1461
  if (metrics !== null && applicationRestartMetrics.length > 0) {
1458
1462
  metrics = [...applicationRestartMetrics, ...metrics]
1459
1463
  }
@@ -1581,6 +1585,13 @@ export class Runtime extends EventEmitter {
1581
1585
 
1582
1586
  const applicationsMetrics = {}
1583
1587
 
1588
+ // Process-level metrics are reported only once for the whole runtime, without
1589
+ // an application label, since they are shared by all the applications running
1590
+ // in worker threads (see issue #3332). Applications running as separate OS
1591
+ // processes report their own labeled values, which take precedence below.
1592
+ const runtimeProcessMetrics = {}
1593
+ const applicationProcessMetrics = new Set()
1594
+
1584
1595
  for (const metric of metrics) {
1585
1596
  const { name, values } = metric
1586
1597
 
@@ -1592,9 +1603,22 @@ export class Runtime extends EventEmitter {
1592
1603
  const applicationId = labels?.[this.#metricsLabelName]
1593
1604
 
1594
1605
  if (!applicationId) {
1606
+ if (name === 'process_cpu_percent_usage') {
1607
+ runtimeProcessMetrics.cpu = values[0].value
1608
+ continue
1609
+ }
1610
+ if (name === 'process_resident_memory_bytes') {
1611
+ runtimeProcessMetrics.rss = values[0].value
1612
+ continue
1613
+ }
1614
+
1595
1615
  throw new Error(`Missing ${this.#metricsLabelName} label in metrics`)
1596
1616
  }
1597
1617
 
1618
+ if (name === 'process_cpu_percent_usage' || name === 'process_resident_memory_bytes') {
1619
+ applicationProcessMetrics.add(`${applicationId}:${name}`)
1620
+ }
1621
+
1598
1622
  let applicationMetrics = applicationsMetrics[applicationId]
1599
1623
  if (!applicationMetrics) {
1600
1624
  applicationMetrics = {
@@ -1618,6 +1642,24 @@ export class Runtime extends EventEmitter {
1618
1642
  parsePromMetric(applicationMetrics, metric)
1619
1643
  }
1620
1644
 
1645
+ // Apply the runtime-wide process-level values to every application that did
1646
+ // not report its own (i.e. every application running in a worker thread).
1647
+ for (const [applicationId, applicationMetrics] of Object.entries(applicationsMetrics)) {
1648
+ if (
1649
+ runtimeProcessMetrics.cpu !== undefined &&
1650
+ !applicationProcessMetrics.has(`${applicationId}:process_cpu_percent_usage`)
1651
+ ) {
1652
+ applicationMetrics.cpu = runtimeProcessMetrics.cpu
1653
+ }
1654
+
1655
+ if (
1656
+ runtimeProcessMetrics.rss !== undefined &&
1657
+ !applicationProcessMetrics.has(`${applicationId}:process_resident_memory_bytes`)
1658
+ ) {
1659
+ applicationMetrics.rss = runtimeProcessMetrics.rss
1660
+ }
1661
+ }
1662
+
1621
1663
  function parsePromMetric (applicationMetrics, promMetric) {
1622
1664
  const { name } = promMetric
1623
1665
 
@@ -2961,7 +3003,7 @@ export class Runtime extends EventEmitter {
2961
3003
  await executeWithTimeout(sendViaITC(worker, 'stop', { force: !!this.error, dependents }), exitTimeout)
2962
3004
  } catch (error) {
2963
3005
  this.emitAndNotify('application:worker:stop:error', eventPayload)
2964
- this.logger.info({ error: ensureLoggableError(error) }, `Failed to stop ${label}. Killing a worker thread.`)
3006
+ this.logger.error({ err: ensureLoggableError(error) }, `Failed to stop ${label}. Killing a worker thread.`)
2965
3007
  } finally {
2966
3008
  worker[kITC].notify('application:worker:stop:processed')
2967
3009
  // Wait for the processed message to be received
@@ -95,7 +95,8 @@ export class Controller extends EventEmitter {
95
95
  worker: workerData?.worker,
96
96
  resourceLimits: workerData?.resourceLimits,
97
97
  hasManagementApi: !!runtimeConfig.managementApi,
98
- fetchApplicationUrl: fetchApplicationUrl.bind(null, applicationConfig)
98
+ fetchApplicationUrl: fetchApplicationUrl.bind(null, applicationConfig),
99
+ strictEnv: runtimeConfig.strictEnv
99
100
  }
100
101
  }
101
102
 
@@ -141,7 +142,8 @@ export class Controller extends EventEmitter {
141
142
  if (appConfig.config) {
142
143
  // Parse the configuration file the first time to obtain the schema
143
144
  const unvalidatedConfig = await loadConfiguration(appConfig.config, null, {
144
- onMissingEnv: this.#context.fetchApplicationUrl
145
+ onMissingEnv: this.#context.fetchApplicationUrl,
146
+ strictEnv: this.#context.strictEnv
145
147
  })
146
148
  const pkg = await loadConfigurationModule(appConfig.path, unvalidatedConfig)
147
149
  this.capability = await pkg.create(appConfig.path, appConfig.config, this.#context)
package/lib/worker/itc.js CHANGED
@@ -183,7 +183,12 @@ export async function setupITC (controller, application, dispatcher, sharedConte
183
183
 
184
184
  // Reply to the runtime that the start failed, so it can cleanup
185
185
  once(itc, 'application:worker:start:processed').then(() => {
186
- closeITC(dispatcher, itc, messaging).catch(() => {})
186
+ closeITC(dispatcher, itc, messaging).catch(err => {
187
+ logger.error(
188
+ { err: ensureLoggableError(err) },
189
+ 'Failed to close the worker ITC after a failed start.'
190
+ )
191
+ })
187
192
  })
188
193
 
189
194
  throw ensureLoggableError(e)
@@ -199,23 +204,32 @@ export async function setupITC (controller, application, dispatcher, sharedConte
199
204
  },
200
205
 
201
206
  async stop ({ force, dependents }) {
202
- const status = controller.getStatus()
207
+ try {
208
+ const status = controller.getStatus()
203
209
 
204
- if (!force && status === 'starting') {
205
- await once(controller, 'start')
206
- }
210
+ if (!force && status === 'starting') {
211
+ await once(controller, 'start')
212
+ }
207
213
 
208
- if (force || status.startsWith('start')) {
209
- // This gives a chance to a capability to perform custom logic
210
- const events = getEvents()
211
- events.emit('stop')
214
+ if (force || status.startsWith('start')) {
215
+ // This gives a chance to a capability to perform custom logic
216
+ const events = getEvents()
217
+ events.emit('stop')
212
218
 
213
- await controller.stop(force, dependents)
219
+ await controller.stop(force, dependents)
220
+ }
221
+ } finally {
222
+ // Always schedule cleanup, even when stop throws. Otherwise the worker
223
+ // keeps open handles and runtime shutdown hangs until the grace timeout.
224
+ once(itc, 'application:worker:stop:processed').then(() => {
225
+ closeITC(dispatcher, itc, messaging).catch(err => {
226
+ logger.error(
227
+ { err: ensureLoggableError(err) },
228
+ 'Failed to close the worker ITC after stop.'
229
+ )
230
+ })
231
+ })
214
232
  }
215
-
216
- once(itc, 'application:worker:stop:processed').then(() => {
217
- closeITC(dispatcher, itc, messaging).catch(() => {})
218
- })
219
233
  },
220
234
 
221
235
  async getDependencies () {
@@ -364,6 +364,16 @@ async function main () {
364
364
 
365
365
  function stripBasePath (basePath) {
366
366
  const kBasePath = Symbol('kBasePath')
367
+ const absoluteUrlPattern = /^https?:\/\//i
368
+
369
+ function prependBasePath (value) {
370
+ // Absolute and protocol-relative URLs (RFC 7231 Location) must not be rewritten
371
+ if (typeof value !== 'string' || absoluteUrlPattern.test(value) || value.startsWith('//') || value.startsWith(basePath)) {
372
+ return value
373
+ }
374
+
375
+ return basePath + value
376
+ }
367
377
 
368
378
  subscribe('http.server.request.start', ({ request, response }) => {
369
379
  if (request.url.startsWith(basePath)) {
@@ -389,8 +399,8 @@ function stripBasePath (basePath) {
389
399
 
390
400
  if (headers) {
391
401
  for (const key in headers) {
392
- if (key.toLowerCase() === 'location' && !headers[key].startsWith(basePath)) {
393
- headers[key] = basePath + headers[key]
402
+ if (key.toLowerCase() === 'location') {
403
+ headers[key] = prependBasePath(headers[key])
394
404
  }
395
405
  }
396
406
  }
@@ -401,8 +411,8 @@ function stripBasePath (basePath) {
401
411
 
402
412
  ServerResponse.prototype.setHeader = function (name, value) {
403
413
  if (this[kBasePath]) {
404
- if (name.toLowerCase() === 'location' && !value.startsWith(basePath)) {
405
- value = basePath + value
414
+ if (name.toLowerCase() === 'location') {
415
+ value = prependBasePath(value)
406
416
  }
407
417
  }
408
418
  originSetHeader.call(this, name, value)
@@ -114,14 +114,21 @@ export class MessagingITC extends ITC {
114
114
  context.meta = { ...options?.meta, ...telemetry?.meta }
115
115
  }
116
116
 
117
- const send = () => executeWithTimeout(super.send(name, message, context), this.#timeout)
118
- const response = telemetry ? await telemetry.run(send) : await send()
119
-
120
- if (response === kTimeout) {
121
- throw new MessagingError(application, 'Timeout while waiting for a response.')
117
+ const abortController = new AbortController()
118
+ const timeout = setTimeout(() => abortController.abort(), this.#timeout).unref()
119
+ context.signal = abortController.signal
120
+
121
+ try {
122
+ const send = () => super.send(name, message, context)
123
+ return telemetry ? await telemetry.run(send) : await send()
124
+ } catch (err) {
125
+ if (abortController.signal.aborted) {
126
+ throw new MessagingError(application, 'Timeout while waiting for a response.')
127
+ }
128
+ throw err
129
+ } finally {
130
+ clearTimeout(timeout)
122
131
  }
123
-
124
- return response
125
132
  } catch (err) {
126
133
  error = err
127
134
  throw err
@@ -181,6 +188,12 @@ export class MessagingITC extends ITC {
181
188
  channel.postMessage(sanitize(request, transferList), transferList)
182
189
  }
183
190
 
191
+ _onRequestSettled (reqId, context) {
192
+ if (context.trackResponse) {
193
+ context.channel[kPendingResponses].delete(reqId)
194
+ }
195
+ }
196
+
184
197
  _createClosePromise () {
185
198
  const { promise, resolve, reject } = Promise.withResolvers()
186
199
  this.#closeResolvers = { resolve, reject }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/runtime",
3
- "version": "3.62.1",
3
+ "version": "3.62.3",
4
4
  "description": "Platformatic runtime",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -39,14 +39,14 @@
39
39
  "typescript": "^5.9.3",
40
40
  "undici-oidc-interceptor": "^0.9.0",
41
41
  "why-is-node-running": "^2.2.2",
42
- "@platformatic/composer": "3.62.1",
43
- "@platformatic/db": "3.62.1",
44
- "@platformatic/gateway": "3.62.1",
45
- "@platformatic/node": "3.62.1",
46
- "@platformatic/service": "3.62.1",
47
- "@platformatic/sql-mapper": "3.62.1",
48
- "@platformatic/sql-graphql": "3.62.1",
49
- "@platformatic/wattpm-pprof-capture": "3.62.1"
42
+ "@platformatic/composer": "3.62.3",
43
+ "@platformatic/db": "3.62.3",
44
+ "@platformatic/gateway": "3.62.3",
45
+ "@platformatic/node": "3.62.3",
46
+ "@platformatic/service": "3.62.3",
47
+ "@platformatic/sql-mapper": "3.62.3",
48
+ "@platformatic/wattpm-pprof-capture": "3.62.3",
49
+ "@platformatic/sql-graphql": "3.62.3"
50
50
  },
51
51
  "dependencies": {
52
52
  "@fastify/accepts": "^5.0.0",
@@ -69,7 +69,7 @@
69
69
  "help-me": "^5.0.0",
70
70
  "minimist": "^1.2.8",
71
71
  "pino": "^10.1.0",
72
- "pino-opentelemetry-transport": "^3.0.0",
72
+ "pino-opentelemetry-transport": "^4.0.2",
73
73
  "pino-pretty": "^13.0.0",
74
74
  "semgrator": "^0.3.0",
75
75
  "sonic-boom": "^4.2.0",
@@ -77,13 +77,13 @@
77
77
  "undici": "^7.27.2",
78
78
  "undici-thread-interceptor": "^1.3.1",
79
79
  "ws": "^8.16.0",
80
- "@platformatic/basic": "3.62.1",
81
- "@platformatic/foundation": "3.62.1",
82
- "@platformatic/generators": "3.62.1",
83
- "@platformatic/globals": "3.62.1",
84
- "@platformatic/itc": "3.62.1",
85
- "@platformatic/telemetry": "3.62.1",
86
- "@platformatic/metrics": "3.62.1"
80
+ "@platformatic/basic": "3.62.3",
81
+ "@platformatic/foundation": "3.62.3",
82
+ "@platformatic/generators": "3.62.3",
83
+ "@platformatic/globals": "3.62.3",
84
+ "@platformatic/metrics": "3.62.3",
85
+ "@platformatic/telemetry": "3.62.3",
86
+ "@platformatic/itc": "3.62.3"
87
87
  },
88
88
  "engines": {
89
89
  "node": ">=22.19.0"
package/schema.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.62.1.json",
2
+ "$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.62.3.json",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "title": "Platformatic Runtime Config",
5
5
  "type": "object",
@@ -3492,6 +3492,17 @@
3492
3492
  "envfile": {
3493
3493
  "type": "string"
3494
3494
  },
3495
+ "strictEnv": {
3496
+ "anyOf": [
3497
+ {
3498
+ "type": "boolean"
3499
+ },
3500
+ {
3501
+ "type": "string"
3502
+ }
3503
+ ],
3504
+ "description": "When set to true, the configuration loading fails if a {PLT_*} placeholder references an environment variable which is not set. When set to \"warn\", a warning listing the missing variables is logged but the placeholders are still replaced with an empty string. Defaults to false."
3505
+ },
3495
3506
  "sourceMaps": {
3496
3507
  "type": "boolean",
3497
3508
  "default": false