|
| 1 | +import { redisTest } from "@internal/testcontainers"; |
| 2 | +import { describe } from "vitest"; |
| 3 | +import { mkdirSync, writeFileSync } from "node:fs"; |
| 4 | +import { dirname, join } from "node:path"; |
| 5 | +import { fileURLToPath } from "node:url"; |
| 6 | +import { createRedisClient } from "@internal/redis"; |
| 7 | +import { RunQueueFullKeyProducer } from "../keyProducer.js"; |
| 8 | +import { FairQueueSelectionStrategy } from "../fairQueueSelectionStrategy.js"; |
| 9 | +import { runScenario } from "../fairness-spike/harness/driver.js"; |
| 10 | +import { SfqStrategy } from "../fairness-spike/strategies/sfqStrategy.js"; |
| 11 | +import { GROUP_SEPARATOR } from "../fairness-spike/types.js"; |
| 12 | +import { |
| 13 | + buildWorkload, |
| 14 | + weightsOf, |
| 15 | + type WorkloadConfig, |
| 16 | +} from "../fairness-spike/harness/workload.js"; |
| 17 | +import type { RunMetrics, GroupMetrics } from "../fairness-spike/harness/metrics.js"; |
| 18 | + |
| 19 | +/** |
| 20 | + * The total cap's REAL job: cross-TASK isolation. Two keyless tasks (base queues) |
| 21 | + * share one env; a heavy task floods it and starves a light task. This is the |
| 22 | + * problem #2617's total cap is for, and it is a DIFFERENT problem from the |
| 23 | + * cross-KEY starvation the caps bench showed the total cap does not fix. |
| 24 | + * |
| 25 | + * The total cap on the heavy task is the real per-queue concurrency gate |
| 26 | + * (updateQueueConcurrencyLimits); for a keyless task the per-queue limit is the |
| 27 | + * per-task total (one base queue, no ck variants to sum), so this is faithful. |
| 28 | + * Compared against the production FairQueueSelectionStrategy (baseline) and the |
| 29 | + * spike SFQ selector, both driving the real RunQueue + testDequeueFromMasterQueue. |
| 30 | + */ |
| 31 | + |
| 32 | +const RESULTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "results"); |
| 33 | +const SEEDS = ["seed-a", "seed-b", "seed-c"]; |
| 34 | +const ENV_LIMIT = 4; |
| 35 | +const HEAVY_TOTAL_CAP = 2; |
| 36 | + |
| 37 | +const keys = new RunQueueFullKeyProducer(); |
| 38 | +const q = (tenant: string) => `${tenant}${GROUP_SEPARATOR}0`; |
| 39 | + |
| 40 | +type CrossScenario = { |
| 41 | + config: Omit<WorkloadConfig, "seed">; |
| 42 | + heavy: string; |
| 43 | + lightKey: string; |
| 44 | +}; |
| 45 | + |
| 46 | +const SCENARIOS: Record<string, CrossScenario> = { |
| 47 | + // heavy task floods, two light tasks trickle in. All keyless (queueCount 1). |
| 48 | + crossTaskSkew: { |
| 49 | + heavy: "heavy", |
| 50 | + lightKey: "light-1", |
| 51 | + config: { |
| 52 | + envConcurrencyLimit: ENV_LIMIT, |
| 53 | + tenants: [ |
| 54 | + { tenantId: "heavy", runCount: 240, holdMsMean: 25 }, |
| 55 | + { tenantId: "light-1", runCount: 15, arrival: "poisson", ratePerSec: 10, holdMsMean: 25 }, |
| 56 | + { tenantId: "light-2", runCount: 15, arrival: "poisson", ratePerSec: 10, holdMsMean: 25 }, |
| 57 | + ], |
| 58 | + }, |
| 59 | + }, |
| 60 | +}; |
| 61 | + |
| 62 | +type RedisOpts = { keyPrefix: string; host: string; port: number }; |
| 63 | +type Treatment = { |
| 64 | + label: string; |
| 65 | + // returns the strategy and an optional client to quit afterwards. The real |
| 66 | + // FairQueueSelectionStrategy takes RedisOptions and owns its own client; the |
| 67 | + // spike SfqStrategy takes a live client. |
| 68 | + makeStrategy: (redis: RedisOpts) => { strategy: any; client?: ReturnType<typeof createRedisClient> }; |
| 69 | + capHeavy?: boolean; |
| 70 | +}; |
| 71 | + |
| 72 | +const TREATMENTS: Treatment[] = [ |
| 73 | + { |
| 74 | + label: "baseline(fairqueue)", |
| 75 | + makeStrategy: (redis) => ({ strategy: new FairQueueSelectionStrategy({ redis, keys }) }), |
| 76 | + }, |
| 77 | + { |
| 78 | + label: "heavyTotalCap", |
| 79 | + makeStrategy: (redis) => ({ strategy: new FairQueueSelectionStrategy({ redis, keys }) }), |
| 80 | + capHeavy: true, |
| 81 | + }, |
| 82 | + { |
| 83 | + label: "sfq", |
| 84 | + makeStrategy: (redis) => { |
| 85 | + const client = createRedisClient(redis); |
| 86 | + return { strategy: new SfqStrategy({ redis: client, keys }), client }; |
| 87 | + }, |
| 88 | + }, |
| 89 | +]; |
| 90 | + |
| 91 | +function stats(xs: number[]) { |
| 92 | + return { |
| 93 | + mean: xs.reduce((a, b) => a + b, 0) / xs.length, |
| 94 | + min: Math.min(...xs), |
| 95 | + max: Math.max(...xs), |
| 96 | + }; |
| 97 | +} |
| 98 | + |
| 99 | +function fmt(n: number, d = 0): string { |
| 100 | + return Number.isFinite(n) ? n.toFixed(d) : String(n); |
| 101 | +} |
| 102 | + |
| 103 | +function waitOf(metrics: RunMetrics, key: string): number { |
| 104 | + return metrics.perGroup.find((g) => g.groupId === key)?.meanWait ?? 0; |
| 105 | +} |
| 106 | + |
| 107 | +describe("cross-task total cap bench", () => { |
| 108 | + mkdirSync(RESULTS_DIR, { recursive: true }); |
| 109 | + |
| 110 | + for (const [scenarioName, scenario] of Object.entries(SCENARIOS)) { |
| 111 | + redisTest( |
| 112 | + `cross-task scenario: ${scenarioName}`, |
| 113 | + async ({ redisContainer }) => { |
| 114 | + const runs = new Map<string, Array<{ seed: string; metrics: RunMetrics }>>(); |
| 115 | + |
| 116 | + for (const seed of SEEDS) { |
| 117 | + const config: WorkloadConfig = { ...scenario.config, seed }; |
| 118 | + const workload = buildWorkload(config); |
| 119 | + const expectedTotal = workload.tenants.reduce((n, t) => n + t.runCount, 0); |
| 120 | + |
| 121 | + for (const treatment of TREATMENTS) { |
| 122 | + const redis = { |
| 123 | + keyPrefix: `rq:xtask:${scenarioName}:${treatment.label}:${seed}:`, |
| 124 | + host: redisContainer.getHost(), |
| 125 | + port: redisContainer.getPort(), |
| 126 | + }; |
| 127 | + const { strategy, client } = treatment.makeStrategy(redis); |
| 128 | + const metrics = await runScenario({ |
| 129 | + redis, |
| 130 | + strategy, |
| 131 | + workload, |
| 132 | + perQueueCap: treatment.capHeavy ? { [q(scenario.heavy)]: HEAVY_TOTAL_CAP } : undefined, |
| 133 | + }); |
| 134 | + await client?.quit(); |
| 135 | + if (metrics.totalDequeued !== expectedTotal) { |
| 136 | + throw new Error( |
| 137 | + `${scenarioName}/${treatment.label}/${seed}: dequeued ${metrics.totalDequeued} of ${expectedTotal}` |
| 138 | + ); |
| 139 | + } |
| 140 | + const arr = runs.get(treatment.label) ?? []; |
| 141 | + arr.push({ seed, metrics }); |
| 142 | + runs.set(treatment.label, arr); |
| 143 | + } |
| 144 | + } |
| 145 | + |
| 146 | + const perTreatment = [...runs.entries()].map(([label, rs]) => ({ |
| 147 | + treatment: label, |
| 148 | + lightWait: stats(rs.map((r) => waitOf(r.metrics, scenario.lightKey))), |
| 149 | + heavyWait: stats(rs.map((r) => waitOf(r.metrics, scenario.heavy))), |
| 150 | + makespan: stats(rs.map((r) => r.metrics.makespanMs)), |
| 151 | + contentionWorst: stats(rs.map((r) => r.metrics.contentionWorstShareOverWeight)), |
| 152 | + detailSeed0: rs[0].metrics.perGroup as GroupMetrics[], |
| 153 | + })); |
| 154 | + |
| 155 | + const firstWorkload = buildWorkload({ ...scenario.config, seed: SEEDS[0] }); |
| 156 | + writeFileSync( |
| 157 | + join(RESULTS_DIR, `xtask-${scenarioName}.json`), |
| 158 | + JSON.stringify( |
| 159 | + { |
| 160 | + scenario: scenarioName, |
| 161 | + seeds: SEEDS, |
| 162 | + envLimit: ENV_LIMIT, |
| 163 | + heavyTotalCap: HEAVY_TOTAL_CAP, |
| 164 | + lightKey: scenario.lightKey, |
| 165 | + weights: weightsOf(firstWorkload), |
| 166 | + perTreatment, |
| 167 | + }, |
| 168 | + null, |
| 169 | + 2 |
| 170 | + ) |
| 171 | + ); |
| 172 | + |
| 173 | + const lines = [ |
| 174 | + ``, |
| 175 | + `### cross-task: ${scenarioName} (${SEEDS.length} seeds, env=${ENV_LIMIT}, heavyTotalCap=${HEAVY_TOTAL_CAP}, light=${scenario.lightKey})`, |
| 176 | + `treatment lightWait heavyWait makespan contWorstS/W`, |
| 177 | + ...perTreatment.map( |
| 178 | + (r) => |
| 179 | + `${r.treatment.padEnd(19)} ${fmt(r.lightWait.mean).padStart(8)} ${fmt( |
| 180 | + r.heavyWait.mean |
| 181 | + ).padStart(8)} ${fmt(r.makespan.mean).padStart(7)} ${fmt( |
| 182 | + r.contentionWorst.mean, |
| 183 | + 3 |
| 184 | + ).padStart(7)}` |
| 185 | + ), |
| 186 | + ``, |
| 187 | + ]; |
| 188 | + process.stdout.write(lines.join("\n") + "\n"); |
| 189 | + }, |
| 190 | + 300_000 |
| 191 | + ); |
| 192 | + } |
| 193 | +}); |
0 commit comments