Skip to content

Commit 1afa147

Browse files
committed
test(run-engine): caps-vs-scheduling bench over the real CK Lua gates
Runs the plan's concurrency caps (per-key limit via the real per-queue gate, total cap via a driver group gate) head-to-head with SFQ/DRR through the real CK-dequeue Lua. Per-key caps fix a starved key's wait when one key floods (eligibility-aware dequeue) but fail when a tenant shards its backlog across many keys (the sybil split), and are not work-conserving; a total cap only lowers the ceiling and worsens cross-key wait; scheduling fixes every case including sybil and stays work-conserving. Adds a makespan work-conservation metric.
1 parent 82c9590 commit 1afa147

8 files changed

Lines changed: 2408 additions & 2 deletions

File tree

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# Caps vs scheduling: reconciliation findings
2+
3+
Throwaway spike. Ships nothing; delete before any merge to main.
4+
5+
Bottom line: the plan-of-record's concurrency CAPS and the earlier spike's fair
6+
SCHEDULING are different knobs, and the data on the real CK-dequeue Lua matches
7+
the queueing theory (see `RESEARCH.md`). A per-key cap fixes a starved key's wait
8+
when ONE key floods, because Trigger's CK dequeue is oldest-eligible-first, but it
9+
FAILS when a tenant shards its backlog across many concurrency keys (the sybil
10+
split), and it is not work-conserving. Fair scheduling (SFQ/DRR) fixes the wait on
11+
every scenario, including the sybil split, and stays work-conserving. A total
12+
(per-task) cap does not address cross-key starvation at all: applied inside a task
13+
it only lowers the ceiling and makes the starved key's wait worse. The two
14+
mechanisms are complementary, and every production system that needs fairness
15+
under saturation layers them (Kubernetes APF: seats + fair queueing).
16+
17+
## How the caps were modelled (fidelity)
18+
19+
- Per-key cap (Phase 2): the REAL Lua gate. `updateQueueConcurrencyLimits` sets
20+
the base queue's concurrencyLimit, and the CK-dequeue Lua caps each ck variant's
21+
in-flight at it and skips an at-limit variant (oldest-eligible-first, true age
22+
order, no rescore involved). Uniform across variants: Phase 2's per-key HGET
23+
override would cap only the heavy key, but a light key never approaches the cap
24+
so the effect is equivalent here. (This also means "just lower the existing
25+
per-queue concurrency limit" is itself a per-key cap; Phase 2 makes it
26+
per-key-specific.)
27+
- Total cap (Phase 1): driver-side. The real Lua has no group gate yet, so the
28+
driver refuses to admit while total in-flight across all variants of the base
29+
queue (= `:groupConcurrency` SCARD in one base queue) is at the cap.
30+
- Ordering disciplines (baseline age order, SFQ, DRR) are unchanged from the CK
31+
scheduling spike, driven through the same real Lua at `maxCount = 1`.
32+
- Same `maxCount = 1` fidelity caveat as the CK spike: production dequeues in
33+
batches, so a real per-key/total gate lives inside the batched Lua.
34+
35+
## Results
36+
37+
env=4, per-key cap=2, total cap=2, 3 seeds. `lightWait` = the starved key's mean
38+
wait (logical ms), the headline. `makespan` = drain time (work-conservation
39+
signal). `contWorstS/W` = worst contention share over weight (directional).
40+
41+
| scenario | treatment | lightWait | worstWait | makespan | contWorstS/W |
42+
| ----------- | -------------- | --------- | --------- | -------- | ------------ |
43+
| ckSkew | baseline | 1098 | 1261 | 2083 | 0.187 |
44+
| ckSkew | perKeyCap | 20 | 1555 | 3038 | 0.814 |
45+
| ckSkew | totalCap | 2840 | 2974 | 3947 | 0.213 |
46+
| ckSkew | sfq | 14 | 1069 | 2083 | 0.723 |
47+
| ckSkew | drr | 17 | 1067 | 2083 | 0.608 |
48+
| ckSkew | perKeyCap+sfq | 7 | 1628 | 3114 | 0.800 |
49+
| ckTrickle | baseline | 1107 | 1134 | 1940 | 0.279 |
50+
| ckTrickle | perKeyCap | 19 | 1555 | 3038 | 0.922 |
51+
| ckTrickle | totalCap | 2852 | 2861 | 3905 | 0.279 |
52+
| ckTrickle | sfq | 17 | 1070 | 1942 | 0.909 |
53+
| ckTrickle | drr | 23 | 1069 | 1941 | 0.790 |
54+
| ckTrickle | perKeyCap+sfq | 8 | 1606 | 3097 | 0.658 |
55+
| ckSybil | baseline | 1767 | 1823 | 2067 | 0.000 |
56+
| ckSybil | perKeyCap | 1718 | 1831 | 2148 | 0.367 |
57+
| ckSybil | totalCap | 3796 | 3796 | 4147 | 0.000 |
58+
| ckSybil | sfq | 462 | 1062 | 2068 | 0.690 |
59+
| ckSybil | drr | 496 | 1081 | 2071 | 0.690 |
60+
| ckSybil | perKeyCap+sfq | 462 | 1062 | 2068 | 0.690 |
61+
| ckHeavyIdle | baseline | 633 | 633 | 1240 | 1.000 |
62+
| ckHeavyIdle | perKeyCap | 1291 | 1291 | 2507 | 1.000 |
63+
| ckHeavyIdle | totalCap | 1291 | 1291 | 2507 | 1.000 |
64+
| ckHeavyIdle | sfq | 633 | 633 | 1240 | 1.000 |
65+
| ckHeavyIdle | drr | 633 | 633 | 1240 | 1.000 |
66+
| ckHeavyIdle | perKeyCap+sfq | 1291 | 1291 | 2507 | 1.000 |
67+
68+
(ckHeavyIdle is a single key, so "lightWait" is the heavy key's own wait and the
69+
contention metric is degenerate at 1.0; the signal there is makespan.)
70+
71+
## Verdicts
72+
73+
- Per-key cap (Phase 2): PROVEN for the single-heavy case, DISPROVEN for the
74+
sybil case, and it is not work-conserving.
75+
- Single heavy key (ckSkew/ckTrickle): cuts the light key's wait like a
76+
scheduler (1098 to 20, 1107 to 19) because capping the one heavy key frees
77+
slots and the CK Lua is oldest-eligible-first, so the light key's head is
78+
reachable. This works ONLY because the dequeue skips at-cap variants; on a
79+
head-blocking FIFO the same cap would idle the freed slots and make wait worse.
80+
- Sybil split (ckSybil): barely moves the light key's wait (1767 to 1718)
81+
because the attacker's 10 keys keep env saturated with older heads and no
82+
per-key cap bounds their aggregate. Only the fair order rescues the light key
83+
(sfq 462, ~3.7x better than perKeyCap). Concurrency keys are client-chosen, so
84+
this is cheap to trigger.
85+
- Not work-conserving: throttles the capped key even with the env idle
86+
(ckHeavyIdle makespan 1240 to 2507, 2x; ckSkew 2083 to 3038, +46%).
87+
- Total cap (Phase 1) for cross-KEY fairness: DISPROVEN. Applied inside one task
88+
it only lowers the whole task's ceiling and, with age order unchanged, makes the
89+
starved key's wait worse on every contended scenario (ckSkew 1098 to 2840,
90+
ckSybil 1767 to 3796). The total cap's real job is cross-TASK isolation
91+
(reservation between base queues when the sum of per-task caps is below the env
92+
limit); that is a different problem from #2617's within-task cross-key
93+
starvation and is not exercised by this single-base-queue harness (noted as
94+
future work).
95+
- Scheduling (SFQ/DRR): PROVEN on every scenario including the sybil split, and
96+
work-conserving (makespan stays at the baseline optimum 2083/1240). SFQ and DRR
97+
track each other within noise, as in the CK spike.
98+
- Layered per-key cap + SFQ (the Kubernetes-APF pattern): best of both on the
99+
single-heavy case (lowest light wait AND the cap's occupancy bound), but
100+
inherits the static cap's makespan penalty (ckSkew 3114, ckHeavyIdle 2507). On
101+
the sybil case the cap adds nothing and SFQ does all the work (462). APF avoids
102+
the work-conservation penalty by making the cap ELASTIC (borrow/lend seats); a
103+
static cap cannot.
104+
105+
## Reconciliation with the earlier spike and the plan of record
106+
107+
- The earlier spike's recommendation (score `ckIndex` by a fair discipline) is the
108+
general fix: it is the only mechanism here that survives the sybil split and it
109+
is work-conserving.
110+
- The plan of record ships caps first, and that is a defensible sequencing, not a
111+
contradiction. A per-key cap is bounded, predictable, operator-controlled, and
112+
self-healing (a Redis SET), and it fully fixes the common single-heavy-key case
113+
with far less engine risk than reworking the dequeue scoring. Its limits are
114+
real (defeated by key sharding, not work-conserving), which is exactly why the
115+
plan calls automatic elastic fairness a later, opt-in phase.
116+
- The honest layered conclusion (matching Kubernetes APF, SQL Server Resource
117+
Governor, YARN, and the Parekh-Gallager result that a delay bound needs BOTH an
118+
admission regulator AND a scheduler): keep the caps for isolation and
119+
entitlements, and add a fair dequeue order for the contended region when
120+
saturation and key-sharding make caps alone insufficient. Not either/or.
121+
122+
## Caveats
123+
124+
- Relative ranking only; single shard, single base queue, single sequential
125+
consumer; simulated holds on a logical clock; 3 seeds; equal weights.
126+
- `maxCount = 1` (see fidelity note); the total cap is driver-modelled, not the
127+
real (unbuilt) group gate.
128+
- Per-key cap is modelled uniformly (real per-queue gate); a per-key-specific
129+
Phase-2 override is equivalent here only because the light key never approaches
130+
the cap.
131+
- Cross-task isolation (the total cap's real purpose) is argued from the research,
132+
not measured; a multi-base-queue harness is future work.
133+
- Wait is the trustworthy signal; contention share is volume-confounded for
134+
low-volume keys (same caveat as the CK spike).
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import { redisTest } from "@internal/testcontainers";
2+
import type { RedisOptions } from "@internal/redis";
3+
import { describe } from "vitest";
4+
import { mkdirSync, writeFileSync } from "node:fs";
5+
import { dirname, join } from "node:path";
6+
import { fileURLToPath } from "node:url";
7+
import { runCkScenario } from "./harness/ckDriver.js";
8+
import {
9+
buildWorkload,
10+
weightsOf,
11+
type WorkloadConfig,
12+
} from "../fairness-spike/harness/workload.js";
13+
import type { RunMetrics, GroupMetrics } from "../fairness-spike/harness/metrics.js";
14+
import { BaselineCk, SfqCk, DrrCk, type CkDiscipline } from "./disciplines.js";
15+
16+
/**
17+
* Caps vs scheduling. Runs the plan-of-record's concurrency CAPS (Phase-2 per-key
18+
* limit via the real per-queue concurrency gate, Phase-1 total cap via a
19+
* driver-side group gate) head-to-head against the scheduling disciplines
20+
* (SFQ/DRR) on identical scenarios, through the real CK-dequeue Lua at
21+
* maxCount = 1.
22+
*
23+
* A "treatment" is (order discipline, per-key cap?, total cap?). Caps are
24+
* admission settings, not disciplines: the per-key cap is the real Lua's native
25+
* per-variant gate (oldest-eligible-first, true age order), the total cap is the
26+
* driver refusing to admit past the group ceiling.
27+
*
28+
* Headline = the light (starved) key's wait. makespan = work-conservation signal.
29+
* contention share = directional.
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 PER_KEY_CAP = 2; // heavy key(s) bound to half the env
36+
const TOTAL_CAP = 2; // per-task total ceiling, below env
37+
38+
type Treatment = {
39+
label: string;
40+
makeDiscipline: () => CkDiscipline;
41+
perKeyCap?: number;
42+
totalCap?: number;
43+
};
44+
45+
const TREATMENTS: Treatment[] = [
46+
{ label: "baseline", makeDiscipline: () => new BaselineCk() },
47+
{ label: "perKeyCap", makeDiscipline: () => new BaselineCk(), perKeyCap: PER_KEY_CAP },
48+
{ label: "totalCap", makeDiscipline: () => new BaselineCk(), totalCap: TOTAL_CAP },
49+
{ label: "sfq", makeDiscipline: () => new SfqCk() },
50+
{ label: "drr", makeDiscipline: () => new DrrCk() },
51+
{ label: "perKeyCap+sfq", makeDiscipline: () => new SfqCk(), perKeyCap: PER_KEY_CAP },
52+
];
53+
54+
type CapScenario = {
55+
config: Omit<WorkloadConfig, "seed">;
56+
/** the key whose wait is the headline (a starved light key, or the heavy key for heavy-idle) */
57+
lightKey: string;
58+
};
59+
60+
function sybilHeavy(count: number, runsEach: number) {
61+
return Array.from({ length: count }, (_, i) => ({
62+
tenantId: `heavy-${i}`,
63+
runCount: runsEach,
64+
holdMsMean: 25,
65+
}));
66+
}
67+
68+
const SCENARIOS: Record<string, CapScenario> = {
69+
// one heavy key floods (old head), four light keys trickle in later. One heavy
70+
// key => a per-key cap frees slots the light keys can take.
71+
ckSkew: {
72+
lightKey: "light-1",
73+
config: {
74+
envConcurrencyLimit: ENV_LIMIT,
75+
tenants: [
76+
{ tenantId: "heavy", runCount: 240, holdMsMean: 25 },
77+
{ tenantId: "light-1", runCount: 15, arrival: "poisson", ratePerSec: 10, holdMsMean: 25 },
78+
{ tenantId: "light-2", runCount: 15, arrival: "poisson", ratePerSec: 10, holdMsMean: 25 },
79+
{ tenantId: "light-3", runCount: 15, arrival: "poisson", ratePerSec: 10, holdMsMean: 25 },
80+
{ tenantId: "light-4", runCount: 15, arrival: "poisson", ratePerSec: 10, holdMsMean: 25 },
81+
],
82+
},
83+
},
84+
85+
// a bulk backlog plus two keys trickling in slowly
86+
ckTrickle: {
87+
lightKey: "trickle-1",
88+
config: {
89+
envConcurrencyLimit: ENV_LIMIT,
90+
tenants: [
91+
{ tenantId: "bulk", runCount: 240, holdMsMean: 25 },
92+
{ tenantId: "trickle-1", runCount: 30, arrival: "poisson", ratePerSec: 25, holdMsMean: 25 },
93+
{ tenantId: "trickle-2", runCount: 30, arrival: "poisson", ratePerSec: 25, holdMsMean: 25 },
94+
],
95+
},
96+
},
97+
98+
// sybil split: one attacker spreads its backlog across 10 concurrency keys, each
99+
// with a large backlog that stays non-empty through the light key's whole
100+
// arrival window. Each attacker key is under the same per-key cap, but the cap
101+
// frees no aggregate slot (2 attacker keys fill env, and as one empties the next
102+
// attacker key's old head is served before the newer light key). Only a fair
103+
// order rescues the light key.
104+
ckSybil: {
105+
lightKey: "light",
106+
config: {
107+
envConcurrencyLimit: ENV_LIMIT,
108+
tenants: [
109+
...sybilHeavy(10, 30),
110+
{ tenantId: "light", runCount: 20, arrival: "poisson", ratePerSec: 40, holdMsMean: 25 },
111+
],
112+
},
113+
},
114+
115+
// work-conservation: one heavy key alone with a big backlog. A per-key or total
116+
// cap throttles it below the env limit and idles slots, inflating makespan; a
117+
// scheduler uses the whole env. Headline here is makespan, not wait.
118+
ckHeavyIdle: {
119+
lightKey: "heavy",
120+
config: {
121+
envConcurrencyLimit: ENV_LIMIT,
122+
tenants: [{ tenantId: "heavy", runCount: 200, holdMsMean: 25 }],
123+
},
124+
},
125+
};
126+
127+
function stats(xs: number[]) {
128+
return {
129+
mean: xs.reduce((a, b) => a + b, 0) / xs.length,
130+
min: Math.min(...xs),
131+
max: Math.max(...xs),
132+
};
133+
}
134+
135+
function fmt(n: number, d = 0): string {
136+
return Number.isFinite(n) ? n.toFixed(d) : String(n);
137+
}
138+
139+
function waitOf(metrics: RunMetrics, key: string): number {
140+
return metrics.perGroup.find((g) => g.groupId === key)?.meanWait ?? 0;
141+
}
142+
143+
function worstWaitOf(metrics: RunMetrics): number {
144+
return metrics.perGroup.length ? Math.max(...metrics.perGroup.map((g) => g.meanWait)) : 0;
145+
}
146+
147+
describe("caps vs scheduling bench", () => {
148+
mkdirSync(RESULTS_DIR, { recursive: true });
149+
150+
for (const [scenarioName, scenario] of Object.entries(SCENARIOS)) {
151+
redisTest(
152+
`caps scenario: ${scenarioName}`,
153+
async ({ redisContainer }) => {
154+
const runs = new Map<string, Array<{ seed: string; metrics: RunMetrics }>>();
155+
156+
for (const seed of SEEDS) {
157+
const config: WorkloadConfig = { ...scenario.config, seed };
158+
const workload = buildWorkload(config);
159+
const expectedTotal = workload.tenants.reduce((n, t) => n + t.runCount, 0);
160+
161+
for (const treatment of TREATMENTS) {
162+
const redis: RedisOptions = {
163+
keyPrefix: `rq:caps:${scenarioName}:${treatment.label}:${seed}:`,
164+
host: redisContainer.getHost(),
165+
port: redisContainer.getPort(),
166+
};
167+
const metrics = await runCkScenario({
168+
redis,
169+
discipline: treatment.makeDiscipline(),
170+
workload,
171+
perKeyCap: treatment.perKeyCap,
172+
totalCap: treatment.totalCap,
173+
});
174+
if (metrics.totalDequeued !== expectedTotal) {
175+
throw new Error(
176+
`${scenarioName}/${treatment.label}/${seed}: dequeued ${metrics.totalDequeued} of ${expectedTotal}`
177+
);
178+
}
179+
const arr = runs.get(treatment.label) ?? [];
180+
arr.push({ seed, metrics });
181+
runs.set(treatment.label, arr);
182+
}
183+
}
184+
185+
const perTreatment = [...runs.entries()].map(([label, rs]) => ({
186+
treatment: label,
187+
lightWait: stats(rs.map((r) => waitOf(r.metrics, scenario.lightKey))),
188+
worstWait: stats(rs.map((r) => worstWaitOf(r.metrics))),
189+
makespan: stats(rs.map((r) => r.metrics.makespanMs)),
190+
contentionWorst: stats(rs.map((r) => r.metrics.contentionWorstShareOverWeight)),
191+
detailSeed0: rs[0].metrics.perGroup as GroupMetrics[],
192+
}));
193+
194+
const firstWorkload = buildWorkload({ ...scenario.config, seed: SEEDS[0] });
195+
writeFileSync(
196+
join(RESULTS_DIR, `caps-${scenarioName}.json`),
197+
JSON.stringify(
198+
{
199+
scenario: scenarioName,
200+
seeds: SEEDS,
201+
envLimit: ENV_LIMIT,
202+
perKeyCap: PER_KEY_CAP,
203+
totalCap: TOTAL_CAP,
204+
lightKey: scenario.lightKey,
205+
weights: weightsOf(firstWorkload),
206+
perTreatment,
207+
},
208+
null,
209+
2
210+
)
211+
);
212+
213+
const lines = [
214+
``,
215+
`### caps: ${scenarioName} (${SEEDS.length} seeds, env=${ENV_LIMIT}, perKeyCap=${PER_KEY_CAP}, totalCap=${TOTAL_CAP}, light=${scenario.lightKey})`,
216+
`treatment lightWait worstWait makespan contWorstS/W`,
217+
...perTreatment.map(
218+
(r) =>
219+
`${r.treatment.padEnd(16)} ${fmt(r.lightWait.mean).padStart(8)} ${fmt(
220+
r.worstWait.mean
221+
).padStart(8)} ${fmt(r.makespan.mean).padStart(7)} ${fmt(
222+
r.contentionWorst.mean,
223+
3
224+
).padStart(7)}`
225+
),
226+
``,
227+
];
228+
process.stdout.write(lines.join("\n") + "\n");
229+
},
230+
300_000
231+
);
232+
}
233+
});

0 commit comments

Comments
 (0)