Skip to content

Commit 9b8440c

Browse files
committed
test(run-engine): cross-task total-cap bench (the total cap's real job)
Measures the claim the caps bench left argued-not-measured: the per-task total cap's real purpose is cross-TASK isolation, not cross-key. Two keyless tasks share one env; capping the heavy task (its real per-queue concurrency gate, which for a keyless task equals its total cap) cuts the light task's wait 475 to 2 under the production FairQueueSelectionStrategy, versus failing at the cross-key grain. Still not work-conserving (makespan 2039 to 3039); SFQ protects the light task too and stays work-conserving. Adds a perQueueCap hook to the base-queue driver.
1 parent c59a159 commit 9b8440c

4 files changed

Lines changed: 417 additions & 9 deletions

File tree

internal-packages/run-engine/src/run-queue/fairness-spike-ck/CAPS_FINDINGS.md

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,21 @@ contention metric is degenerate at 1.0; makespan is the signal there. ckSybil is
119119
in-task comparison is capacity-confounded. `totalCap=2` caps the whole task's
120120
aggregate at half of env=4, so it simply halves throughput: light's wait rises
121121
(ckSkew 1098 to 2840) for the same reason heavy's does (both now share half the
122-
server), which is Little's-Law throughput loss, not a fairness effect. The total
123-
cap's real purpose is cross-TASK isolation (reservation between base queues when
124-
the sum of per-task caps is below the env limit), a different problem from
125-
#2617's within-task cross-key starvation and one this single-base-queue harness
126-
does not exercise (noted as future work). Do not read the "worse" numbers as
127-
"total caps harm fairness"; read them as "wrong knob, and measured on a lower
128-
ceiling."
122+
server), which is Little's-Law throughput loss, not a fairness effect. It is the
123+
wrong knob for cross-key starvation, measured on a lower ceiling; do not read
124+
the "worse" numbers as "total caps harm fairness."
125+
- Total cap (Phase 1) at the cross-TASK grain: this IS its job, and it works.
126+
Measured in a separate multi-base-queue bench (`crossTaskCaps.bench.test.ts`):
127+
two keyless tasks share one env, a heavy task floods it, and capping the heavy
128+
task (its per-queue concurrency limit, the real native gate, which for a keyless
129+
task equals its total cap) cuts the light TASK's wait from 475 to 2 under the
130+
production `FairQueueSelectionStrategy`. So the total cap protects a light task
131+
from a heavy task, the reservation-isolation role the research describes. It is
132+
still not work-conserving (makespan 2039 to 3039), and SFQ at the task grain
133+
protects the light task too (wait 14) while staying work-conserving (2039). The
134+
fidelity note: this models a KEYLESS task, so the per-queue limit is the total;
135+
a task WITH concurrency keys needs the group SET to sum across variants (the
136+
unbuilt Phase-1 gate).
129137
- Combined total + per-key (the shipped Phase-1+2 config): in this toy the total
130138
cap (2) is below a single per-key cap's reach, so it dominates and the per-key
131139
cap is non-binding (`total+perKey` equals `totalCap` to the digit). This toy
@@ -184,5 +192,7 @@ make caps alone insufficient. Not either/or.
184192
- Contention share is volume-confounded for low-volume keys, and for the per-key
185193
cap on the sharded case it is seed-noisy (0.07..0.71); wait is the trustworthy
186194
signal, share is directional.
187-
- Cross-task isolation (the total cap's real purpose) is argued from the research,
188-
not measured; a multi-base-queue harness is future work.
195+
- Cross-task isolation (the total cap's real purpose) is now measured in
196+
`crossTaskCaps.bench.test.ts` for KEYLESS tasks (per-queue limit = total cap).
197+
A task with concurrency keys needs the unbuilt group-SET gate to sum across
198+
variants; that batched, keyed path is still not exercised.
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
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

Comments
 (0)