@prosopo/user-access-policy 3.11.3 → 3.12.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.
Files changed (69) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +38 -37
  2. package/.turbo/turbo-build$colon$tsc.log +14 -14
  3. package/.turbo/turbo-build.log +39 -38
  4. package/CHANGELOG.md +30 -0
  5. package/dist/api/delete/deleteRules.d.ts +2 -2
  6. package/dist/api/delete/deleteRules.d.ts.map +1 -1
  7. package/dist/api/delete/deleteRules.js.map +1 -1
  8. package/dist/api/read/findRuleIds.d.ts +1 -1
  9. package/dist/api/read/findRuleIds.d.ts.map +1 -1
  10. package/dist/api/read/findRuleIds.js.map +1 -1
  11. package/dist/cjs/redis/reader/redisRulesQuery.cjs +3 -2
  12. package/dist/cjs/redis/reader/redisRulesReader.cjs +101 -1
  13. package/dist/cjs/redis/reader/redisRulesSplitQuery.cjs +78 -0
  14. package/dist/cjs/redis/redisRuleIndex.cjs +3 -1
  15. package/dist/cjs/redis/redisRulesWriter.cjs +17 -6
  16. package/dist/cjs/rule.cjs +2 -0
  17. package/dist/cjs/ruleInput/policyInput.cjs +9 -1
  18. package/dist/redis/reader/redisRulesQuery.d.ts.map +1 -1
  19. package/dist/redis/reader/redisRulesQuery.js +4 -3
  20. package/dist/redis/reader/redisRulesQuery.js.map +1 -1
  21. package/dist/redis/reader/redisRulesReader.d.ts +1 -0
  22. package/dist/redis/reader/redisRulesReader.d.ts.map +1 -1
  23. package/dist/redis/reader/redisRulesReader.js +101 -1
  24. package/dist/redis/reader/redisRulesReader.js.map +1 -1
  25. package/dist/redis/reader/redisRulesSplitQuery.d.ts +8 -0
  26. package/dist/redis/reader/redisRulesSplitQuery.d.ts.map +1 -0
  27. package/dist/redis/reader/redisRulesSplitQuery.js +78 -0
  28. package/dist/redis/reader/redisRulesSplitQuery.js.map +1 -0
  29. package/dist/redis/redisRuleIndex.d.ts +1 -0
  30. package/dist/redis/redisRuleIndex.d.ts.map +1 -1
  31. package/dist/redis/redisRuleIndex.js +2 -0
  32. package/dist/redis/redisRuleIndex.js.map +1 -1
  33. package/dist/redis/redisRulesWriter.d.ts +1 -1
  34. package/dist/redis/redisRulesWriter.d.ts.map +1 -1
  35. package/dist/redis/redisRulesWriter.js +14 -3
  36. package/dist/redis/redisRulesWriter.js.map +1 -1
  37. package/dist/rule.d.ts +1 -0
  38. package/dist/rule.d.ts.map +1 -1
  39. package/dist/rule.js +3 -1
  40. package/dist/rule.js.map +1 -1
  41. package/dist/ruleInput/policyInput.d.ts +2 -2
  42. package/dist/ruleInput/policyInput.d.ts.map +1 -1
  43. package/dist/ruleInput/policyInput.js +10 -2
  44. package/dist/ruleInput/policyInput.js.map +1 -1
  45. package/dist/ruleInput/ruleInput.d.ts +6 -6
  46. package/dist/ruleInput/ruleInput.d.ts.map +1 -1
  47. package/dist/ruleInput/ruleInput.js.map +1 -1
  48. package/dist/tests/redis/reader/redisRulesSplitQuery.unit.test.d.ts +2 -0
  49. package/dist/tests/redis/reader/redisRulesSplitQuery.unit.test.d.ts.map +1 -0
  50. package/dist/tests/redis/reader/redisRulesSplitQuery.unit.test.js +79 -0
  51. package/dist/tests/redis/reader/redisRulesSplitQuery.unit.test.js.map +1 -0
  52. package/dist/tests/redis/redisRulesReaderLoad.benchmark.integration.test.d.ts +2 -0
  53. package/dist/tests/redis/redisRulesReaderLoad.benchmark.integration.test.d.ts.map +1 -0
  54. package/dist/tests/redis/redisRulesReaderLoad.benchmark.integration.test.js +288 -0
  55. package/dist/tests/redis/redisRulesReaderLoad.benchmark.integration.test.js.map +1 -0
  56. package/package.json +3 -3
  57. package/src/api/delete/deleteRules.ts +7 -1
  58. package/src/api/read/findRuleIds.ts +3 -1
  59. package/src/redis/reader/redisRulesQuery.ts +12 -2
  60. package/src/redis/reader/redisRulesReader.ts +145 -7
  61. package/src/redis/reader/redisRulesSplitQuery.ts +170 -0
  62. package/src/redis/redisRuleIndex.ts +6 -0
  63. package/src/redis/redisRulesWriter.ts +19 -5
  64. package/src/rule.ts +12 -0
  65. package/src/ruleInput/policyInput.ts +17 -3
  66. package/src/ruleInput/ruleInput.ts +7 -1
  67. package/src/tests/redis/reader/redisRulesSplitQuery.unit.test.ts +132 -0
  68. package/src/tests/redis/redisRulesReaderLoad.benchmark.integration.test.ts +516 -0
  69. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,516 @@
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
+ const mockLogger = new Proxy(
168
+ {},
169
+ {
170
+ get: () => () => {},
171
+ },
172
+ ) as unknown as Logger;
173
+
174
+ const seededKeys: string[] = [];
175
+
176
+ beforeAll(async () => {
177
+ redisConnection = createTestRedisConnection();
178
+ redisClient = await setupRedisIndex(
179
+ redisConnection,
180
+ accessRulesRedisIndex,
181
+ mockLogger,
182
+ ).getClient();
183
+ reader = new RedisRulesReader(redisClient, mockLogger);
184
+
185
+ const rules: AccessRule[] = [];
186
+ for (let i = 0; i < IP_RULE_COUNT; i++) rules.push(buildIpBlockRule(i));
187
+ for (let i = 0; i < CIDR_RULE_COUNT; i++) rules.push(buildCidrBlockRule(i));
188
+ for (let i = 0; i < MIXED_RULE_COUNT; i++) rules.push(buildMixedRule(i));
189
+
190
+ await executeBatchesSequentially(
191
+ chunkIntoBatches(rules, 1000),
192
+ async (batch) => {
193
+ const multi = redisClient.multi();
194
+ for (const rule of batch) {
195
+ const key = getAccessRuleRedisKey(rule);
196
+ seededKeys.push(key);
197
+ multi.hSet(key, getRedisRuleValue(rule));
198
+ }
199
+ await multi.exec();
200
+ },
201
+ );
202
+
203
+ const indexInfo = await redisClient.ft.info(ACCESS_RULES_REDIS_INDEX_NAME);
204
+ // Description is unique per rule so every rule should own its
205
+ // key; allow 10% margin for any incidental dedupe.
206
+ const expectedRules = IP_RULE_COUNT + CIDR_RULE_COUNT + MIXED_RULE_COUNT;
207
+ expect(indexInfo.num_docs).toBeGreaterThan(expectedRules * 0.9);
208
+ }, 300_000);
209
+
210
+ afterAll(async () => {
211
+ await executeBatchesSequentially(
212
+ chunkIntoBatches(seededKeys, 1000),
213
+ async (batch) => {
214
+ const multi = redisClient.multi();
215
+ for (const key of batch) {
216
+ multi.del(key);
217
+ }
218
+ await multi.exec();
219
+ },
220
+ );
221
+ }, 60_000);
222
+
223
+ test(`split-query hot path stays flat under ${IP_RULE_COUNT + CIDR_RULE_COUNT + MIXED_RULE_COUNT} rules`, async () => {
224
+ // Warm-up: first call pays for RediSearch index page-in.
225
+ await reader.findRules(
226
+ {
227
+ policyScope: { clientId: "warmup" },
228
+ policyScopeMatch: FilterScopeMatch.Greedy,
229
+ userScope: { numericIp: ipv4(999_999) },
230
+ userScopeMatch: FilterScopeMatch.Greedy,
231
+ blockOnly: true,
232
+ },
233
+ true,
234
+ );
235
+
236
+ const samples: number[] = [];
237
+ for (let i = 0; i < QUERY_COUNT; i++) {
238
+ // Half the queries target seeded IPs (hits), half target IPs
239
+ // outside the seeded range (misses). Both must be fast.
240
+ const isHit = i % 2 === 0;
241
+ const numericIp = isHit
242
+ ? ipv4(i % IP_RULE_COUNT)
243
+ : ipv4(IP_RULE_COUNT + i + 1_000_000);
244
+
245
+ const start = performance.now();
246
+ await reader.findRules(
247
+ {
248
+ policyScope: { clientId: `client${i % 30}` },
249
+ policyScopeMatch: FilterScopeMatch.Greedy,
250
+ userScope: {
251
+ numericIp,
252
+ ja4Hash: `t13d_load_${i % 50}`,
253
+ userAgentHash: `ua${i % 100}`,
254
+ countryCode: `C${i % 30}`,
255
+ asn: 1000 + (i % 500),
256
+ },
257
+ userScopeMatch: FilterScopeMatch.Greedy,
258
+ blockOnly: true,
259
+ },
260
+ true,
261
+ );
262
+ samples.push(performance.now() - start);
263
+ }
264
+
265
+ samples.sort((a, b) => a - b);
266
+ const p50 = percentile(samples, 0.5);
267
+ const p99 = percentile(samples, 0.99);
268
+ const avg = samples.reduce((a, b) => a + b, 0) / samples.length;
269
+
270
+ console.log(
271
+ `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)`,
272
+ );
273
+
274
+ expect(p50).toBeLessThan(P50_LATENCY_MS);
275
+ expect(p99).toBeLessThan(P99_LATENCY_MS);
276
+ }, 300_000);
277
+
278
+ test("finds exact-IP bulk-ban rule for matching request", async () => {
279
+ // Pick an IP known to have been seeded as an individual-IP ban.
280
+ const targetIp = ipv4(42);
281
+ const results = await reader.findRules(
282
+ {
283
+ policyScope: { clientId: "client1" },
284
+ policyScopeMatch: FilterScopeMatch.Greedy,
285
+ userScope: {
286
+ numericIp: targetIp,
287
+ ja4Hash: "t13d_load_1",
288
+ },
289
+ userScopeMatch: FilterScopeMatch.Greedy,
290
+ blockOnly: true,
291
+ },
292
+ true,
293
+ );
294
+
295
+ // The exact-IP rule must be among the returned candidates —
296
+ // this is the "rule that must never be missed" property.
297
+ const found = results.find((r) => r.numericIp === targetIp);
298
+ expect(found).toBeDefined();
299
+ expect(found?.type).toBe(AccessPolicyType.Block);
300
+ }, 60_000);
301
+
302
+ test("finds CIDR-range bulk-ban rule for request IP inside the range", async () => {
303
+ // Pick a CIDR range and a request IP known to fall inside it.
304
+ const { min, max } = ipv4CidrRange(7);
305
+ const requestIp = min + 42n;
306
+ expect(requestIp).toBeLessThanOrEqual(max);
307
+
308
+ const results = await reader.findRules(
309
+ {
310
+ policyScope: { clientId: "client1" },
311
+ policyScopeMatch: FilterScopeMatch.Greedy,
312
+ userScope: { numericIp: requestIp },
313
+ userScopeMatch: FilterScopeMatch.Greedy,
314
+ blockOnly: true,
315
+ },
316
+ true,
317
+ );
318
+
319
+ const found = results.find(
320
+ (r) =>
321
+ r.numericIpMaskMin === min &&
322
+ r.numericIpMaskMax === max &&
323
+ r.type === AccessPolicyType.Block,
324
+ );
325
+ expect(found).toBeDefined();
326
+ }, 60_000);
327
+
328
+ test("finds ja4-scoped rule via the split-query ja4 probe", async () => {
329
+ // The mixed population includes ja4-scoped rules; a request
330
+ // with matching ja4Hash must surface the rule even when the
331
+ // request IP has no matching numericIp entry.
332
+ const results = await reader.findRules(
333
+ {
334
+ policyScope: { clientId: "client0" },
335
+ policyScopeMatch: FilterScopeMatch.Greedy,
336
+ userScope: {
337
+ numericIp: ipv4(999_998),
338
+ ja4Hash: "t13d_load_0",
339
+ },
340
+ userScopeMatch: FilterScopeMatch.Greedy,
341
+ blockOnly: true,
342
+ },
343
+ true,
344
+ );
345
+
346
+ const ja4Rule = results.find(
347
+ (r) => r.ja4Hash === "t13d_load_0" && r.clientId === "client0",
348
+ );
349
+ expect(ja4Rule).toBeDefined();
350
+ }, 60_000);
351
+
352
+ test("returns no match when request IP is outside every rule range", async () => {
353
+ // Way outside every seeded range.
354
+ const results = await reader.findRules(
355
+ {
356
+ policyScope: { clientId: "client99" },
357
+ policyScopeMatch: FilterScopeMatch.Greedy,
358
+ userScope: { numericIp: ipv4(9_999_999) },
359
+ userScopeMatch: FilterScopeMatch.Greedy,
360
+ blockOnly: true,
361
+ },
362
+ true,
363
+ );
364
+
365
+ // No IP-scoped rule should apply; only fall-through matches
366
+ // (mixed rules keyed on other fields) could surface here, and
367
+ // we're not populating any of those on this request.
368
+ const ipMatch = results.find(
369
+ (r) =>
370
+ r.numericIp !== undefined ||
371
+ (r.numericIpMaskMin !== undefined && r.numericIpMaskMax !== undefined),
372
+ );
373
+ expect(ipMatch).toBeUndefined();
374
+ }, 60_000);
375
+
376
+ test(`concurrent retry-storm: ${STORM_CONCURRENCY} in-flight × ${STORM_WAVE_COUNT} waves against ${IP_RULE_COUNT + CIDR_RULE_COUNT + MIXED_RULE_COUNT} rules`, async () => {
377
+ // Emulates the incident's frontend-retry pattern: the client
378
+ // keeps retrying the same banned scope, and each retry starts
379
+ // before the previous one has completed. In production the
380
+ // provider-side verdict cache absorbs waves 2..N; here we run
381
+ // the reader directly (no cache) so the numbers show the
382
+ // worst-case Redis-only path — what the cache is protecting
383
+ // against under stampede.
384
+ //
385
+ // A single scope is used across every request so the FT
386
+ // probes are byte-for-byte identical. That means every wave
387
+ // is a candidate for the same posting-list intersections;
388
+ // under the *old* single-query path this would compound with
389
+ // the per-request APPLY(exists()×11) cost and crush tail
390
+ // latency. Under the split path each probe still hits its
391
+ // own posting list and the only added cost is command
392
+ // queuing on the shared TCP connection.
393
+
394
+ // Warm-up: page the index in and let redis-client establish
395
+ // pipelines before the first measured wave.
396
+ await reader.findRules(
397
+ {
398
+ policyScope: { clientId: "client1" },
399
+ policyScopeMatch: FilterScopeMatch.Greedy,
400
+ userScope: { numericIp: ipv4(999_997) },
401
+ userScopeMatch: FilterScopeMatch.Greedy,
402
+ blockOnly: true,
403
+ },
404
+ true,
405
+ );
406
+
407
+ // Identical scope for every request. Hits a real rule
408
+ // (ipv4(42) is in the seeded IP-ban range) so each response
409
+ // carries the same non-empty verdict payload.
410
+ const stormScope = {
411
+ numericIp: ipv4(42),
412
+ ja4Hash: "t13d_load_1",
413
+ userAgentHash: "ua5",
414
+ countryCode: "C5",
415
+ asn: 1005,
416
+ };
417
+ const clientId = "client1";
418
+
419
+ const allSamples: number[] = [];
420
+ const perWave: Array<{
421
+ wave: number;
422
+ p50: number;
423
+ p99: number;
424
+ min: number;
425
+ max: number;
426
+ elapsedMs: number;
427
+ }> = [];
428
+
429
+ for (let wave = 0; wave < STORM_WAVE_COUNT; wave++) {
430
+ const waveStart = performance.now();
431
+ const waveSamples: number[] = [];
432
+
433
+ // Fire all N requests in parallel and wait for the whole
434
+ // wave to complete. This mirrors a burst of retries
435
+ // arriving inside one event-loop turn — every request
436
+ // starts before any has completed, so the redis client's
437
+ // command queue depth peaks at N.
438
+ await Promise.all(
439
+ Array.from({ length: STORM_CONCURRENCY }, async () => {
440
+ const start = performance.now();
441
+ const results = await reader.findRules(
442
+ {
443
+ policyScope: { clientId },
444
+ policyScopeMatch: FilterScopeMatch.Greedy,
445
+ userScope: stormScope,
446
+ userScopeMatch: FilterScopeMatch.Greedy,
447
+ blockOnly: true,
448
+ },
449
+ true,
450
+ );
451
+ const dur = performance.now() - start;
452
+ waveSamples.push(dur);
453
+ // Sanity: the storm scope has a matching exact-IP
454
+ // block rule, so every response must include it.
455
+ // A regression that drops responses under
456
+ // concurrency shows up here rather than as a
457
+ // silent under-count of samples.
458
+ expect(
459
+ results.find((r) => r.numericIp === stormScope.numericIp),
460
+ ).toBeDefined();
461
+ }),
462
+ );
463
+
464
+ const waveElapsed = performance.now() - waveStart;
465
+ waveSamples.sort((a, b) => a - b);
466
+ const p50 = percentile(waveSamples, 0.5);
467
+ const p99 = percentile(waveSamples, 0.99);
468
+ const min = waveSamples[0] ?? 0;
469
+ const max = waveSamples[waveSamples.length - 1] ?? 0;
470
+ perWave.push({
471
+ wave: wave + 1,
472
+ p50,
473
+ p99,
474
+ min,
475
+ max,
476
+ elapsedMs: waveElapsed,
477
+ });
478
+ allSamples.push(...waveSamples);
479
+ }
480
+
481
+ allSamples.sort((a, b) => a - b);
482
+ const overallP50 = percentile(allSamples, 0.5);
483
+ const overallP99 = percentile(allSamples, 0.99);
484
+ const overallAvg =
485
+ allSamples.reduce((a, b) => a + b, 0) / allSamples.length;
486
+ const totalRequests = STORM_CONCURRENCY * STORM_WAVE_COUNT;
487
+ const totalElapsed = perWave.reduce((sum, w) => sum + w.elapsedMs, 0);
488
+ const throughput = (totalRequests * 1000) / totalElapsed;
489
+
490
+ console.log(
491
+ `storm ${STORM_CONCURRENCY}×${STORM_WAVE_COUNT}: avg=${overallAvg.toFixed(2)}ms p50=${overallP50.toFixed(2)}ms p99=${overallP99.toFixed(2)}ms ` +
492
+ `throughput=${throughput.toFixed(0)} req/s total=${totalElapsed.toFixed(0)}ms`,
493
+ );
494
+ // Per-wave breakdown so a regression that only affects
495
+ // first-wave stampede (cache-miss shape) shows up
496
+ // independently from steady-state numbers.
497
+ for (const w of perWave) {
498
+ console.log(
499
+ ` wave ${w.wave.toString().padStart(2)}: p50=${w.p50.toFixed(2)}ms p99=${w.p99.toFixed(2)}ms ` +
500
+ `min=${w.min.toFixed(2)}ms max=${w.max.toFixed(2)}ms wave-elapsed=${w.elapsedMs.toFixed(0)}ms`,
501
+ );
502
+ }
503
+
504
+ expect(overallP50).toBeLessThan(STORM_P50_LATENCY_MS);
505
+ expect(overallP99).toBeLessThan(STORM_P99_LATENCY_MS);
506
+ // Wave-1 stampede check: even the cold-cache wave (which is
507
+ // what the production cache absorbs) has to stay tractable —
508
+ // a regression that only hurts first-request-per-scope
509
+ // latency would otherwise hide behind steady-state numbers.
510
+ const firstWave = perWave[0];
511
+ expect(firstWave).toBeDefined();
512
+ if (firstWave) {
513
+ expect(firstWave.p99).toBeLessThan(STORM_P99_LATENCY_MS);
514
+ }
515
+ }, 300_000);
516
+ });