@platformatic/watt-extra 1.10.1-alpha.2 → 1.11.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.
@@ -1,22 +1,11 @@
1
1
  {
2
2
  "permissions": {
3
3
  "allow": [
4
- "Read(//work/workspaces/workspace-platformatic/platformatic/**)",
5
- "Bash(npx borp:*)",
6
- "Bash(timeout 30 npx borp -c 1 --timeout=20000 ./test/trigger-flamegraphs.test.js)",
7
- "Bash(xargs cat:*)",
8
- "Bash(pnpm install)",
9
- "Bash(find:*)",
10
- "Bash(cat:*)",
11
- "WebFetch(domain:github.com)",
4
+ "Bash(node --test-only:*)",
12
5
  "Bash(node --test:*)",
13
- "Bash(for i in 1 2 3)",
14
- "Bash(do echo \"Run $i:\")",
15
- "Bash(done)",
16
- "Bash(git stash:*)",
17
- "Bash(echo:*)",
18
- "Bash(grep:*)",
19
- "Bash(gh api:*)"
6
+ "Bash(for i in {1..3})",
7
+ "Bash(do echo \"=== Run $i ===\")",
8
+ "Bash(done)"
20
9
  ],
21
10
  "deny": [],
22
11
  "ask": []
package/lib/watt.js CHANGED
@@ -513,8 +513,7 @@ class Watt {
513
513
  const config = runtime.getRuntimeConfig(true)
514
514
 
515
515
  for (const app of config.applications ?? []) {
516
- this.#logger.info({ name: app.id, type: app.type }, `Configuring service ${app.id} of type ${app.type}`)
517
- if (app.type === '@platformatic/next') {
516
+ if (app.type === 'next') {
518
517
  await this.#configureNextService(runtime, app)
519
518
  } else if (
520
519
  [
@@ -531,8 +530,6 @@ class Watt {
531
530
  async #configureNextService (runtime, service) {
532
531
  let nextSchema
533
532
 
534
- this.#logger.info(`Configuring next service: ${service}`)
535
-
536
533
  try {
537
534
  const nextPackage = createRequire(
538
535
  resolve(service.path, 'index.js')
@@ -550,13 +547,9 @@ class Watt {
550
547
 
551
548
  const patches = []
552
549
 
553
- const isCacheIsSchema = 'cache' in nextSchema.properties
554
- this.#logger.info(`'cache' in nextSchema.properties: ${isCacheIsSchema}`)
555
-
556
- if (isCacheIsSchema) {
550
+ if ('cache' in nextSchema.properties) {
557
551
  const httpCache = this.#instanceConfig?.httpCache?.clientOpts || {}
558
552
  const { keyPrefix, host, port, username, password } = httpCache
559
- this.#logger.info({ keyPrefix, host, port }, `Configuring cache for next service ${service.id}`)
560
553
 
561
554
  if (!keyPrefix || !host || !port) {
562
555
  this.#logger.warn(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/watt-extra",
3
- "version": "1.10.1-alpha.2",
3
+ "version": "1.11.0-alpha.1",
4
4
  "description": "The Platformatic runtime manager",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -24,7 +24,7 @@
24
24
  "@platformatic/node": "^3.25.0",
25
25
  "@platformatic/service": "^3.25.0",
26
26
  "atomic-sleep": "^1.0.0",
27
- "borp": "^0.21.0",
27
+ "borp": "^1.0.0",
28
28
  "eslint": "9",
29
29
  "fastify": "^5.4.0",
30
30
  "fastify-plugin": "^5.0.1",
package/plugins/env.js CHANGED
@@ -26,8 +26,10 @@ const schema = {
26
26
  PLT_JWT_EXPIRATION_OFFSET_SEC: { type: 'number', default: 60 },
27
27
  PLT_UPDATES_RECONNECT_INTERVAL_SEC: { type: 'number', default: 1 },
28
28
  PLT_ELU_HEALTH_SIGNAL_THRESHOLD: { type: 'number', default: 0.8 },
29
- PLT_HEAP_HEALTH_SIGNAL_THRESHOLD: { type: ['number', 'string'], default: '4GB' },
30
- PLT_ALERTS_GRACE_PERIOD_SEC: { type: 'number', default: 30 }
29
+ PLT_HEAP_HEALTH_SIGNAL_THRESHOLD: { type: ['number', 'string'], default: '200MB' },
30
+ PLT_ALERTS_GRACE_PERIOD_SEC: { type: 'number', default: 30 },
31
+ PLT_HEALTH_SIGNALS_SHORT_BATCH_TIMEOUT: { type: 'number', default: 2000 },
32
+ PLT_HEALTH_SIGNALS_LONG_BATCH_TIMEOUT: { type: 'number', default: 2000 }
31
33
  }
32
34
  }
33
35
 
@@ -1,42 +1,59 @@
1
+ import { randomUUID } from 'node:crypto'
1
2
  import { request } from 'undici'
2
3
  import semver from 'semver'
3
4
  import { parseMemorySize } from '@platformatic/foundation'
4
5
 
5
6
  class HealthSignalsCache {
6
- #signals = []
7
- #size = 100
7
+ #signalsByService = {}
8
+ #maxSize = 500
8
9
 
9
- constructor () {
10
- this.#signals = []
11
- }
12
-
13
- add (signals) {
10
+ addServiceSignals (serviceId, signals) {
14
11
  for (const signal of signals) {
15
- this.#signals.push(signal)
12
+ this.addServiceSignal(serviceId, signal.type, signal)
16
13
  }
17
- if (this.#signals.length > this.#size) {
18
- this.#signals.splice(0, this.#signals.length - this.#size)
14
+ }
15
+
16
+ addServiceSignal (serviceId, signalType, signal) {
17
+ this.#signalsByService[serviceId] ??= {}
18
+ this.#signalsByService[serviceId][signalType] ??= []
19
+
20
+ const signals = this.#signalsByService[serviceId][signalType]
21
+ signals.push(signal)
22
+
23
+ if (signals.length > this.#maxSize) {
24
+ signals.splice(0, signals.length - this.#maxSize)
19
25
  }
20
26
  }
21
27
 
22
- getAll () {
23
- const values = this.#signals
24
- this.#signals = []
25
- return values
28
+ getAllSignals () {
29
+ const signalsByService = this.#signalsByService
30
+ this.#signalsByService = {}
31
+ return signalsByService
26
32
  }
27
33
  }
28
34
 
29
35
  async function healthSignals (app, _opts) {
30
- const signalsCaches = {}
31
- const servicesSendingStatuses = {}
36
+ app.getRuntimeId = () => {
37
+ if (!app.runtimeId) {
38
+ app.runtimeId = randomUUID()
39
+ }
40
+ return app.runtimeId
41
+ }
42
+
43
+ const signalsCache = new HealthSignalsCache()
44
+ const heapTotalByService = {}
32
45
 
33
46
  // TODO: needed to the UI compatibility
34
47
  // remove after depricating the Scaler v1 UI
35
- const servicesMetrics = {}
48
+ let servicesMetrics = {}
36
49
 
37
50
  // Store listener reference for cleanup
38
51
  let healthMetricsListener = null
39
52
 
53
+ // Store thresholds for use in sendHealthSignals
54
+ let eluThreshold = null
55
+ let heapThresholdMb = null
56
+
40
57
  async function setupHealthSignals () {
41
58
  const scalerAlgorithmVersion = app.instanceConfig?.scaler?.version ?? 'v1'
42
59
  if (scalerAlgorithmVersion !== 'v2') {
@@ -54,13 +71,6 @@ async function healthSignals (app, _opts) {
54
71
  return
55
72
  }
56
73
 
57
- const eluThreshold = app.env.PLT_ELU_HEALTH_SIGNAL_THRESHOLD
58
-
59
- let heapThreshold = app.env.PLT_HEAP_HEALTH_SIGNAL_THRESHOLD
60
- if (typeof heapThreshold === 'string') {
61
- heapThreshold = parseMemorySize(heapThreshold)
62
- }
63
-
64
74
  // Skip alerts setup if ICC is not configured
65
75
  if (!app.env.PLT_ICC_URL) {
66
76
  app.log.info('PLT_ICC_URL not set, skipping alerts setup')
@@ -68,8 +78,6 @@ async function healthSignals (app, _opts) {
68
78
  }
69
79
 
70
80
  const scalerUrl = app.instanceConfig?.iccServices?.scaler?.url
71
- const runtime = app.watt.runtime
72
-
73
81
  if (!scalerUrl) {
74
82
  app.log.warn(
75
83
  'No scaler URL found in ICC services, health alerts disabled'
@@ -77,6 +85,40 @@ async function healthSignals (app, _opts) {
77
85
  return
78
86
  }
79
87
 
88
+ const runtime = app.watt.runtime
89
+ const batchShortTimeout = app.env.PLT_HEALTH_SIGNALS_SHORT_BATCH_TIMEOUT
90
+ const batchLongTimeout = app.env.PLT_HEALTH_SIGNALS_LONG_BATCH_TIMEOUT
91
+ eluThreshold = app.env.PLT_ELU_HEALTH_SIGNAL_THRESHOLD
92
+
93
+ // TODO: get the used heap and use the 0.8 by default as a threshold
94
+ let heapThreshold = app.env.PLT_HEAP_HEALTH_SIGNAL_THRESHOLD
95
+ if (typeof heapThreshold === 'string') {
96
+ heapThreshold = parseMemorySize(heapThreshold)
97
+ }
98
+ heapThresholdMb = Math.round(heapThreshold / 1024 / 1024)
99
+
100
+ let batchHasHighValue = false
101
+ let batchStartedAt = null
102
+ setInterval(() => {
103
+ const now = Date.now()
104
+ const batchTimeout = batchHasHighValue
105
+ ? batchShortTimeout
106
+ : batchLongTimeout
107
+
108
+ if (now - batchStartedAt >= batchTimeout) {
109
+ batchHasHighValue = false
110
+ batchStartedAt = null
111
+
112
+ const signals = signalsCache.getAllSignals()
113
+ const metrics = servicesMetrics
114
+ servicesMetrics = {}
115
+
116
+ sendHealthSignals(signals, metrics).catch(err => {
117
+ app.log.error({ err }, 'Failed to send health signals to scaler')
118
+ })
119
+ }
120
+ }, 1000).unref()
121
+
80
122
  // Remove old listener if it exists (for ICC recovery scenario)
81
123
  if (healthMetricsListener) {
82
124
  runtime.removeListener('application:worker:health:metrics', healthMetricsListener)
@@ -95,30 +137,25 @@ async function healthSignals (app, _opts) {
95
137
  } = healthInfo
96
138
 
97
139
  const { elu, heapUsed, heapTotal } = currentHealth
140
+ const heapUsedMb = Math.round(heapUsed / 1024 / 1024)
141
+ const now = Date.now()
98
142
 
99
- if (elu > eluThreshold) {
100
- healthSignals.push({
101
- type: 'elu',
102
- value: currentHealth.elu,
103
- description:
104
- `The ${serviceId} has an ELU of ${(elu * 100).toFixed(2)} %, ` +
105
- `above the maximum allowed usage of ${(eluThreshold * 100).toFixed(2)} %`,
106
- timestamp: Date.now()
107
- })
108
- }
143
+ signalsCache.addServiceSignal(serviceId, 'elu', {
144
+ workerId,
145
+ value: elu,
146
+ timestamp: now
147
+ })
148
+ signalsCache.addServiceSignal(serviceId, 'heap', {
149
+ workerId,
150
+ value: heapUsedMb,
151
+ timestamp: now
152
+ })
153
+ heapTotalByService[serviceId] = heapTotal
154
+ signalsCache.addServiceSignals(serviceId, healthSignals)
109
155
 
110
- if (heapThreshold && heapUsed > heapThreshold) {
111
- const usedHeapMb = Math.round(heapUsed / 1024 / 1024)
112
- const heapThresholdMb = Math.round(heapThreshold / 1024 / 1024)
113
-
114
- healthSignals.push({
115
- type: 'heapUsed',
116
- value: currentHealth.heapUsed,
117
- description:
118
- `The ${serviceId} is using ${usedHeapMb} MB of heap, ` +
119
- `above the maximum allowed usage of ${heapThresholdMb} MB`,
120
- timestamp: Date.now()
121
- })
156
+ batchStartedAt ??= now
157
+ if (elu > eluThreshold || heapUsedMb > heapThresholdMb) {
158
+ batchHasHighValue = true
122
159
  }
123
160
 
124
161
  // TODO: needed to the UI compatibility
@@ -132,44 +169,35 @@ async function healthSignals (app, _opts) {
132
169
  metrics.heapUsed = heapUsed
133
170
  metrics.heapTotal = heapTotal
134
171
  }
135
-
136
- if (healthSignals.length > 0) {
137
- await sendHealthSignalsWithTimeout(serviceId, workerId, healthSignals)
138
- }
139
172
  }
140
173
  runtime.on('application:worker:health:metrics', healthMetricsListener)
141
174
  }
142
175
  app.setupHealthSignals = setupHealthSignals
143
176
 
144
- async function sendHealthSignalsWithTimeout (serviceId, workerId, signals) {
145
- signalsCaches[serviceId] ??= new HealthSignalsCache()
146
- servicesSendingStatuses[serviceId] ??= false
147
-
148
- const signalsCache = signalsCaches[serviceId]
149
- signalsCache.add(signals)
150
-
151
- if (!servicesSendingStatuses[serviceId]) {
152
- servicesSendingStatuses[serviceId] = true
153
- setTimeout(async () => {
154
- servicesSendingStatuses[serviceId] = false
155
-
156
- const metrics = servicesMetrics[serviceId]
157
- servicesMetrics[serviceId] = null
177
+ async function sendHealthSignals (rawSignals) {
178
+ const scalerUrl = app.instanceConfig?.iccServices?.scaler?.url
179
+ const applicationId = app.instanceConfig?.applicationId
180
+ const authHeaders = await app.getAuthorizationHeader()
158
181
 
159
- try {
160
- const signals = signalsCache.getAll()
161
- await sendHealthSignals(serviceId, workerId, signals, metrics)
162
- } catch (err) {
163
- app.log.error({ err }, 'Failed to send health signals to scaler')
182
+ // Transform signals to the format expected by ICC LoadPredictor
183
+ const signals = {}
184
+ for (const [serviceId, serviceSignals] of Object.entries(rawSignals)) {
185
+ signals[serviceId] = {
186
+ elu: {
187
+ values: serviceSignals.elu || [],
188
+ options: { threshold: eluThreshold }
189
+ },
190
+ heap: {
191
+ values: serviceSignals.heap || [],
192
+ options: {
193
+ threshold: heapThresholdMb,
194
+ heapTotal: heapTotalByService[serviceId] || 0
195
+ }
164
196
  }
165
- }, 5000).unref()
197
+ }
166
198
  }
167
- }
168
199
 
169
- async function sendHealthSignals (serviceId, workerId, signals, metrics) {
170
- const scalerUrl = app.instanceConfig?.iccServices?.scaler?.url
171
- const applicationId = app.instanceConfig?.applicationId
172
- const authHeaders = await app.getAuthorizationHeader()
200
+ const runtimeId = app.getRuntimeId()
173
201
 
174
202
  const { statusCode, body } = await request(`${scalerUrl}/signals`, {
175
203
  method: 'POST',
@@ -177,30 +205,34 @@ async function healthSignals (app, _opts) {
177
205
  'Content-Type': 'application/json',
178
206
  ...authHeaders
179
207
  },
180
- body: JSON.stringify({
181
- applicationId,
182
- serviceId,
183
- signals,
184
- elu: metrics.elu,
185
- heapUsed: metrics.heapUsed,
186
- heapTotal: metrics.heapTotal
187
- })
208
+ body: JSON.stringify({ applicationId, runtimeId, signals })
188
209
  })
189
210
 
190
211
  if (statusCode !== 200) {
191
212
  const error = await body.text()
192
213
  app.log.error({ error }, 'Failed to send health signals to scaler')
214
+ return
193
215
  }
194
216
 
195
- const alert = await body.json()
217
+ const { alerts = [] } = await body.json()
218
+ const promises = []
196
219
 
197
- app.sendFlamegraphs({
198
- serviceIds: [serviceId],
199
- workerIds: [workerId],
200
- alertId: alert.id
201
- }).catch(err => {
202
- app.log.error({ err }, 'Failed to send a flamegraph')
203
- })
220
+ for (const alert of alerts) {
221
+ const { serviceId, workerId, alertId } = alert
222
+ const promise = app.sendFlamegraphs({
223
+ serviceIds: [serviceId],
224
+ workerIds: [workerId],
225
+ alertId
226
+ })
227
+ promises.push(promise)
228
+ }
229
+ const results = await Promise.allSettled(promises)
230
+
231
+ for (const result of results) {
232
+ if (result.status === 'rejected') {
233
+ app.log.error({ err: result.reason }, 'Failed to send a flamegraph')
234
+ }
235
+ }
204
236
  }
205
237
  }
206
238
 
package/plugins/update.js CHANGED
@@ -2,11 +2,14 @@ import WebSocket from 'ws'
2
2
  import { once } from 'node:events'
3
3
  import { setTimeout as sleep } from 'node:timers/promises'
4
4
 
5
- function createWebSocketUrl (httpUrl, path) {
5
+ function createWebSocketUrl (httpUrl, path, queryParams = {}) {
6
6
  const url = new URL(httpUrl)
7
7
  url.protocol = url.protocol.replace('http', 'ws')
8
8
  const basePath = url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`
9
9
  url.pathname = `${basePath}${path}`
10
+ for (const [key, value] of Object.entries(queryParams)) {
11
+ url.searchParams.set(key, value)
12
+ }
10
13
  return url.toString()
11
14
  }
12
15
 
@@ -64,8 +67,9 @@ async function updatePlugin (app) {
64
67
  return null
65
68
  }
66
69
 
67
- const wsUrl = createWebSocketUrl(iccUrl, `api/updates/applications/${applicationId}`)
68
- app.log.info(`Connecting to updates websocket at ${wsUrl}`)
70
+ const runtimeId = app.getRuntimeId()
71
+ const wsUrl = createWebSocketUrl(iccUrl, `api/updates/applications/${applicationId}`, { runtimeId })
72
+ app.log.info({ runtimeId }, `Connecting to updates websocket at ${wsUrl}`)
69
73
 
70
74
  try {
71
75
  const headers = await app.getAuthorizationHeader()
@@ -31,7 +31,12 @@ test('should send health signals when service becomes unhealthy', async (t) => {
31
31
  processSignals: (req) => {
32
32
  assert.equal(req.headers.authorization, 'Bearer test-token')
33
33
  receivedSignalReqs.push(req.body)
34
- return { id: 'test-alert-id' }
34
+ // Real ICC returns { alerts: [{ serviceId, workerId, alertId }] }
35
+ return {
36
+ alerts: [
37
+ { serviceId: 'main', workerId: 'main:0', alertId: 'test-alert-id' }
38
+ ]
39
+ }
35
40
  },
36
41
  processFlamegraphs: (req) => {
37
42
  const alertId = req.query.alertId
@@ -87,36 +92,53 @@ test('should send health signals when service becomes unhealthy', async (t) => {
87
92
  assert.strictEqual(statusCode, 200)
88
93
  }
89
94
 
90
- assert.strictEqual(receivedSignalReqs.length, 1)
95
+ // Multiple batches may be sent due to timing, verify we received at least one
96
+ assert.ok(receivedSignalReqs.length >= 1, `Expected at least 1 signal request, got ${receivedSignalReqs.length}`)
91
97
 
92
- const receivedSignalReq = receivedSignalReqs[0]
93
- assert.ok(receivedSignalReq, 'Alert should have been received')
98
+ // Use the last signal request which should have the most complete data
99
+ const receivedSignalReq = receivedSignalReqs[receivedSignalReqs.length - 1]
100
+ assert.ok(receivedSignalReq, 'Signal request should have been received')
94
101
  assert.strictEqual(receivedSignalReq.applicationId, applicationId)
95
- assert.strictEqual(receivedSignalReq.serviceId, 'main')
96
- assert.ok(receivedSignalReq.elu > 0.9)
97
- assert.ok(receivedSignalReq.heapUsed > 0)
98
- assert.ok(receivedSignalReq.heapTotal > 0)
99
-
100
- const receivedSignals = receivedSignalReq.signals
101
- assert.ok(receivedSignals.length > 5)
102
-
103
- const eluSignals = receivedSignals.filter(
104
- (signal) => signal.type === 'elu'
105
- )
106
- const customSignals = receivedSignals.filter(
107
- (signal) => signal.type === 'custom'
108
- )
109
- assert.strictEqual(customSignals.length, 1)
110
-
111
- for (const receivedSignal of eluSignals) {
112
- assert.strictEqual(receivedSignal.type, 'elu')
113
- assert.ok(receivedSignal.value > 0.9)
114
- assert.ok(receivedSignal.timestamp > 0)
102
+ assert.ok(receivedSignalReq.runtimeId, 'runtimeId should be present')
103
+ assert.ok(typeof receivedSignalReq.runtimeId === 'string', 'runtimeId should be a string')
104
+
105
+ // Verify v2 signals format: { serviceId: { elu: { values, options }, heap: { values, options } } }
106
+ const signals = receivedSignalReq.signals
107
+ assert.ok(signals, 'signals should be present')
108
+ assert.ok(signals.main, 'main service signals should be present')
109
+
110
+ // Check ELU signals structure
111
+ const eluSignals = signals.main.elu
112
+ assert.ok(eluSignals, 'ELU signals should be present')
113
+ assert.ok(Array.isArray(eluSignals.values), 'ELU values should be an array')
114
+ assert.ok(eluSignals.options, 'ELU options should be present')
115
+ assert.ok(typeof eluSignals.options.threshold === 'number', 'ELU threshold should be a number')
116
+
117
+ // Check heap signals structure
118
+ const heapSignals = signals.main.heap
119
+ assert.ok(heapSignals, 'Heap signals should be present')
120
+ assert.ok(Array.isArray(heapSignals.values), 'Heap values should be an array')
121
+ assert.ok(heapSignals.options, 'Heap options should be present')
122
+ assert.ok(typeof heapSignals.options.threshold === 'number', 'Heap threshold should be a number')
123
+
124
+ // Verify ELU values have the correct structure with workerId
125
+ assert.ok(eluSignals.values.length > 0, 'Should have ELU values')
126
+ for (const eluValue of eluSignals.values) {
127
+ assert.ok(typeof eluValue.value === 'number', 'ELU value should be a number')
128
+ assert.ok(typeof eluValue.timestamp === 'number', 'ELU timestamp should be a number')
129
+ assert.ok(typeof eluValue.workerId === 'string', 'ELU workerId should be a string')
115
130
  }
116
- for (const receivedSignal of customSignals) {
117
- assert.strictEqual(receivedSignal.type, 'custom')
118
- assert.strictEqual(receivedSignal.value, 42)
119
- assert.ok(receivedSignal.timestamp > 0)
131
+
132
+ // Check that at least one ELU value is high (from CPU intensive operation)
133
+ const highEluValue = eluSignals.values.find(v => v.value > 0.9)
134
+ assert.ok(highEluValue, 'Should have at least one high ELU value')
135
+
136
+ // Verify heap values have the correct structure with workerId
137
+ assert.ok(heapSignals.values.length > 0, 'Should have heap values')
138
+ for (const heapValue of heapSignals.values) {
139
+ assert.ok(typeof heapValue.value === 'number', 'Heap value should be a number')
140
+ assert.ok(typeof heapValue.timestamp === 'number', 'Heap timestamp should be a number')
141
+ assert.ok(typeof heapValue.workerId === 'string', 'Heap workerId should be a string')
120
142
  }
121
143
 
122
144
  // Wait for the second flamegraph to be generated
@@ -411,60 +411,3 @@ test('should merge user telemetry config with ICC exporter', async (t) => {
411
411
  assert.ok(hasUserSkip, 'User skip pattern should be preserved')
412
412
  assert.ok(hasDefaultSkip, 'Default skip pattern should be added')
413
413
  })
414
-
415
- test('should configure next service with cache', async (t) => {
416
- const appName = 'test-app'
417
- const applicationId = randomUUID()
418
- const applicationPath = join(__dirname, 'fixtures', 'runtime-next')
419
- const nextServicePath = join(applicationPath, 'web', 'next')
420
-
421
- await installDeps(t, applicationPath)
422
- await installDeps(t, nextServicePath, ['@platformatic/next', 'next'])
423
-
424
- const { execa } = await import('execa')
425
- await execa(join(__dirname, '../node_modules/.bin/plt'), ['build'], {
426
- cwd: applicationPath,
427
- })
428
-
429
- const icc = await startICC(t, {
430
- applicationId,
431
- port: 3001,
432
- controlPlaneResponse: {
433
- applicationId,
434
- httpCache: {
435
- clientOpts: {
436
- keyPrefix: 'test-prefix',
437
- host: '127.0.0.1',
438
- port: 6379,
439
- username: 'user',
440
- password: 'pass'
441
- }
442
- }
443
- }
444
- })
445
-
446
- setUpEnvironment({
447
- PLT_APP_NAME: appName,
448
- PLT_APP_DIR: applicationPath,
449
- PLT_ICC_URL: icc.iccUrl,
450
- PLT_APP_PORT: 3043,
451
- PLT_METRICS_PORT: 9092
452
- })
453
-
454
- const app = await start()
455
-
456
- t.after(async () => {
457
- await app.close()
458
- await icc.close()
459
- })
460
-
461
- const runtimeConfig = app.watt.runtime.getRuntimeConfig(true)
462
- const nextApp = runtimeConfig.applications.find(a => a.type === '@platformatic/next')
463
- assert.ok(nextApp, 'Next service should be found in applications')
464
-
465
- const nextConfig = await app.watt.runtime.getApplicationConfig(nextApp.id)
466
- assert.ok(nextConfig.cache, 'Cache should be configured for next service')
467
- assert.strictEqual(nextConfig.cache.adapter, 'valkey')
468
- assert.strictEqual(nextConfig.cache.prefix, 'test-prefix')
469
- assert.strictEqual(nextConfig.cache.maxTTL, 604800)
470
- })
@@ -79,6 +79,7 @@ function createMockApp (port, includeScalerUrl = true, env = {}) {
79
79
  }
80
80
  }
81
81
 
82
+ let runtimeId = null
82
83
  const app = {
83
84
  log: {
84
85
  info: () => {},
@@ -93,6 +94,12 @@ function createMockApp (port, includeScalerUrl = true, env = {}) {
93
94
  getAuthorizationHeader: async () => {
94
95
  return { Authorization: 'Bearer test-token' }
95
96
  },
97
+ getRuntimeId: () => {
98
+ if (!runtimeId) {
99
+ runtimeId = 'test-runtime-id'
100
+ }
101
+ return runtimeId
102
+ },
96
103
  env: {
97
104
  PLT_APP_NAME: 'test-app',
98
105
  PLT_APP_DIR: '/path/to/app',
@@ -6,7 +6,8 @@ import updatePlugin from '../plugins/update.js'
6
6
  import { once, EventEmitter } from 'node:events'
7
7
  import { setTimeout as sleep } from 'node:timers/promises'
8
8
 
9
- function createMockApp (port) {
9
+ function createMockApp (port, options = {}) {
10
+ let runtimeId = null
10
11
  return {
11
12
  log: {
12
13
  info: () => {},
@@ -20,6 +21,12 @@ function createMockApp (port) {
20
21
  getAuthorizationHeader: async () => {
21
22
  return { Authorization: 'Bearer test-token' }
22
23
  },
24
+ getRuntimeId: () => {
25
+ if (!runtimeId) {
26
+ runtimeId = options.runtimeId || 'test-runtime-id'
27
+ }
28
+ return runtimeId
29
+ },
23
30
  env: {
24
31
  PLT_APP_NAME: 'test-app',
25
32
  PLT_APP_DIR: '/path/to/app',
@@ -92,6 +99,46 @@ test('update plugin connects to websocket', async (t) => {
92
99
  equal(!!subscriptionAckLog, true, 'Should log subscription acknowledgment')
93
100
  })
94
101
 
102
+ test('update plugin includes runtimeId in websocket URL', async (t) => {
103
+ const ee = new EventEmitter()
104
+ setUpEnvironment()
105
+
106
+ const expectedRuntimeId = 'test-runtime-id-12345'
107
+ let receivedUrl = null
108
+
109
+ // Setup WebSocket server
110
+ const wss = new WebSocketServer({ port })
111
+ t.after(async () => wss.close())
112
+
113
+ wss.on('connection', (ws, req) => {
114
+ receivedUrl = req.url
115
+
116
+ ws.on('message', (data) => {
117
+ const message = JSON.parse(data.toString())
118
+ if (message.command === 'subscribe' && message.topic === '/config') {
119
+ ws.send(JSON.stringify({ command: 'ack' }))
120
+ ee.emit('subscriptionAckSent')
121
+ }
122
+ })
123
+ })
124
+
125
+ const app = createMockApp(port, { runtimeId: expectedRuntimeId })
126
+ t.after(() => app.closeUpdates())
127
+
128
+ await updatePlugin(app)
129
+
130
+ const ack = once(ee, 'subscriptionAckSent')
131
+ app.connectToUpdates()
132
+ await ack
133
+
134
+ // Verify runtimeId is in the URL query params
135
+ equal(
136
+ receivedUrl.includes(`runtimeId=${expectedRuntimeId}`),
137
+ true,
138
+ `WebSocket URL should include runtimeId query param. Received URL: ${receivedUrl}`
139
+ )
140
+ })
141
+
95
142
  test('update plugin handles config update messages', async (t) => {
96
143
  const ee = new EventEmitter()
97
144
  setUpEnvironment()