@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +1 -0
- package/dist/lib/modules/resource-usage.js +904 -0
- package/dist/lib/modules/resource-usage.js.map +7 -0
- package/dist/lib/number.js +14 -0
- package/dist/lib/number.js.map +7 -0
- package/dist/lib/search/config.js +2 -5
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/registry.js.map +2 -2
- package/package.json +2 -2
- package/src/lib/__tests__/number.test.ts +33 -0
- package/src/lib/modules/__tests__/resource-usage.test.ts +351 -0
- package/src/lib/modules/resource-usage.ts +1213 -0
- package/src/lib/number.ts +14 -0
- package/src/lib/search/config.ts +2 -5
- package/src/modules/registry.ts +2 -0
|
@@ -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
|
+
})
|