@prosopo/user-access-policy 3.14.14 → 3.14.15

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.
Files changed (65) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +2 -5
  2. package/.turbo/turbo-build$colon$tsc.log +14 -17
  3. package/.turbo/turbo-build.log +3 -9
  4. package/CHANGELOG.md +18 -0
  5. package/LICENSE +201 -0
  6. package/package.json +83 -81
  7. package/src/.export.ts +0 -65
  8. package/src/api/.export.ts +0 -25
  9. package/src/api/accessRulesApiClient.ts +0 -13
  10. package/src/api/delete/.export.ts +0 -18
  11. package/src/api/delete/deleteAllRules.ts +0 -47
  12. package/src/api/delete/deleteRuleGroups.ts +0 -96
  13. package/src/api/delete/deleteRules.ts +0 -87
  14. package/src/api/read/.export.ts +0 -25
  15. package/src/api/read/fetchRules.ts +0 -96
  16. package/src/api/read/findRuleIds.ts +0 -97
  17. package/src/api/read/getMissingIds.ts +0 -81
  18. package/src/api/ruleApiRoutes.ts +0 -146
  19. package/src/api/rulesApiClient.ts +0 -154
  20. package/src/api/write/.export.ts +0 -15
  21. package/src/api/write/insertRules.ts +0 -185
  22. package/src/api/write/rehashRules.ts +0 -85
  23. package/src/classifyBrowser.ts +0 -77
  24. package/src/classifyOs.ts +0 -74
  25. package/src/headerMatch.ts +0 -180
  26. package/src/matchedRule.ts +0 -99
  27. package/src/mongoose/.export.ts +0 -15
  28. package/src/mongoose/mongooseRuleSchema.ts +0 -75
  29. package/src/redis/.export.ts +0 -17
  30. package/src/redis/reader/redisAggregate.ts +0 -157
  31. package/src/redis/reader/redisRulesQuery.ts +0 -268
  32. package/src/redis/reader/redisRulesReader.ts +0 -738
  33. package/src/redis/reader/redisRulesSplitQuery.ts +0 -234
  34. package/src/redis/redisClient.ts +0 -125
  35. package/src/redis/redisRuleIndex.ts +0 -121
  36. package/src/redis/redisRulesStorage.ts +0 -68
  37. package/src/redis/redisRulesWriter.ts +0 -172
  38. package/src/rule.ts +0 -122
  39. package/src/ruleInput/.export.ts +0 -19
  40. package/src/ruleInput/policyInput.ts +0 -120
  41. package/src/ruleInput/ruleInput.ts +0 -123
  42. package/src/ruleInput/userScopeInput.ts +0 -142
  43. package/src/ruleRecord.ts +0 -77
  44. package/src/rulesStorage.ts +0 -90
  45. package/src/tests/blockingRuleShapes.unit.test.ts +0 -244
  46. package/src/tests/classifyBrowser.unit.test.ts +0 -138
  47. package/src/tests/classifyOs.unit.test.ts +0 -97
  48. package/src/tests/headerMatch.unit.test.ts +0 -350
  49. package/src/tests/insertRulesEndpoint.unit.test.ts +0 -89
  50. package/src/tests/matchedRule.unit.test.ts +0 -184
  51. package/src/tests/policyInput.unit.test.ts +0 -218
  52. package/src/tests/redis/deferredPoolClause.benchmark.ts +0 -249
  53. package/src/tests/redis/reader/redisAggregate.unit.test.ts +0 -121
  54. package/src/tests/redis/reader/redisRulesQuery.unit.test.ts +0 -349
  55. package/src/tests/redis/reader/redisRulesSplitQuery.unit.test.ts +0 -297
  56. package/src/tests/redis/redisRulesReaderLoad.benchmark.integration.test.ts +0 -520
  57. package/src/tests/redis/redisRulesReaderRank.benchmark.integration.test.ts +0 -350
  58. package/src/tests/redis/redisRulesStorage.integration.test.ts +0 -1811
  59. package/src/tests/testLogger.ts +0 -38
  60. package/src/tests/transformRule.unit.test.ts +0 -345
  61. package/src/transformRule.ts +0 -133
  62. package/tsconfig.cjs.json +0 -41
  63. package/tsconfig.json +0 -47
  64. package/tsconfig.tsbuildinfo +0 -1
  65. package/tsconfig.types.json +0 -9
