@open-mercato/shared 0.6.6-develop.6384.1.f06fc0b42c → 0.6.6-develop.6386.1.391a1afb9e

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.
@@ -0,0 +1,351 @@
1
+ import {
2
+ clearModuleResourceUsageData,
3
+ getModuleResourceUsageReport,
4
+ resetModuleResourceUsage,
5
+ withModuleResourceUsage,
6
+ } from '../resource-usage'
7
+ import fs from 'node:fs'
8
+ import os from 'node:os'
9
+ import path from 'node:path'
10
+
11
+ describe('module resource usage tracker', () => {
12
+ const previousEnv = process.env.OM_MODULE_RESOURCE_USAGE
13
+ const previousCpuThreshold = process.env.OM_MODULE_RESOURCE_HEAVY_CPU_MS
14
+ const previousSnapshotEnv = process.env.OM_MODULE_RESOURCE_USAGE_SNAPSHOT
15
+ const previousSnapshotDir = process.env.OM_MODULE_RESOURCE_USAGE_DIR
16
+ let tempDir: string | null = null
17
+
18
+ beforeEach(() => {
19
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'om-module-usage-'))
20
+ process.env.OM_MODULE_RESOURCE_USAGE_DIR = tempDir
21
+ process.env.OM_MODULE_RESOURCE_USAGE_SNAPSHOT = 'off'
22
+ resetModuleResourceUsage()
23
+ delete process.env.OM_MODULE_RESOURCE_USAGE
24
+ delete process.env.OM_MODULE_RESOURCE_HEAVY_CPU_MS
25
+ })
26
+
27
+ afterEach(() => {
28
+ resetModuleResourceUsage()
29
+ if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true })
30
+ tempDir = null
31
+ if (previousEnv === undefined) delete process.env.OM_MODULE_RESOURCE_USAGE
32
+ else process.env.OM_MODULE_RESOURCE_USAGE = previousEnv
33
+ if (previousCpuThreshold === undefined) delete process.env.OM_MODULE_RESOURCE_HEAVY_CPU_MS
34
+ else process.env.OM_MODULE_RESOURCE_HEAVY_CPU_MS = previousCpuThreshold
35
+ if (previousSnapshotEnv === undefined) delete process.env.OM_MODULE_RESOURCE_USAGE_SNAPSHOT
36
+ else process.env.OM_MODULE_RESOURCE_USAGE_SNAPSHOT = previousSnapshotEnv
37
+ if (previousSnapshotDir === undefined) delete process.env.OM_MODULE_RESOURCE_USAGE_DIR
38
+ else process.env.OM_MODULE_RESOURCE_USAGE_DIR = previousSnapshotDir
39
+ })
40
+
41
+ it('aggregates successful module operations', async () => {
42
+ await withModuleResourceUsage(
43
+ { moduleId: 'customers', surface: 'api', operation: 'GET /api/customers/people' },
44
+ async () => 'ok',
45
+ )
46
+
47
+ const report = getModuleResourceUsageReport()
48
+
49
+ expect(report.enabled).toBe(true)
50
+ expect(report.totals.calls).toBe(1)
51
+ expect(report.modules).toHaveLength(1)
52
+ expect(report.modules[0].moduleId).toBe('customers')
53
+ expect(report.modules[0].calls).toBe(1)
54
+ expect(report.modules[0].surfaces[0].surface).toBe('api')
55
+ expect(report.modules[0].topOperations[0].operation).toBe('GET /api/customers/people')
56
+ expect(report.bucketIntervalMs).toBe(5 * 60 * 1000)
57
+ expect(report.buckets).toHaveLength(1)
58
+ expect(new Date(report.buckets[0].bucketStart).getUTCMinutes() % 5).toBe(0)
59
+ expect(report.buckets[0].bucketIntervalMs).toBe(5 * 60 * 1000)
60
+ expect(report.buckets[0].stage).toBe('startup')
61
+ expect(report.buckets[0].totals.calls).toBe(1)
62
+ expect(report.buckets[0].modules[0].moduleId).toBe('customers')
63
+ expect(report.buckets[0].modules[0].calls).toBe(1)
64
+ expect(report.buckets[0].modules[0].surfaces[0].surface).toBe('api')
65
+ expect(report.buckets[0].modules[0].topOperations[0].operation).toBe('GET /api/customers/people')
66
+ })
67
+
68
+ it('marks overlapping calls concurrentCalls-tainted, but not a call that runs alone', async () => {
69
+ let releaseA: (() => void) | null = null
70
+ const blockedA = new Promise<void>((resolve) => { releaseA = resolve })
71
+
72
+ const callA = withModuleResourceUsage(
73
+ { moduleId: 'customers', surface: 'api', operation: 'GET /api/customers/people' },
74
+ async () => {
75
+ await blockedA
76
+ },
77
+ )
78
+ // Give callA's promise chain a tick to register itself as in-flight before callB starts.
79
+ await Promise.resolve()
80
+
81
+ await withModuleResourceUsage(
82
+ { moduleId: 'catalog', surface: 'api', operation: 'GET /api/catalog/products' },
83
+ async () => undefined,
84
+ )
85
+
86
+ releaseA!()
87
+ await callA
88
+
89
+ await withModuleResourceUsage(
90
+ { moduleId: 'sales', surface: 'api', operation: 'GET /api/sales/orders' },
91
+ async () => undefined,
92
+ )
93
+
94
+ const report = getModuleResourceUsageReport()
95
+ const byModule = new Map(report.modules.map((module) => [module.moduleId, module]))
96
+
97
+ // customers and catalog overlapped in wall-clock time, so both are tainted.
98
+ expect(byModule.get('customers')?.topOperations[0].concurrentCalls).toBe(1)
99
+ expect(byModule.get('catalog')?.topOperations[0].concurrentCalls).toBe(1)
100
+ // sales ran entirely after both finished, so it's untainted.
101
+ expect(byModule.get('sales')?.topOperations[0].concurrentCalls).toBe(0)
102
+ })
103
+
104
+ it('records errors and rethrows the original failure', async () => {
105
+ await expect(
106
+ withModuleResourceUsage(
107
+ { moduleId: 'sales', surface: 'worker', operation: 'sales:workers:sync' },
108
+ async () => {
109
+ throw new Error('boom')
110
+ },
111
+ ),
112
+ ).rejects.toThrow('boom')
113
+
114
+ const report = getModuleResourceUsageReport()
115
+
116
+ expect(report.totals.calls).toBe(1)
117
+ expect(report.totals.errors).toBe(1)
118
+ expect(report.modules[0].moduleId).toBe('sales')
119
+ expect(report.modules[0].errors).toBe(1)
120
+ })
121
+
122
+ it('returns the real result even when bookkeeping throws after a successful call', async () => {
123
+ const state = (globalThis as Record<string, unknown>).__openMercatoModuleResourceUsage__ as { entries: unknown }
124
+ state.entries = {
125
+ get() { throw new Error('boom-bookkeeping') },
126
+ }
127
+
128
+ const result = await withModuleResourceUsage(
129
+ { moduleId: 'customers', surface: 'api', operation: 'GET /api/customers/people' },
130
+ async () => 'real-result',
131
+ )
132
+
133
+ expect(result).toBe('real-result')
134
+ })
135
+
136
+ it('still rethrows the original error when bookkeeping also throws on the error path', async () => {
137
+ const state = (globalThis as Record<string, unknown>).__openMercatoModuleResourceUsage__ as { entries: unknown }
138
+ state.entries = {
139
+ get() { throw new Error('boom-bookkeeping') },
140
+ }
141
+
142
+ await expect(
143
+ withModuleResourceUsage(
144
+ { moduleId: 'sales', surface: 'worker', operation: 'sales:workers:sync' },
145
+ async () => {
146
+ throw new Error('boom-real')
147
+ },
148
+ ),
149
+ ).rejects.toThrow('boom-real')
150
+ })
151
+
152
+ it('does not crash when hot reload keeps legacy bucket state without operation maps', () => {
153
+ const now = Date.now()
154
+ const bucketStartMs = Math.floor(now / (10 * 60 * 1000)) * (10 * 60 * 1000)
155
+ ;(globalThis as Record<string, unknown>).__openMercatoModuleResourceUsage__ = {
156
+ startedAt: new Date(bucketStartMs).toISOString(),
157
+ entries: new Map(),
158
+ buckets: new Map([
159
+ [
160
+ String(bucketStartMs),
161
+ {
162
+ bucketStartMs,
163
+ bucketEndMs: bucketStartMs + 10 * 60 * 1000,
164
+ modules: new Map([
165
+ [
166
+ 'customers',
167
+ {
168
+ moduleId: 'customers',
169
+ calls: 1,
170
+ errors: 0,
171
+ totalDurationMs: 12,
172
+ totalCpuMs: 4,
173
+ positiveHeapDeltaBytes: 1024,
174
+ positiveRssDeltaBytes: 2048,
175
+ recentDurationsMs: [12],
176
+ },
177
+ ],
178
+ ]),
179
+ },
180
+ ],
181
+ ]),
182
+ lastSnapshotAt: 0,
183
+ shutdownHookRegistered: false,
184
+ }
185
+
186
+ const report = getModuleResourceUsageReport()
187
+
188
+ expect(report.buckets).toHaveLength(1)
189
+ expect(new Date(report.buckets[0].bucketStart).getUTCMinutes() % 5).toBe(0)
190
+ expect(report.buckets[0].modules[0].moduleId).toBe('customers')
191
+ expect(report.buckets[0].modules[0].topOperations).toEqual([])
192
+ })
193
+
194
+ it('infers module id from resource id when explicit module id is absent', async () => {
195
+ await withModuleResourceUsage(
196
+ { surface: 'subscriber', operation: 'customers.person.created -> customers:subscribers:index', resourceId: 'customers:subscribers:index' },
197
+ async () => undefined,
198
+ )
199
+
200
+ const report = getModuleResourceUsageReport()
201
+
202
+ expect(report.modules[0].moduleId).toBe('customers')
203
+ })
204
+
205
+ it('does not record when tracking is disabled', async () => {
206
+ process.env.OM_MODULE_RESOURCE_USAGE = 'off'
207
+
208
+ await withModuleResourceUsage(
209
+ { moduleId: 'customers', surface: 'api', operation: 'GET /api/customers/people' },
210
+ async () => undefined,
211
+ )
212
+
213
+ const report = getModuleResourceUsageReport()
214
+
215
+ expect(report.enabled).toBe(false)
216
+ expect(report.totals.calls).toBe(0)
217
+ expect(report.modules).toEqual([])
218
+ })
219
+
220
+ it('marks candidates when thresholds are crossed', async () => {
221
+ process.env.OM_MODULE_RESOURCE_HEAVY_CPU_MS = '0'
222
+
223
+ await withModuleResourceUsage(
224
+ { moduleId: 'catalog', surface: 'api', operation: 'GET /api/catalog/products' },
225
+ async () => undefined,
226
+ )
227
+
228
+ const report = getModuleResourceUsageReport()
229
+
230
+ expect(report.candidates.map((entry) => entry.moduleId)).toContain('catalog')
231
+ })
232
+
233
+ it('clears in-memory telemetry data', async () => {
234
+ await withModuleResourceUsage(
235
+ { moduleId: 'customers', surface: 'api', operation: 'GET /api/customers/people' },
236
+ async () => undefined,
237
+ )
238
+
239
+ expect(getModuleResourceUsageReport().totals.calls).toBe(1)
240
+
241
+ clearModuleResourceUsageData()
242
+
243
+ expect(getModuleResourceUsageReport().totals.calls).toBe(0)
244
+ })
245
+
246
+ it('skips re-migrating the buckets map on getState() when the bucket interval has not changed', async () => {
247
+ await withModuleResourceUsage(
248
+ { moduleId: 'customers', surface: 'api', operation: 'GET /api/customers/people' },
249
+ async () => undefined,
250
+ )
251
+ const stateAfterFirst = (globalThis as Record<string, unknown>).__openMercatoModuleResourceUsage__ as { buckets: Map<string, unknown> }
252
+ const bucketsMapAfterFirstCall = stateAfterFirst.buckets
253
+
254
+ await withModuleResourceUsage(
255
+ { moduleId: 'customers', surface: 'api', operation: 'GET /api/customers/people' },
256
+ async () => undefined,
257
+ )
258
+ const stateAfterSecond = (globalThis as Record<string, unknown>).__openMercatoModuleResourceUsage__ as { buckets: Map<string, unknown> }
259
+
260
+ // migrateMutableTimeBuckets always builds a brand new Map, so an unchanged reference proves
261
+ // getState() skipped the rebuild on the second call.
262
+ expect(stateAfterSecond.buckets).toBe(bucketsMapAfterFirstCall)
263
+ })
264
+
265
+ it('writes the snapshot file asynchronously and merges another process\'s snapshot into the report', async () => {
266
+ process.env.OM_MODULE_RESOURCE_USAGE_SNAPSHOT = 'on'
267
+
268
+ await withModuleResourceUsage(
269
+ { moduleId: 'customers', surface: 'api', operation: 'GET /api/customers/people' },
270
+ async () => undefined,
271
+ )
272
+
273
+ // The flush is fire-and-forget (no fs.*Sync calls on the hot path), so wait for the async
274
+ // write to land instead of asserting on it synchronously.
275
+ const ownSnapshotPath = path.join(tempDir!, `process-${process.pid}.json`)
276
+ const start = Date.now()
277
+ while (!fs.existsSync(ownSnapshotPath)) {
278
+ if (Date.now() - start > 2000) throw new Error('Timed out waiting for async snapshot flush')
279
+ await new Promise((resolve) => setTimeout(resolve, 10))
280
+ }
281
+ const ownPayload = JSON.parse(fs.readFileSync(ownSnapshotPath, 'utf8'))
282
+ expect(ownPayload.entries.some((entry: { moduleId: string }) => entry.moduleId === 'customers')).toBe(true)
283
+
284
+ // Simulate another process's snapshot file and confirm getModuleResourceUsageReport() merges
285
+ // it in via a single consolidated directory scan.
286
+ const otherPid = process.pid + 1
287
+ fs.writeFileSync(
288
+ path.join(tempDir!, `process-${otherPid}.json`),
289
+ JSON.stringify({
290
+ pid: otherPid,
291
+ generatedAt: new Date().toISOString(),
292
+ startedAt: new Date().toISOString(),
293
+ entries: [{
294
+ moduleId: 'sales',
295
+ surface: 'worker',
296
+ operation: 'sales:workers:sync',
297
+ resourceId: null,
298
+ calls: 3,
299
+ errors: 0,
300
+ concurrentCalls: 0,
301
+ totalDurationMs: 30,
302
+ maxDurationMs: 10,
303
+ p95DurationMs: 10,
304
+ totalCpuUserMs: 5,
305
+ totalCpuSystemMs: 1,
306
+ maxCpuMs: 2,
307
+ totalHeapDeltaBytes: 1024,
308
+ positiveHeapDeltaBytes: 1024,
309
+ maxHeapDeltaBytes: 512,
310
+ totalRssDeltaBytes: 2048,
311
+ positiveRssDeltaBytes: 2048,
312
+ maxRssDeltaBytes: 1024,
313
+ firstSeenAt: new Date().toISOString(),
314
+ lastSeenAt: new Date().toISOString(),
315
+ }],
316
+ buckets: [],
317
+ }),
318
+ )
319
+
320
+ const report = getModuleResourceUsageReport()
321
+ expect(report.modules.map((module) => module.moduleId)).toEqual(expect.arrayContaining(['customers', 'sales']))
322
+ expect(report.modules.find((module) => module.moduleId === 'sales')?.calls).toBe(3)
323
+ })
324
+
325
+ it('prunes a stale snapshot file from another process instead of leaving it on disk forever', async () => {
326
+ process.env.OM_MODULE_RESOURCE_USAGE_SNAPSHOT = 'on'
327
+
328
+ const stalePid = process.pid + 2
329
+ const stalePath = path.join(tempDir!, `process-${stalePid}.json`)
330
+ fs.writeFileSync(
331
+ stalePath,
332
+ JSON.stringify({
333
+ pid: stalePid,
334
+ generatedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(),
335
+ startedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(),
336
+ entries: [],
337
+ buckets: [],
338
+ }),
339
+ )
340
+
341
+ // readSnapshotPayloads() runs as part of building the report; the prune it triggers is
342
+ // fire-and-forget, so poll for the file to disappear instead of asserting synchronously.
343
+ getModuleResourceUsageReport()
344
+ const start = Date.now()
345
+ while (fs.existsSync(stalePath)) {
346
+ if (Date.now() - start > 2000) throw new Error('Timed out waiting for stale snapshot to be pruned')
347
+ await new Promise((resolve) => setTimeout(resolve, 10))
348
+ }
349
+ expect(fs.existsSync(stalePath)).toBe(false)
350
+ })
351
+ })