Skip to content

Commit b748a4f

Browse files
committed
chore(run-engine): ship note + comment/format cleanup
Task 10 (final). Adds the .server-changes note (ships dark, off by default), fixes three stale/misleading test comments (keyProducer var naming; the un-seeded test 4 comment; the flag-off KEYS-scan comment now correctly credits redisTest flushall), and applies format. No production logic changed.
1 parent 5fd901a commit b748a4f

4 files changed

Lines changed: 151 additions & 137 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
The run queue can now schedule runs across concurrency key variants fairly, so one tenant or key with a large backlog can't starve runs waiting on other keys. This is opt-in via a flag and off by default, so nothing changes unless it's enabled.

internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,8 @@ describe("CK virtual-time (SFQ) dequeue", () => {
127127
const lightVariant = variantName("light");
128128
const ckVtimeKey = testOptions.keys.ckVtimeKeyFromQueue(heavyVariant);
129129

130-
// Task 4 (enqueue registration) isn't done yet; seed both variants at tag 0.
130+
// Explicit seed is redundant now that enqueue registers variants at the
131+
// floor itself; kept as a belt-and-braces fixture.
131132
await queue.redis.zadd(ckVtimeKey, 0, heavyVariant, 0, lightVariant);
132133

133134
const shard = testOptions.keys.masterQueueShardForEnvironment(authenticatedEnvDev.id, 2);
@@ -537,8 +538,9 @@ describe("CK virtual-time (SFQ) dequeue", () => {
537538
const t0 = Date.now() - 100_000;
538539

539540
// Enqueue on a variant but do NOT register it in ckVtime (simulating an
540-
// enqueue from old code / before Task 4). Two messages so the variant
541-
// survives its first serve and we can observe it was registered.
541+
// enqueue from old code that predates enqueue-time registration, e.g.
542+
// during a rolling deploy). Two messages so the variant survives its
543+
// first serve and we can observe it was registered.
542544
for (let i = 0; i < 2; i++) {
543545
await queue.enqueueMessage({
544546
env: authenticatedEnvDev,
@@ -897,7 +899,11 @@ describe("CK virtual-time (SFQ) dequeue", () => {
897899
for (let i = 0; i < 3; i++) {
898900
await queue.enqueueMessage({
899901
env: authenticatedEnvDev,
900-
message: makeMessage({ runId: `r-${ck}-${i}`, concurrencyKey: ck, timestamp: head + i }),
902+
message: makeMessage({
903+
runId: `r-${ck}-${i}`,
904+
concurrencyKey: ck,
905+
timestamp: head + i,
906+
}),
901907
workerQueue: authenticatedEnvDev.id,
902908
skipDequeueProcessing: true,
903909
});
@@ -944,9 +950,9 @@ describe("CK virtual-time (SFQ) dequeue", () => {
944950

945951
// After the whole mixed sequence (enqueues, batched dequeues, a nack,
946952
// acks, one message still in flight so the keyspace is non-empty) no
947-
// vtime state exists at all: no :ckVtime, no :ckVtimeFloor. ioredis
948-
// prepends the keyPrefix to the KEYS pattern, so this scans exactly
949-
// this test's keyspace.
953+
// vtime state exists at all: no :ckVtime, no :ckVtimeFloor. The KEYS
954+
// scan is safe here because redisTest runs flushall before each test,
955+
// so the DB only holds this test's keys.
950956
const allKeys = await queue.redis.keys("*");
951957
expect(allKeys.length).toBeGreaterThan(0);
952958
expect(allKeys.filter((k) => k.includes("ckVtime"))).toEqual([]);

internal-packages/run-engine/src/run-queue/tests/ckVtimeFairness.test.ts

Lines changed: 127 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -250,96 +250,96 @@ describe("CK virtual-time fairness on the real batched dequeue path", () => {
250250
// The head-age starvation the spike measured appears on this path when env
251251
// capacity serializes the calls (limit 1): flag OFF then always picks the
252252
// globally oldest head, which is heavy for its whole backlog.
253-
redisTest("ckSkew: light keys stop waiting behind the heavy backlog", async ({
254-
redisContainer,
255-
}) => {
256-
const t0 = Date.now() - 500_000;
257-
const messages: ScenarioMessage[] = [];
258-
for (let i = 0; i < 120; i++) {
259-
messages.push({ runId: `heavy-${i}`, ck: "heavy", timestamp: t0 });
260-
}
261-
for (let i = 0; i < 10; i++) {
262-
for (let k = 0; k < 4; k++) {
263-
messages.push({
264-
runId: `light${k}-${i}`,
265-
ck: `light${k}`,
266-
timestamp: t0 + 10_000 + i * 4 + k,
267-
});
253+
redisTest(
254+
"ckSkew: light keys stop waiting behind the heavy backlog",
255+
async ({ redisContainer }) => {
256+
const t0 = Date.now() - 500_000;
257+
const messages: ScenarioMessage[] = [];
258+
for (let i = 0; i < 120; i++) {
259+
messages.push({ runId: `heavy-${i}`, ck: "heavy", timestamp: t0 });
268260
}
261+
for (let i = 0; i < 10; i++) {
262+
for (let k = 0; k < 4; k++) {
263+
messages.push({
264+
runId: `light${k}-${i}`,
265+
ck: `light${k}`,
266+
timestamp: t0 + 10_000 + i * 4 + k,
267+
});
268+
}
269+
}
270+
const scenario: Scenario = {
271+
name: "ckSkew",
272+
messages,
273+
envConcurrencyLimit: 1,
274+
holdSteps: 3,
275+
maxSteps: 1_000,
276+
};
277+
278+
const on = await runScenario(redisContainer, scenario, true);
279+
const off = await runScenario(redisContainer, scenario, false);
280+
281+
assertConservation(scenario, on, off);
282+
283+
const isLight = (ck: string) => ck.startsWith("light");
284+
const onWait = meanWait(on, isLight);
285+
const offWait = meanWait(off, isLight);
286+
debugLog("ckSkew", { onWait, offWait, ratio: onWait / offWait });
287+
288+
// Heavy's wait may rise under the fair order; that is expected and not
289+
// asserted down.
290+
expect(onWait).toBeLessThanOrEqual(0.3 * offWait);
269291
}
270-
const scenario: Scenario = {
271-
name: "ckSkew",
272-
messages,
273-
envConcurrencyLimit: 1,
274-
holdSteps: 3,
275-
maxSteps: 1_000,
276-
};
277-
278-
const on = await runScenario(redisContainer, scenario, true);
279-
const off = await runScenario(redisContainer, scenario, false);
280-
281-
assertConservation(scenario, on, off);
282-
283-
const isLight = (ck: string) => ck.startsWith("light");
284-
const onWait = meanWait(on, isLight);
285-
const offWait = meanWait(off, isLight);
286-
debugLog("ckSkew", { onWait, offWait, ratio: onWait / offWait });
287-
288-
// Heavy's wait may rise under the fair order; that is expected and not
289-
// asserted down.
290-
expect(onWait).toBeLessThanOrEqual(0.3 * offWait);
291-
});
292+
);
292293

293294
// ckTrickle (spike shape): one bulk key with a 120-message backlog on an old
294295
// shared head, two trickle keys with 15 messages each on later heads. Same
295296
// serialized contention regime as ckSkew, same assertion.
296-
redisTest("ckTrickle: trickle keys stop waiting behind the bulk backlog", async ({
297-
redisContainer,
298-
}) => {
299-
const t0 = Date.now() - 500_000;
300-
const messages: ScenarioMessage[] = [];
301-
for (let i = 0; i < 120; i++) {
302-
messages.push({ runId: `bulk-${i}`, ck: "bulk", timestamp: t0 });
303-
}
304-
for (let i = 0; i < 15; i++) {
305-
for (let k = 0; k < 2; k++) {
306-
messages.push({
307-
runId: `trickle${k}-${i}`,
308-
ck: `trickle${k}`,
309-
timestamp: t0 + 10_000 + i * 2 + k,
310-
});
297+
redisTest(
298+
"ckTrickle: trickle keys stop waiting behind the bulk backlog",
299+
async ({ redisContainer }) => {
300+
const t0 = Date.now() - 500_000;
301+
const messages: ScenarioMessage[] = [];
302+
for (let i = 0; i < 120; i++) {
303+
messages.push({ runId: `bulk-${i}`, ck: "bulk", timestamp: t0 });
311304
}
312-
}
313-
const scenario: Scenario = {
314-
name: "ckTrickle",
315-
messages,
316-
envConcurrencyLimit: 1,
317-
holdSteps: 3,
318-
maxSteps: 1_000,
319-
};
305+
for (let i = 0; i < 15; i++) {
306+
for (let k = 0; k < 2; k++) {
307+
messages.push({
308+
runId: `trickle${k}-${i}`,
309+
ck: `trickle${k}`,
310+
timestamp: t0 + 10_000 + i * 2 + k,
311+
});
312+
}
313+
}
314+
const scenario: Scenario = {
315+
name: "ckTrickle",
316+
messages,
317+
envConcurrencyLimit: 1,
318+
holdSteps: 3,
319+
maxSteps: 1_000,
320+
};
320321

321-
const on = await runScenario(redisContainer, scenario, true);
322-
const off = await runScenario(redisContainer, scenario, false);
322+
const on = await runScenario(redisContainer, scenario, true);
323+
const off = await runScenario(redisContainer, scenario, false);
323324

324-
assertConservation(scenario, on, off);
325+
assertConservation(scenario, on, off);
325326

326-
const isTrickle = (ck: string) => ck.startsWith("trickle");
327-
const onWait = meanWait(on, isTrickle);
328-
const offWait = meanWait(off, isTrickle);
329-
debugLog("ckTrickle", { onWait, offWait, ratio: onWait / offWait });
327+
const isTrickle = (ck: string) => ck.startsWith("trickle");
328+
const onWait = meanWait(on, isTrickle);
329+
const offWait = meanWait(off, isTrickle);
330+
debugLog("ckTrickle", { onWait, offWait, ratio: onWait / offWait });
330331

331-
expect(onWait).toBeLessThanOrEqual(0.3 * offWait);
332-
});
332+
expect(onWait).toBeLessThanOrEqual(0.3 * offWait);
333+
}
334+
);
333335

334336
// ckSybil (spike shape, the case per-key caps cannot fix): 20 attacker keys
335337
// with 8 messages each, all on older heads, and 1 light key with 10 newer
336338
// messages. 21 variants against a batch of 10 exercises the batched path
337339
// properly: flag OFF walks the age order and only reaches the light key when
338340
// the attackers are nearly drained; flag ON serves the light key from the
339341
// floor on its first fair round.
340-
redisTest("ckSybil: many attacker keys cannot starve a light key", async ({
341-
redisContainer,
342-
}) => {
342+
redisTest("ckSybil: many attacker keys cannot starve a light key", async ({ redisContainer }) => {
343343
const t0 = Date.now() - 500_000;
344344
const messages: ScenarioMessage[] = [];
345345
for (let i = 0; i < 8; i++) {
@@ -397,72 +397,74 @@ describe("CK virtual-time fairness on the real batched dequeue path", () => {
397397

398398
// ckBalanced (spike shape, no-harm check): 4 symmetric keys with 25 messages
399399
// each. The fair order must not make the symmetric case worse.
400-
redisTest("ckBalanced: fair order does not hurt the symmetric case", async ({
401-
redisContainer,
402-
}) => {
403-
const t0 = Date.now() - 500_000;
404-
const cks = ["bal0", "bal1", "bal2", "bal3"];
405-
const messages: ScenarioMessage[] = [];
406-
for (let i = 0; i < 25; i++) {
407-
for (let k = 0; k < cks.length; k++) {
408-
messages.push({
409-
runId: `${cks[k]}-${i}`,
410-
ck: cks[k]!,
411-
timestamp: t0 + i * 4 + k,
412-
});
400+
redisTest(
401+
"ckBalanced: fair order does not hurt the symmetric case",
402+
async ({ redisContainer }) => {
403+
const t0 = Date.now() - 500_000;
404+
const cks = ["bal0", "bal1", "bal2", "bal3"];
405+
const messages: ScenarioMessage[] = [];
406+
for (let i = 0; i < 25; i++) {
407+
for (let k = 0; k < cks.length; k++) {
408+
messages.push({
409+
runId: `${cks[k]}-${i}`,
410+
ck: cks[k]!,
411+
timestamp: t0 + i * 4 + k,
412+
});
413+
}
413414
}
414-
}
415-
const scenario: Scenario = {
416-
name: "ckBalanced",
417-
messages,
418-
envConcurrencyLimit: 4,
419-
holdSteps: 3,
420-
maxSteps: 500,
421-
};
415+
const scenario: Scenario = {
416+
name: "ckBalanced",
417+
messages,
418+
envConcurrencyLimit: 4,
419+
holdSteps: 3,
420+
maxSteps: 500,
421+
};
422422

423-
const on = await runScenario(redisContainer, scenario, true);
424-
const off = await runScenario(redisContainer, scenario, false);
423+
const on = await runScenario(redisContainer, scenario, true);
424+
const off = await runScenario(redisContainer, scenario, false);
425425

426-
assertConservation(scenario, on, off);
426+
assertConservation(scenario, on, off);
427427

428-
const maxPerKeyMeanWait = (result: ScenarioResult) =>
429-
Math.max(...cks.map((ck) => meanWait(result, (c) => c === ck)));
428+
const maxPerKeyMeanWait = (result: ScenarioResult) =>
429+
Math.max(...cks.map((ck) => meanWait(result, (c) => c === ck)));
430430

431-
const onMax = maxPerKeyMeanWait(on);
432-
const offMax = maxPerKeyMeanWait(off);
433-
debugLog("ckBalanced", { onMax, offMax, ratio: onMax / offMax });
431+
const onMax = maxPerKeyMeanWait(on);
432+
const offMax = maxPerKeyMeanWait(off);
433+
debugLog("ckBalanced", { onMax, offMax, ratio: onMax / offMax });
434434

435-
expect(onMax).toBeLessThanOrEqual(1.25 * offMax);
436-
});
435+
expect(onMax).toBeLessThanOrEqual(1.25 * offMax);
436+
}
437+
);
437438

438439
// ckHeavyIdle (spike shape, work conservation): a single key with 60
439440
// messages and nothing else contending. Any extra step to drain under the
440441
// fair order is a work-conservation bug, so the step counts must be exactly
441442
// equal.
442-
redisTest("ckHeavyIdle: a lone key drains in exactly the same steps", async ({
443-
redisContainer,
444-
}) => {
445-
const t0 = Date.now() - 500_000;
446-
const messages: ScenarioMessage[] = [];
447-
for (let i = 0; i < 60; i++) {
448-
messages.push({ runId: `solo-${i}`, ck: "solo", timestamp: t0 + i });
449-
}
450-
const scenario: Scenario = {
451-
name: "ckHeavyIdle",
452-
messages,
453-
envConcurrencyLimit: 25,
454-
holdSteps: 3,
455-
maxSteps: 300,
456-
};
443+
redisTest(
444+
"ckHeavyIdle: a lone key drains in exactly the same steps",
445+
async ({ redisContainer }) => {
446+
const t0 = Date.now() - 500_000;
447+
const messages: ScenarioMessage[] = [];
448+
for (let i = 0; i < 60; i++) {
449+
messages.push({ runId: `solo-${i}`, ck: "solo", timestamp: t0 + i });
450+
}
451+
const scenario: Scenario = {
452+
name: "ckHeavyIdle",
453+
messages,
454+
envConcurrencyLimit: 25,
455+
holdSteps: 3,
456+
maxSteps: 300,
457+
};
457458

458-
const on = await runScenario(redisContainer, scenario, true);
459-
const off = await runScenario(redisContainer, scenario, false);
459+
const on = await runScenario(redisContainer, scenario, true);
460+
const off = await runScenario(redisContainer, scenario, false);
460461

461-
assertConservation(scenario, on, off);
462+
assertConservation(scenario, on, off);
462463

463-
debugLog("ckHeavyIdle", { onDrainStep: on.drainStep, offDrainStep: off.drainStep });
464+
debugLog("ckHeavyIdle", { onDrainStep: on.drainStep, offDrainStep: off.drainStep });
464465

465-
expect(on.drainStep).toBeGreaterThanOrEqual(0);
466-
expect(on.drainStep).toBe(off.drainStep);
467-
});
466+
expect(on.drainStep).toBeGreaterThanOrEqual(0);
467+
expect(on.drainStep).toBe(off.drainStep);
468+
}
469+
);
468470
});

internal-packages/run-engine/src/run-queue/tests/keyProducer.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -434,17 +434,17 @@ describe("KeyProducer", () => {
434434
});
435435

436436
it("produces ckVtime keys from a CK variant queue name", () => {
437-
const keys = new RunQueueFullKeyProducer();
437+
const keyProducer = new RunQueueFullKeyProducer();
438438
const q = "{org:o1}:proj:p1:env:e1:queue:task/my-task:ck:tenant-a";
439-
expect(keys.ckVtimeKeyFromQueue(q)).toBe(
439+
expect(keyProducer.ckVtimeKeyFromQueue(q)).toBe(
440440
"{org:o1}:proj:p1:env:e1:queue:task/my-task:ckVtime"
441441
);
442-
expect(keys.ckVtimeFloorKeyFromQueue(q)).toBe(
442+
expect(keyProducer.ckVtimeFloorKeyFromQueue(q)).toBe(
443443
"{org:o1}:proj:p1:env:e1:queue:task/my-task:ckVtimeFloor"
444444
);
445445
// ck wildcard and base-queue inputs normalise the same way
446-
expect(keys.ckVtimeKeyFromQueue(q.replace(":ck:tenant-a", ":ck:*"))).toBe(
447-
keys.ckVtimeKeyFromQueue(q)
446+
expect(keyProducer.ckVtimeKeyFromQueue(q.replace(":ck:tenant-a", ":ck:*"))).toBe(
447+
keyProducer.ckVtimeKeyFromQueue(q)
448448
);
449449
});
450450
});

0 commit comments

Comments
 (0)