@@ -1,520 +0,0 @@
1
- // Copyright 2021-2026 Prosopo (UK) Ltd.
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
-
15
- import { chunkIntoBatches, executeBatchesSequentially } from "@prosopo/common";
16
- import type { Logger } from "@prosopo/logger";
17
- import {
18
- type RedisConnection,
19
- createTestRedisConnection,
20
- setupRedisIndex,
21
- } from "@prosopo/redis-client";
22
- import type { RedisClientType } from "redis";
23
- import { afterAll, beforeAll, describe, expect, test } from "vitest";
24
- import { RedisRulesReader } from "#policy/redis/reader/redisRulesReader.js";
25
- import {
26
- ACCESS_RULES_REDIS_INDEX_NAME,
27
- accessRulesRedisIndex,
28
- getAccessRuleRedisKey,
29
- } from "#policy/redis/redisRuleIndex.js";
30
- import { getRedisRuleValue } from "#policy/redis/redisRulesWriter.js";
31
- import { AccessPolicyType, type AccessRule } from "#policy/rule.js";
32
- import { FilterScopeMatch } from "#policy/rulesStorage.js";
33
-
34
- // Regression barrier for the block-lookup hot path against the shape
35
- // of a large bulk-ban rule population.
36
- //
37
- // What this test defends against:
38
- // 1. A bulk-ban script inserts on the order of 17k access-policy
39
- // rules, mostly IP-only (individual IPs plus a few CIDR aggregates,
40
- // no other scope fields).
41
- // 2. Every incoming captcha request runs FT.AGGREGATE against
42
- // `index:user-access-rules` with a strict-match ranked query. That
43
- // query's LOAD + APPLY(exists()×11) + SORTBY step scales linearly
44
- // in the tenant's total rule count. At 17k rules under production
45
- // traffic the tail latency degrades sharply and upstream 502s
46
- // cascade.
47
- // 3. This benchmark seeds a matching rule shape and asserts the
48
- // split-query path's latency stays flat regardless of population
49
- // size — every probe hits its own index posting list (numericIp,
50
- // numericIpMaskMin) so the cost is O(matches for this request),
51
- // not O(total rules).
52
-
53
- const IP_RULE_COUNT = 17_000;
54
- const CIDR_RULE_COUNT = 1_300;
55
- const MIXED_RULE_COUNT = 1_000;
56
- const QUERY_COUNT = 300;
57
-
58
- // Concurrent-storm config. Emulates a frontend retry storm: N requests
59
- // in flight against Redis at once, sustained across multiple waves,
60
- // with an identical banned scope so every request would hit the same
61
- // FT probes. In production the process-wide verdict cache absorbs
62
- // waves 2..N; this benchmark measures the reader path directly (no
63
- // cache) so wave 1 numbers stand in for what the cache is protecting
64
- // and wave 2..N numbers show the split query's steady-state behaviour
65
- // under sustained connection multiplexing.
66
- const STORM_CONCURRENCY = 100;
67
- const STORM_WAVE_COUNT = 10;
68
-
69
- // CI thresholds. Localhost warm-cache measurements land around
70
- // p50 = 5-15 ms, p99 = 30-50 ms; thresholds set well above that to
71
- // absorb noisy shared runners. A regression to the single-query design
72
- // against this rule population would blow through both by
73
- // order-of-magnitude margins, so the ceilings are the alarm not a
74
- // performance target.
75
- const P50_LATENCY_MS = 120;
76
- const P99_LATENCY_MS = 400;
77
-
78
- // Storm-mode thresholds: same story but with headroom for the extra
79
- // tail from redis-client command queuing under N-in-flight concurrency.
80
- // The single TCP connection multiplexes commands, so p99 grows with
81
- // concurrency even when each command is individually cheap.
82
- const STORM_P50_LATENCY_MS = 200;
83
- const STORM_P99_LATENCY_MS = 800;
84
-
85
- const ipv4 = (i: number): bigint => {
86
- // 10.0.0.0/8 range — private, unambiguous, plenty of room to spread
87
- // out over 17k unique addresses without wrapping past /8.
88
- const base = 10n << 24n;
89
- return base + BigInt(i);
90
- };
91
-
92
- const ipv4CidrRange = (i: number): { min: bigint; max: bigint } => {
93
- // 192.168.<i%256>.0/24 style ranges. Deterministic and disjoint from
94
- // the individual-IP range above so tests can target either shape
95
- // unambiguously.
96
- const octet = i % 256;
97
- const base = (192n << 24n) + (168n << 16n) + (BigInt(octet) << 8n);
98
- return { min: base, max: base + 255n };
99
- };
100
-
101
- // Deterministic rule generators matched to the incident population shape.
102
- const buildIpBlockRule = (i: number): AccessRule => ({
103
- type: AccessPolicyType.Block,
104
- description: `bulk-ban-ip-${i}`,
105
- numericIp: ipv4(i),
106
- });
107
-
108
- const buildCidrBlockRule = (i: number): AccessRule => {
109
- const { min, max } = ipv4CidrRange(i);
110
- return {
111
- type: AccessPolicyType.Block,
112
- description: `bulk-ban-cidr-${i}`,
113
- numericIpMaskMin: min,
114
- numericIpMaskMax: max,
115
- };
116
- };
117
-
118
- // A handful of "normal" client-scoped rules so the rule set isn't
119
- // homogeneous — matches production's mixed anomaly-detector +
120
- // manual-rule shape.
121
- const buildMixedRule = (i: number): AccessRule => {
122
- const variant = i % 4;
123
- if (variant === 0) {
124
- return {
125
- type: AccessPolicyType.Block,
126
- description: `mixed-${i}`,
127
- clientId: `client${i % 30}`,
128
- ja4Hash: `t13d_load_${i % 50}`,
129
- };
130
- }
131
- if (variant === 1) {
132
- return {
133
- type: AccessPolicyType.Restrict,
134
- description: `mixed-${i}`,
135
- countryCode: `C${i % 30}`,
136
- };
137
- }
138
- if (variant === 2) {
139
- return {
140
- type: AccessPolicyType.Block,
141
- description: `mixed-${i}`,
142
- asn: 1000 + (i % 500),
143
- };
144
- }
145
- return {
146
- type: AccessPolicyType.Block,
147
- description: `mixed-${i}`,
148
- clientId: `client${i % 30}`,
149
- userAgentHash: `ua${i % 100}`,
150
- };
151
- };
152
-
153
- const percentile = (sortedMs: number[], q: number): number => {
154
- if (sortedMs.length === 0) return 0;
155
- const idx = Math.min(
156
- sortedMs.length - 1,
157
- Math.floor((sortedMs.length - 1) * q),
158
- );
159
- return sortedMs[idx] ?? 0;
160
- };
161
-
162
- describe("redisRulesReader load-shape benchmark (17k IP-only rules)", () => {
163
- let redisConnection: RedisConnection;
164
- let redisClient: RedisClientType;
165
- let reader: RedisRulesReader;
166
-
167
- // Every member is a no-op sink, except `with`, which has to return a child
168
- // logger — setupRedisIndex calls `logger.with({ name })` and logs through
169
- // the result. Returning the proxy itself keeps the child a sink too.
170
- const mockLogger = new Proxy(
171
- {},
172
- {
173
- get: (_target, property) =>
174
- property === "with" ? () => mockLogger : () => {},
175
- },
176
- ) as unknown as Logger;
177
-
178
- const seededKeys: string[] = [];
179
-
180
- beforeAll(async () => {
181
- redisConnection = createTestRedisConnection();
182
- redisClient = await setupRedisIndex(
183
- redisConnection,
184
- accessRulesRedisIndex,
185
- mockLogger,
186
- ).getClient();
187
- reader = new RedisRulesReader(redisClient, mockLogger);
188
-
189
- const rules: AccessRule[] = [];
190
- for (let i = 0; i < IP_RULE_COUNT; i++) rules.push(buildIpBlockRule(i));
191
- for (let i = 0; i < CIDR_RULE_COUNT; i++) rules.push(buildCidrBlockRule(i));
192
- for (let i = 0; i < MIXED_RULE_COUNT; i++) rules.push(buildMixedRule(i));
193
-
194
- await executeBatchesSequentially(
195
- chunkIntoBatches(rules, 1000),
196
- async (batch) => {
197
- const multi = redisClient.multi();
198
- for (const rule of batch) {
199
- const key = getAccessRuleRedisKey(rule);
200
- seededKeys.push(key);
201
- multi.hSet(key, getRedisRuleValue(rule));
202
- }
203
- await multi.exec();
204
- },
205
- );
206
-
207
- const indexInfo = await redisClient.ft.info(ACCESS_RULES_REDIS_INDEX_NAME);
208
- // Description is unique per rule so every rule should own its
209
- // key; allow 10% margin for any incidental dedupe.
210
- const expectedRules = IP_RULE_COUNT + CIDR_RULE_COUNT + MIXED_RULE_COUNT;
211
- expect(indexInfo.num_docs).toBeGreaterThan(expectedRules * 0.9);
212
- }, 300_000);
213
-
214
- afterAll(async () => {
215
- await executeBatchesSequentially(
216
- chunkIntoBatches(seededKeys, 1000),
217
- async (batch) => {
218
- const multi = redisClient.multi();
219
- for (const key of batch) {
220
- multi.del(key);
221
- }
222
- await multi.exec();
223
- },
224
- );
225
- }, 60_000);
226
-
227
- test(`split-query hot path stays flat under ${IP_RULE_COUNT + CIDR_RULE_COUNT + MIXED_RULE_COUNT} rules`, async () => {
228
- // Warm-up: first call pays for RediSearch index page-in.
229
- await reader.findRules(
230
- {
231
- policyScope: { clientId: "warmup" },
232
- policyScopeMatch: FilterScopeMatch.Greedy,
233
- userScope: { numericIp: ipv4(999_999) },
234
- userScopeMatch: FilterScopeMatch.Greedy,
235
- blockOnly: true,
236
- },
237
- true,
238
- );
239
-
240
- const samples: number[] = [];
241
- for (let i = 0; i < QUERY_COUNT; i++) {
242
- // Half the queries target seeded IPs (hits), half target IPs
243
- // outside the seeded range (misses). Both must be fast.
244
- const isHit = i % 2 === 0;
245
- const numericIp = isHit
246
- ? ipv4(i % IP_RULE_COUNT)
247
- : ipv4(IP_RULE_COUNT + i + 1_000_000);
248
-
249
- const start = performance.now();
250
- await reader.findRules(
251
- {
252
- policyScope: { clientId: `client${i % 30}` },
253
- policyScopeMatch: FilterScopeMatch.Greedy,
254
- userScope: {
255
- numericIp,
256
- ja4Hash: `t13d_load_${i % 50}`,
257
- userAgentHash: `ua${i % 100}`,
258
- countryCode: `C${i % 30}`,
259
- asn: 1000 + (i % 500),
260
- },
261
- userScopeMatch: FilterScopeMatch.Greedy,
262
- blockOnly: true,
263
- },
264
- true,
265
- );
266
- samples.push(performance.now() - start);
267
- }
268
-
269
- samples.sort((a, b) => a - b);
270
- const p50 = percentile(samples, 0.5);
271
- const p99 = percentile(samples, 0.99);
272
- const avg = samples.reduce((a, b) => a + b, 0) / samples.length;
273
-
274
- console.log(
275
- `split-query load: avg=${avg.toFixed(2)}ms p50=${p50.toFixed(2)}ms p99=${p99.toFixed(2)}ms n=${samples.length} (${IP_RULE_COUNT} IPs + ${CIDR_RULE_COUNT} CIDRs + ${MIXED_RULE_COUNT} mixed)`,
276
- );
277
-
278
- expect(p50).toBeLessThan(P50_LATENCY_MS);
279
- expect(p99).toBeLessThan(P99_LATENCY_MS);
280
- }, 300_000);
281
-
282
- test("finds exact-IP bulk-ban rule for matching request", async () => {
283
- // Pick an IP known to have been seeded as an individual-IP ban.
284
- const targetIp = ipv4(42);
285
- const results = await reader.findRules(
286
- {
287
- policyScope: { clientId: "client1" },
288
- policyScopeMatch: FilterScopeMatch.Greedy,
289
- userScope: {
290
- numericIp: targetIp,
291
- ja4Hash: "t13d_load_1",
292
- },
293
- userScopeMatch: FilterScopeMatch.Greedy,
294
- blockOnly: true,
295
- },
296
- true,
297
- );
298
-
299
- // The exact-IP rule must be among the returned candidates —
300
- // this is the "rule that must never be missed" property.
301
- const found = results.find((r) => r.numericIp === targetIp);
302
- expect(found).toBeDefined();
303
- expect(found?.type).toBe(AccessPolicyType.Block);
304
- }, 60_000);
305
-
306
- test("finds CIDR-range bulk-ban rule for request IP inside the range", async () => {
307
- // Pick a CIDR range and a request IP known to fall inside it.
308
- const { min, max } = ipv4CidrRange(7);
309
- const requestIp = min + 42n;
310
- expect(requestIp).toBeLessThanOrEqual(max);
311
-
312
- const results = await reader.findRules(
313
- {
314
- policyScope: { clientId: "client1" },
315
- policyScopeMatch: FilterScopeMatch.Greedy,
316
- userScope: { numericIp: requestIp },
317
- userScopeMatch: FilterScopeMatch.Greedy,
318
- blockOnly: true,
319
- },
320
- true,
321
- );
322
-
323
- const found = results.find(
324
- (r) =>
325
- r.numericIpMaskMin === min &&
326
- r.numericIpMaskMax === max &&
327
- r.type === AccessPolicyType.Block,
328
- );
329
- expect(found).toBeDefined();
330
- }, 60_000);
331
-
332
- test("finds ja4-scoped rule via the split-query ja4 probe", async () => {
333
- // The mixed population includes ja4-scoped rules; a request
334
- // with matching ja4Hash must surface the rule even when the
335
- // request IP has no matching numericIp entry.
336
- const results = await reader.findRules(
337
- {
338
- policyScope: { clientId: "client0" },
339
- policyScopeMatch: FilterScopeMatch.Greedy,
340
- userScope: {
341
- numericIp: ipv4(999_998),
342
- ja4Hash: "t13d_load_0",
343
- },
344
- userScopeMatch: FilterScopeMatch.Greedy,
345
- blockOnly: true,
346
- },
347
- true,
348
- );
349
-
350
- const ja4Rule = results.find(
351
- (r) => r.ja4Hash === "t13d_load_0" && r.clientId === "client0",
352
- );
353
- expect(ja4Rule).toBeDefined();
354
- }, 60_000);
355
-
356
- test("returns no match when request IP is outside every rule range", async () => {
357
- // Way outside every seeded range.
358
- const results = await reader.findRules(
359
- {
360
- policyScope: { clientId: "client99" },
361
- policyScopeMatch: FilterScopeMatch.Greedy,
362
- userScope: { numericIp: ipv4(9_999_999) },
363
- userScopeMatch: FilterScopeMatch.Greedy,
364
- blockOnly: true,
365
- },
366
- true,
367
- );
368
-
369
- // No IP-scoped rule should apply; only fall-through matches
370
- // (mixed rules keyed on other fields) could surface here, and
371
- // we're not populating any of those on this request.
372
- const ipMatch = results.find(
373
- (r) =>
374
- r.numericIp !== undefined ||
375
- (r.numericIpMaskMin !== undefined && r.numericIpMaskMax !== undefined),
376
- );
377
- expect(ipMatch).toBeUndefined();
378
- }, 60_000);
379
-
380
- test(`concurrent retry-storm: ${STORM_CONCURRENCY} in-flight × ${STORM_WAVE_COUNT} waves against ${IP_RULE_COUNT + CIDR_RULE_COUNT + MIXED_RULE_COUNT} rules`, async () => {
381
- // Emulates the incident's frontend-retry pattern: the client
382
- // keeps retrying the same banned scope, and each retry starts
383
- // before the previous one has completed. In production the
384
- // provider-side verdict cache absorbs waves 2..N; here we run
385
- // the reader directly (no cache) so the numbers show the
386
- // worst-case Redis-only path — what the cache is protecting
387
- // against under stampede.
388
- //
389
- // A single scope is used across every request so the FT
390
- // probes are byte-for-byte identical. That means every wave
391
- // is a candidate for the same posting-list intersections;
392
- // under the *old* single-query path this would compound with
393
- // the per-request APPLY(exists()×11) cost and crush tail
394
- // latency. Under the split path each probe still hits its
395
- // own posting list and the only added cost is command
396
- // queuing on the shared TCP connection.
397
-
398
- // Warm-up: page the index in and let redis-client establish
399
- // pipelines before the first measured wave.
400
- await reader.findRules(
401
- {
402
- policyScope: { clientId: "client1" },
403
- policyScopeMatch: FilterScopeMatch.Greedy,
404
- userScope: { numericIp: ipv4(999_997) },
405
- userScopeMatch: FilterScopeMatch.Greedy,
406
- blockOnly: true,
407
- },
408
- true,
409
- );
410
-
411
- // Identical scope for every request. Hits a real rule
412
- // (ipv4(42) is in the seeded IP-ban range) so each response
413
- // carries the same non-empty verdict payload.
414
- const stormScope = {
415
- numericIp: ipv4(42),
416
- ja4Hash: "t13d_load_1",
417
- userAgentHash: "ua5",
418
- countryCode: "C5",
419
- asn: 1005,
420
- };
421
- const clientId = "client1";
422
-
423
- const allSamples: number[] = [];
424
- const perWave: Array<{
425
- wave: number;
426
- p50: number;
427
- p99: number;
428
- min: number;
429
- max: number;
430
- elapsedMs: number;
431
- }> = [];
432
-
433
- for (let wave = 0; wave < STORM_WAVE_COUNT; wave++) {
434
- const waveStart = performance.now();
435
- const waveSamples: number[] = [];
436
-
437
- // Fire all N requests in parallel and wait for the whole
438
- // wave to complete. This mirrors a burst of retries
439
- // arriving inside one event-loop turn — every request
440
- // starts before any has completed, so the redis client's
441
- // command queue depth peaks at N.
442
- await Promise.all(
443
- Array.from({ length: STORM_CONCURRENCY }, async () => {
444
- const start = performance.now();
445
- const results = await reader.findRules(
446
- {
447
- policyScope: { clientId },
448
- policyScopeMatch: FilterScopeMatch.Greedy,
449
- userScope: stormScope,
450
- userScopeMatch: FilterScopeMatch.Greedy,
451
- blockOnly: true,
452
- },
453
- true,
454
- );
455
- const dur = performance.now() - start;
456
- waveSamples.push(dur);
457
- // Sanity: the storm scope has a matching exact-IP
458
- // block rule, so every response must include it.
459
- // A regression that drops responses under
460
- // concurrency shows up here rather than as a
461
- // silent under-count of samples.
462
- expect(
463
- results.find((r) => r.numericIp === stormScope.numericIp),
464
- ).toBeDefined();
465
- }),
466
- );
467
-
468
- const waveElapsed = performance.now() - waveStart;
469
- waveSamples.sort((a, b) => a - b);
470
- const p50 = percentile(waveSamples, 0.5);
471
- const p99 = percentile(waveSamples, 0.99);
472
- const min = waveSamples[0] ?? 0;
473
- const max = waveSamples[waveSamples.length - 1] ?? 0;
474
- perWave.push({
475
- wave: wave + 1,
476
- p50,
477
- p99,
478
- min,
479
- max,
480
- elapsedMs: waveElapsed,
481
- });
482
- allSamples.push(...waveSamples);
483
- }
484
-
485
- allSamples.sort((a, b) => a - b);
486
- const overallP50 = percentile(allSamples, 0.5);
487
- const overallP99 = percentile(allSamples, 0.99);
488
- const overallAvg =
489
- allSamples.reduce((a, b) => a + b, 0) / allSamples.length;
490
- const totalRequests = STORM_CONCURRENCY * STORM_WAVE_COUNT;
491
- const totalElapsed = perWave.reduce((sum, w) => sum + w.elapsedMs, 0);
492
- const throughput = (totalRequests * 1000) / totalElapsed;
493
-
494
- console.log(
495
- `storm ${STORM_CONCURRENCY}×${STORM_WAVE_COUNT}: avg=${overallAvg.toFixed(2)}ms p50=${overallP50.toFixed(2)}ms p99=${overallP99.toFixed(2)}ms ` +
496
- `throughput=${throughput.toFixed(0)} req/s total=${totalElapsed.toFixed(0)}ms`,
497
- );
498
- // Per-wave breakdown so a regression that only affects
499
- // first-wave stampede (cache-miss shape) shows up
500
- // independently from steady-state numbers.
501
- for (const w of perWave) {
502
- console.log(
503
- ` wave ${w.wave.toString().padStart(2)}: p50=${w.p50.toFixed(2)}ms p99=${w.p99.toFixed(2)}ms ` +
504
- `min=${w.min.toFixed(2)}ms max=${w.max.toFixed(2)}ms wave-elapsed=${w.elapsedMs.toFixed(0)}ms`,
505
- );
506
- }
507
-
508
- expect(overallP50).toBeLessThan(STORM_P50_LATENCY_MS);
509
- expect(overallP99).toBeLessThan(STORM_P99_LATENCY_MS);
510
- // Wave-1 stampede check: even the cold-cache wave (which is
511
- // what the production cache absorbs) has to stay tractable —
512
- // a regression that only hurts first-request-per-scope
513
- // latency would otherwise hide behind steady-state numbers.
514
- const firstWave = perWave[0];
515
- expect(firstWave).toBeDefined();
516
- if (firstWave) {
517
- expect(firstWave.p99).toBeLessThan(STORM_P99_LATENCY_MS);
518
- }
519
- }, 300_000);
520
- });