docs(run-engine): plan + references for virtual-time CK fair scheduling

Implementation and testing plan for the recommended run-queue multi-tenant
fairness fix: score the concurrency-key dequeue by SFQ virtual time, layered under
the concurrency caps. Keeps the three spike findings and the queueing-theory
research as references; the throwaway spike harness/bench code is archived on the
remote branch chore/fair-queueing-spike and is not carried onto main.

The plan adds a parallel :ckVtime ZSET + floor (leaving ckIndex's timestamp domain
intact and mixed-deploy-safe), a flag-selected two-pass dequeue command (vtime
order then age-order fallback, so it never serves less than today), and a 19-test
suite that exercises the real batched maxCount>1 path the spikes could not.
This commit is contained in:
Wes Mason
2026-07-23 19:05:37 +01:00
parent 752665ca5a
commit ab89319f82
6 changed files with 1493 additions and 0 deletions
@@ -0,0 +1,829 @@
# Virtual-time (SFQ) scheduling for the concurrency-key dequeue
Implementation and testing plan for the recommendation out of three fairness
spikes. The spike harness and benchmark code are archived on the remote branch
`chore/fair-queueing-spike` (throwaway, never merged); the findings and research
they produced are kept alongside this plan as references:
`docs/superpowers/references/run-queue-fairness-ck-findings.md`,
`.../run-queue-fairness-caps-vs-scheduling-findings.md`, and
`.../run-queue-fairness-research.md`.
The recommendation: score the per-base-queue concurrency-key selection by
start-time fair queueing (SFQ) virtual time instead of head timestamp, inside the
real batched CK-dequeue Lua, layered UNDER the existing per-key concurrency gate
and the planned group caps. Caps bound occupancy; the fair order bounds wait under
contention (the Kubernetes-APF shape, and the Parekh-Gallager joint result).
## Goal
When many concurrency-key variants of one base queue are contending, the
dequeue order across variants follows SFQ virtual time (each variant advances
its own virtual clock by a quantum per serve; new variants join at a monotonic
floor). This fixes the #2617 starvation dynamic the spikes measured: a key
arriving behind a big backlog waits its fair turn instead of waiting for the
backlog to drain, and (unlike per-key caps) the fix survives a tenant sharding
its work across many keys, while staying work-conserving.
Everything is behind a constructor feature flag. Flag off is byte-identical to
today: the exact same Lua scripts run and no new Redis keys are ever touched.
## Architecture
The design keeps `ckIndex` exactly as it is and adds a parallel virtual-time
ZSET. This is the load-bearing decision, so the reasoning up front:
`ckIndex` scores are head-message timestamps, and three things depend on that
score domain staying timestamps:
1. Time eligibility. `ZRANGEBYSCORE ckIndexKey -inf now` filters out variants
whose head message is scheduled in the future (delayed runs, nack backoff).
Virtual-time tags carry no wall-clock meaning, so they cannot express "not
available yet".
2. Master-queue rebalancing. Every CK Lua (enqueue, dequeue, ack, nack, the
sweeper) re-scores the `:ck:*` master-queue member from `ZRANGE ckIndexKey
0 0 WITHSCORES`. The master queue is timestamp-ordered and compared against
`now`; writing virtual times there would corrupt the shard-level selection.
3. Every other writer. `enqueueMessageCkTracked`, `nackMessageCkTracked`,
`acknowledgeMessageCkTracked`, the concurrency sweeper (index.ts ~3733,
~3847) all `ZADD ckIndexKey <head timestamp>`. Rescoring only in the dequeue
Lua would leave a mixed score domain, and during a rolling deploy old
instances would keep writing timestamps regardless (the mixed-arity hazard
the caps plan warned about, in score-domain form).
So: instead of changing `ckIndex`'s score domain, add per base queue
- `{org:...}:...:queue:<base>:ckVtime`, a ZSET, member = the full CK-variant
queue name (the same member strings `ckIndex` holds), score = the variant's
next virtual start tag (the spike's `SfqCk.clock` value, i.e. start of last
serve + quantum).
- `{org:...}:...:queue:<base>:ckVtimeFloor`, a STRING holding the monotonic
floor (the CFS `min_vruntime` analogue from `disciplines.ts`).
Both live under the same `{org:...}` hash tag as every other key of the base
queue, so cluster slotting is unchanged and one Lua script can touch all of
them atomically.
The dequeue Lua (new command, flag-selected) runs two passes:
- Pass 1 (fair order): take candidates from `ckVtime` by rank (lowest tag
first, `ZRANGE 0 W-1 WITHSCORES`), and for each run the existing
per-candidate logic unchanged: per-key concurrency gate, per-variant
time-eligibility check (`ZRANGEBYSCORE <variant> -inf now LIMIT 0 1`), TTL
branch, counters, `ckIndex` rebalance. On each successful serve, advance
that variant's tag (`ZADD ckVtimeKey max(tag, floor) + quantum/weight`)
before moving to the next candidate, so the state is correct per serve
within the batch. A skipped variant (at cap, or head in the future) keeps
its tag: no service, no advance, which is the SFQ rule.
- Pass 2 (fill + discovery): if pass 1 served fewer than `actualMaxCount`,
scan `ckIndex` in today's age order (`ZRANGEBYSCORE -inf now LIMIT 0 W`),
skip variants already attempted in pass 1, and serve the rest through the
same per-candidate logic, registering each served variant into `ckVtime`.
Pass 2 makes the new command a strict superset of today's: it can never
serve fewer messages than the current script would, so work conservation
and mixed-deploy discovery both hold by construction.
Registration (how a variant gets INTO `ckVtime` before it is ever served):
every Lua that adds messages to a variant, i.e. the CK enqueue commands and
the CK nack command, gains a flag-selected variant that does
`ZADD ckVtimeKey NX <floor> <variant>` after its existing `ckIndex` rebalance.
`NX` means registration can never rewind an advanced tag. This is what makes
the sybil case work: a brand-new light key is present in the vtime order at
the floor from its first enqueue, so it is reachable in pass 1 even when a
hundred attacker variants have older heads (which is exactly where today's
age-ordered `*3` window fails, per CAPS_FINDINGS).
Closure argument for registration (state this as an invariant and test it):
a variant's queue becomes non-empty only via enqueue or nack, both of which
register. The sweeper and ack/release Luas only rebalance variants whose
queues are already non-empty, so they never need to register. The dequeue Lua
GCs a variant from BOTH `ckIndex` and `ckVtime` when its queue is empty, so
membership stays closed under all transitions. The one gap is old-code
enqueues during a rolling deploy, and pass 2 covers that (served via age
order, registered on serve).
Batched-call semantics: the current Lua serves at most ONE message per variant
per call (`LIMIT 0, 1` per candidate, and ZSET members are unique in the
candidate list). The new command keeps that. Within one call the batch is
therefore one-serve-per-variant round robin over the `actualMaxCount` lowest
tags, and each serve's `ZADD` makes the NEXT call's order correct. This
deviates from pure SFQ within a single batch (pure SFQ could serve the same
far-behind variant several times in a row) but converges across calls, and
one-per-variant is itself a fair schedule. Preserving it also means zero
change to today's per-call throughput shape.
Layering with caps: the per-key gate (`ckCurrentConcurrency <
queueConcurrencyLimit`) stays exactly where it is, ahead of the serve. The
planned Phase-1 `:groupConcurrency`/`:totalConcurrency` total cap and Phase-2
`:ckLimits` per-key overrides slot into the same per-candidate position as
additional admission conditions when they land; virtual time only decides the
ORDER among candidates those gates admit. Nothing in this plan blocks or is
blocked by the caps work, and the fairQueue-level "queue at total" drop stays
untouched (the CK pick is below the `RunQueueSelectionStrategy` interface;
`fairQueueSelectionStrategy.ts` is not modified).
Weights: concurrency keys carry no configured weight today, so every key gets
weight 1 (quantum advance of 1.0 per serve). The advance is written as
`quantum / weight` with `weight` a named local fixed at 1, so a future
per-key weight (e.g. a sparse `:ckWeights` HASH mirroring the Phase-2
`:ckLimits` shape) is a one-line change at the marked site. Justification for
equal-weight first: the spikes only measured equal weights, no product surface
exists to set a weight, and SFQ's starvation fix does not depend on weights.
State lifecycle (GC/TTL), since concurrency keys are client-chosen and
unbounded:
- Per-variant GC: whenever the dequeue Lua finds a variant queue empty it
already `ZREM`s the variant from `ckIndex`; the new command also `ZREM`s it
from `ckVtime` at those sites. A GC'd key that returns re-registers at the
floor, which is standard SFQ flow re-entry (history is forgiven when a flow
drains; a drained flow was by definition not backlogged).
- Whole-key TTL: `ckVtime` and `ckVtimeFloor` get `EXPIRE <stateTtlSeconds>`
(default 86400, matching the `counterTtlSeconds` precedent) refreshed on
every write. An idle base queue's vtime state evaporates; on resumption
everyone re-enters at floor 0, which is a clean restart. If only the floor
key expires, tags in `ckVtime` still self-heal because every read applies
`max(tag, floor)` and the next dequeue re-advances the floor to the minimum
stored tag.
- Cardinality guard: tags are only created for variants that actually have
queued messages (registration happens on enqueue/nack, GC on empty), so
`ckVtime` cardinality is bounded by `ckIndex` cardinality plus transiently
stale entries awaiting scan-time GC or TTL expiry.
Floor semantics: on each dequeue call, `floor = max(stored floor, score of
ckVtime rank 0)`, written back with the TTL. The floor never decreases (test
this), and a newly registered key's tag starts AT the floor, so it can never
be scheduled behind the accumulated backlog of long-running keys (the SFQ
property; this is the exact `SfqCk` logic from `disciplines.ts`, moved into
Lua with the Map replaced by the ZSET and the floor by the STRING).
Numeric domain: tags are Redis doubles starting at 0 advancing by 1.0 per
serve; integer-exact to 2^53 serves per base queue, so precision is a
non-issue.
The scan window: pass 1's window is `actualMaxCount * windowMultiplier`
(default multiplier 3, same as today, made configurable). The score domain of
the window changes meaning: today the window can hide the oldest ELIGIBLE
head behind at-cap variants with older heads; under vtime it can hide the
lowest ELIGIBLE tag behind at-cap or future-scheduled variants with lower
tags. Those clogging variants keep low tags while skipped (no serve, no
advance), so the failure shape is symmetric with today's, and it is the same
class of limitation CAPS_FINDINGS documents for the `*3` window rather than a
new one. We do not widen the default; we make the multiplier an option so an
operator can widen it if per-key caps plus heavy nack backoff ever clog a
window in practice, and pass 2 guarantees the call still finds work.
## Tech stack
- Redis Lua (ioredis `defineCommand`) in
`internal-packages/run-engine/src/run-queue/index.ts`, following the
existing tracked-command patterns.
- TypeScript for options plumbing and call-site selection.
- vitest + `@internal/testcontainers` (`redisTest`) for all tests. No mocks.
- Verification: `pnpm run typecheck --filter @internal/run-engine` and
`cd internal-packages/run-engine && pnpm run test <file> --run`.
## Global constraints
- Flag off must be byte-identical: off-path call sites keep calling the
existing command names whose script text is not edited at all. New
behaviour lives only in NEW command names (`...Vtime...`), selected in TS.
This is why we add command variants instead of threading an `enableVtime`
ARGV through existing scripts: an ARGV-gated single script would still be a
new script body (new SHA, new arity risks) even when the flag is off.
- `ckIndex`, the master queue, `fairQueueSelectionStrategy.ts`, and all
ack/release/sweeper Luas keep their current score domain and text.
- The dead untracked `dequeueMessagesFromCkQueue` (index.ts ~3999-4141) is not
touched and gets no vtime variant.
- All vtime state mutations happen inside single Lua scripts (atomic; Redis
serialises scripts, which is the whole multi-consumer correctness story).
- No process-memory scheduling state anywhere.
- Do not import anything from `fairness-spike-ck/` or `fairness-spike/` into
production code or the new tests; those directories are throwaway (their
own headers say delete before merge). Port logic and scenario shapes by
copying, with attribution comments.
- Zod stays at the repo-pinned version; no new dependencies.
- Formatting/lint before commit: `pnpm run format && pnpm run lint:fix`.
## File structure
Modify:
- `internal-packages/run-engine/src/run-queue/keyProducer.ts`
(two new key builders + constants)
- `internal-packages/run-engine/src/run-queue/types.ts`
(`RunQueueKeyProducer` interface additions)
- `internal-packages/run-engine/src/run-queue/index.ts`
(options field; four new `defineCommand`s; TS module augmentation for them;
flag switches at the enqueue/nack/dequeue call sites; span attributes)
- `internal-packages/run-engine/src/run-queue/tests/keyProducer.test.ts`
(key builder tests)
- `internal-packages/run-engine/src/engine/types.ts`
(`RunEngineOptions["queue"].ckVirtualTimeScheduling`)
- `internal-packages/run-engine/src/engine/index.ts`
(pass the option through to `new RunQueue({...})`, ~line 196)
- `apps/webapp/app/env.server.ts`
(`RUN_ENGINE_CK_VTIME_SCHEDULING_ENABLED` and friends)
- `apps/webapp/app/v3/runEngine.server.ts`
(wire env vars into the engine options, ~line 61 `queue:` block)
Create:
- `internal-packages/run-engine/src/run-queue/tests/ckVtime.test.ts`
(Lua behaviour: ordering, floor, tag init, advance-within-batch, GC, TTL,
registration, pass-2 fill, flag-off keyspace purity)
- `internal-packages/run-engine/src/run-queue/tests/ckVtimeFairness.test.ts`
(ported scenarios at batched maxCount, wait/share/work-conservation/sybil
assertions)
- `internal-packages/run-engine/src/run-queue/tests/ckVtimeConcurrency.test.ts`
(multi-consumer correctness, op-count budget)
- `.server-changes/2026-XX-XX-ck-fair-scheduling.md` (at PR time; note it
ships dark)
## Tasks
### Task 1: key producer additions
Files: `keyProducer.ts`, `types.ts`, `tests/keyProducer.test.ts`.
Test first (append to `tests/keyProducer.test.ts`, matching its existing
style):
```ts
it("produces ckVtime keys from a CK variant queue name", () => {
const keys = new RunQueueFullKeyProducer();
const q = "{org:o1}:proj:p1:env:e1:queue:task/my-task:ck:tenant-a";
expect(keys.ckVtimeKeyFromQueue(q)).toBe(
"{org:o1}:proj:p1:env:e1:queue:task/my-task:ckVtime"
);
expect(keys.ckVtimeFloorKeyFromQueue(q)).toBe(
"{org:o1}:proj:p1:env:e1:queue:task/my-task:ckVtimeFloor"
);
// ck wildcard and base-queue inputs normalise the same way
expect(keys.ckVtimeKeyFromQueue(q.replace(":ck:tenant-a", ":ck:*"))).toBe(
keys.ckVtimeKeyFromQueue(q)
);
});
```
Implementation in `keyProducer.ts`: add to `constants`
```ts
CK_VTIME_PART: "ckVtime",
CK_VTIME_FLOOR_PART: "ckVtimeFloor",
```
and the builders (next to `ckIndexKeyFromQueue`):
```ts
ckVtimeKeyFromQueue(queue: string): string {
return `${this.baseQueueKeyFromQueue(queue)}:${constants.CK_VTIME_PART}`;
}
ckVtimeFloorKeyFromQueue(queue: string): string {
return `${this.baseQueueKeyFromQueue(queue)}:${constants.CK_VTIME_FLOOR_PART}`;
}
```
Add both signatures to `RunQueueKeyProducer` in `types.ts`.
Verify: `cd internal-packages/run-engine && pnpm run test ./src/run-queue/tests/keyProducer.test.ts --run`
(new tests pass, existing pass), then
`pnpm run typecheck --filter @internal/run-engine` (clean).
### Task 2: options plumbing in RunQueue
File: `index.ts` (RunQueueOptions, ~line 60).
```ts
/**
* Fair (virtual-time / SFQ) ordering across concurrency-key variants of a
* base queue. Off by default; when off, the exact pre-existing Lua commands
* run and no vtime keys are created. See docs/superpowers/plans/
* 2026-07-23-ck-virtual-time-scheduling-plan.md.
*/
ckVirtualTimeScheduling?: {
enabled: boolean;
/** Virtual-time advance per serve (dimensionless). Default 1. */
quantum?: number;
/** Pass-1 candidate window = actualMaxCount * this. Default 3. */
scanWindowMultiplier?: number;
/** EXPIRE applied to ckVtime/ckVtimeFloor on every write. Default 86400. */
stateTtlSeconds?: number;
};
```
Store resolved values once in the constructor (private readonly fields
`#ckVtimeEnabled`, `#ckVtimeQuantum`, `#ckVtimeWindowMultiplier`,
`#ckVtimeStateTtl`) so call sites read fields, never re-derive.
Verify: `pnpm run typecheck --filter @internal/run-engine`.
### Task 3: the vtime dequeue Lua (the core change)
File: `index.ts`. New command `dequeueMessagesFromCkQueueVtimeTracked`,
`numberOfKeys: 12` (the 10 keys of `dequeueMessagesFromCkQueueTracked` plus
`ckVtimeKey`, `ckVtimeFloorKey`), plus its entry in the ioredis module
augmentation (next to the existing declaration at ~line 5591, same parameter
list plus `ckVtimeKey: string, ckVtimeFloorKey: string` after
`lengthCounterKey` and `quantum: string, windowMultiplier: string,
stateTtlSeconds: string` after `maxCount`).
Write the failing tests FIRST in `tests/ckVtime.test.ts`. Scaffold the file
from `tests/ckIndex.test.ts` (same `testOptions`, `authenticatedEnvDev`,
`createQueue`, `makeMessage` helpers), with `createQueue` extended to accept
`ckVirtualTimeScheduling` overrides. Tests to write in this task:
1. "vtime order beats head-timestamp order": enqueue 30 messages on
`ck: heavy` with timestamps `t0 .. t0+29`, then 3 messages on `ck: light`
at `t0+1000`. Register both (enqueue registration is Task 4; until then
the test seeds `ckVtime` directly with
`queue.redis.zadd(ckVtimeKey, 0, heavyVariant, 0, lightVariant)`).
Dequeue with `maxCount: 10` repeatedly (acking between calls to free
concurrency). Assert light's 3 messages are all served within the first 3
calls (age order alone would drain heavy first). Assert each call returns
at most one message per variant.
2. "tags advance per serve within one batched call": seed 5 variants at tag
0, one message each; one dequeue call with `maxCount: 5`; assert all 5
served and `ZSCORE ckVtime <v>` is `1` for each (advanced inside the one
call, not once per call).
3. "floor is monotonic and read-repairs": drive tags to ~20 by repeated
serve of two keys, assert `GET ckVtimeFloor` never decreased across calls
(sample after each call), and equals the min stored tag after the last.
4. "new key initialises at the floor, not zero and not behind the backlog":
after tags reach ~20, register a fresh variant with the enqueue path (or
direct ZADD NX at the current floor pre-Task-4), enqueue one message on
it, one dequeue call; assert the fresh variant is served in that first
call and its tag afterwards is `floor + quantum`, not `1`.
5. "no service, no advance": set the base queue concurrency limit to 1 via
`queue.updateQueueConcurrencyLimits`, occupy `ck: a`'s slot (dequeue one,
do not ack), then call dequeue; assert `ck: a` was skipped, its tag is
unchanged, and other variants were served.
6. "GC on empty variant": drain a variant completely; assert it is removed
from BOTH `ckIndex` and `ckVtime`.
7. "TTL is set and refreshed": after any dequeue, `PTTL ckVtime` and
`PTTL ckVtimeFloor` are in `(0, stateTtlSeconds * 1000]`.
8. "pass 2 fill serves unregistered variants and registers them": enqueue on
a variant, delete its `ckVtime` entry by hand (simulating an old-code
enqueue), dequeue; assert the message is served AND the variant now has a
`ckVtime` tag.
9. "future-scheduled variants are skipped without advance": nack a message
with a future score (or enqueue with future timestamp); dequeue; assert
the variant is not served and its tag is unchanged.
The Lua. Full sketch (the per-candidate serve body is today's tracked body
verbatim; only the parts marked NEW differ):
```lua
local ckIndexKey = KEYS[1]
local queueConcurrencyLimitKey = KEYS[2]
local envConcurrencyLimitKey = KEYS[3]
local envConcurrencyLimitBurstFactorKey = KEYS[4]
local envCurrentConcurrencyKey = KEYS[5]
local messageKeyPrefix = KEYS[6]
local envQueueKey = KEYS[7]
local masterQueueKey = KEYS[8]
local ttlQueueKey = KEYS[9]
local lengthCounterKey = KEYS[10]
local ckVtimeKey = KEYS[11] -- NEW
local ckVtimeFloorKey = KEYS[12] -- NEW
local ckWildcardName = ARGV[1]
local currentTime = tonumber(ARGV[2])
local defaultEnvConcurrencyLimit = ARGV[3]
local defaultEnvConcurrencyBurstFactor = ARGV[4]
local keyPrefix = ARGV[5]
local maxCount = tonumber(ARGV[6] or '1')
local quantum = tonumber(ARGV[7] or '1') -- NEW
local windowMultiplier = tonumber(ARGV[8] or '3') -- NEW
local stateTtl = tonumber(ARGV[9] or '86400') -- NEW
local function decrLengthCounter()
if tonumber(redis.call('GET', lengthCounterKey) or '0') > 0 then
redis.call('DECR', lengthCounterKey)
end
end
-- env gate: identical to dequeueMessagesFromCkQueueTracked
local envCurrentConcurrency = tonumber(redis.call('SCARD', envCurrentConcurrencyKey) or '0')
local envConcurrencyLimit = tonumber(redis.call('GET', envConcurrencyLimitKey) or defaultEnvConcurrencyLimit)
local envConcurrencyLimitBurstFactor = tonumber(redis.call('GET', envConcurrencyLimitBurstFactorKey) or defaultEnvConcurrencyBurstFactor)
local envConcurrencyLimitWithBurstFactor = math.floor(envConcurrencyLimit * envConcurrencyLimitBurstFactor)
if envCurrentConcurrency >= envConcurrencyLimitWithBurstFactor then
return nil
end
local queueConcurrencyLimit = math.min(tonumber(redis.call('GET', queueConcurrencyLimitKey) or '1000000'), envConcurrencyLimit)
local envAvailableCapacity = envConcurrencyLimitWithBurstFactor - envCurrentConcurrency
local actualMaxCount = math.min(maxCount, envAvailableCapacity)
if actualMaxCount <= 0 then
return nil
end
local window = actualMaxCount * windowMultiplier
-- NEW: monotonic floor, advanced to the minimum stored tag
local floor = tonumber(redis.call('GET', ckVtimeFloorKey) or '0')
local minEntry = redis.call('ZRANGE', ckVtimeKey, 0, 0, 'WITHSCORES')
if #minEntry > 0 then
local minTag = tonumber(minEntry[2])
if minTag > floor then
floor = minTag
end
end
local results = {}
local dequeuedCount = 0
local attempted = {}
-- Per-candidate serve. Body between BEGIN/END COPY is today's tracked
-- per-candidate block, unmodified except the two NEW lines.
local function tryServe(ckQueueName)
attempted[ckQueueName] = true
local fullQueueKey = keyPrefix .. ckQueueName
local ckConcurrencyKey = fullQueueKey .. ':currentConcurrency'
local ckCurrentConcurrency = tonumber(redis.call('SCARD', ckConcurrencyKey) or '0')
if ckCurrentConcurrency >= queueConcurrencyLimit then
return
end
-- BEGIN COPY (from dequeueMessagesFromCkQueueTracked, lines ~4219-4273)
local messages = redis.call('ZRANGEBYSCORE', fullQueueKey, '-inf', tostring(currentTime), 'WITHSCORES', 'LIMIT', 0, 1)
if #messages >= 2 then
-- ... TTL-expired / normal-dequeue / stale-orphan branches verbatim ...
-- in the normal-dequeue branch, after dequeuedCount = dequeuedCount + 1:
-- NEW: advance this variant's virtual time (weight hook: fixed 1 today)
-- local weight = 1
-- local tag = tonumber(redis.call('ZSCORE', ckVtimeKey, ckQueueName) or floor)
-- if tag < floor then tag = floor end
-- redis.call('ZADD', ckVtimeKey, tag + (quantum / weight), ckQueueName)
-- rebalance ckIndex from the variant head, verbatim, plus:
-- NEW: if the variant queue is empty, also redis.call('ZREM', ckVtimeKey, ckQueueName)
else
-- empty-in-range branch verbatim, plus the same NEW ZREM when fully empty
end
-- END COPY
end
-- Pass 1: fair order (lowest virtual start tag first)
local vtimeCandidates = redis.call('ZRANGE', ckVtimeKey, 0, window - 1)
for _, ckQueueName in ipairs(vtimeCandidates) do
if dequeuedCount >= actualMaxCount then break end
tryServe(ckQueueName)
end
-- Pass 2: fill + discovery in today's age order (work conservation,
-- mixed-deploy safety). Never runs when pass 1 filled the batch.
if dequeuedCount < actualMaxCount then
local ckQueues = redis.call('ZRANGEBYSCORE', ckIndexKey, '-inf', tostring(currentTime), 'LIMIT', 0, window)
for _, ckQueueName in ipairs(ckQueues) do
if dequeuedCount >= actualMaxCount then break end
if not attempted[ckQueueName] then
tryServe(ckQueueName)
end
end
end
-- NEW: persist floor and refresh TTLs
redis.call('SET', ckVtimeFloorKey, tostring(floor), 'EX', stateTtl)
if redis.call('EXISTS', ckVtimeKey) == 1 then
redis.call('EXPIRE', ckVtimeKey, stateTtl)
end
-- master queue rebalance: verbatim from the tracked command (uses ckIndex,
-- which keeps its timestamp domain)
local earliestIdx = redis.call('ZRANGE', ckIndexKey, 0, 0, 'WITHSCORES')
if #earliestIdx == 0 then
redis.call('ZREM', masterQueueKey, ckWildcardName)
else
redis.call('ZADD', masterQueueKey, earliestIdx[2], ckWildcardName)
end
return results
```
Note the `tryServe` extraction is inside the NEW script only; the old script
is not refactored. When writing the real script, inline today's per-candidate
block into `tryServe` exactly (including `decrLengthCounter`, the TTL-member
removal, and both rebalance branches); the sketch elides it to keep the plan
readable, and the byte-identity constraint applies to the OLD script, which
is untouched.
Call-site switch in `#callDequeueMessagesFromCkQueue` (~line 2206):
```ts
const result = this.#ckVtimeEnabled
? await this.redis.dequeueMessagesFromCkQueueVtimeTracked(
ckIndexKey, queueConcurrencyLimitKey, envConcurrencyLimitKey,
envConcurrencyLimitBurstFactorKey, envCurrentConcurrencyKey,
messageKeyPrefix, envQueueKey, masterQueueKey, ttlQueueKey,
lengthCounterKey,
this.keys.ckVtimeKeyFromQueue(ckWildcardQueue),
this.keys.ckVtimeFloorKeyFromQueue(ckWildcardQueue),
ckWildcardQueue, String(Date.now()),
String(this.options.defaultEnvConcurrency),
String(this.options.defaultEnvConcurrencyBurstFactor ?? 1),
this.options.redis.keyPrefix ?? "", String(maxCount),
String(this.#ckVtimeQuantum), String(this.#ckVtimeWindowMultiplier),
String(this.#ckVtimeStateTtl)
)
: await this.redis.dequeueMessagesFromCkQueueTracked(/* unchanged */);
```
Add span attributes on the vtime path: `ck_vtime_enabled: true` plus, from a
small extension of the return shape if desired later, keep it simple now and
only tag the flag.
Verify: `cd internal-packages/run-engine && pnpm run test ./src/run-queue/tests/ckVtime.test.ts --run`
(tests 1-3, 5-9 pass; 4 passes with the direct-ZADD seeding until Task 4),
then `pnpm run typecheck --filter @internal/run-engine`.
### Task 4: enqueue registration
File: `index.ts`. Two new commands, `enqueueMessageCkVtimeTracked` and
`enqueueMessageWithTtlCkVtimeTracked`, `numberOfKeys: 17` (the existing 15
plus `ckVtimeKey` as KEYS[16] and `ckVtimeFloorKey` as KEYS[17]; the WithTtl
variant is existing-16 plus 2), ARGV extended with `stateTtl`. Script body =
existing tracked script verbatim, plus, in the SLOW PATH ONLY, immediately
after the `-- Rebalance CK index` block:
```lua
-- Register this variant in the virtual-time index at the floor. NX means an
-- already-advanced tag is never rewound.
local vfloor = redis.call('GET', ckVtimeFloorKey) or '0'
redis.call('ZADD', ckVtimeKey, 'NX', vfloor, queueName)
redis.call('EXPIRE', ckVtimeKey, stateTtl)
```
The fast path (direct-to-worker-queue when the variant is empty and capacity
is free) does NOT register or advance; see open decision 1.
Tests first, in `tests/ckVtime.test.ts`:
10. "enqueue registers the variant at the current floor with NX": drive the
floor to ~5 via serves, enqueue on a fresh key, assert
`ZSCORE ckVtime <fresh>` equals the floor; enqueue a second message on a
key whose tag is 9, assert the tag is still 9.
11. "test 4 now passes end-to-end without direct ZADD seeding" (remove the
seeding from test 4).
12. "fast path leaves vtime state untouched": empty variant, free capacity,
enqueue (fast path fires, returns 1); assert no `ckVtime` entry was
created for it. Then saturate capacity, enqueue again (slow path);
assert registration happened.
Call-site switches at ~lines 1906 and 1941 pick the vtime variants when
`this.#ckVtimeEnabled`, passing the two extra keys and `stateTtl`. Add both
to the module augmentation.
Verify: same test file command; plus
`pnpm run test ./src/run-queue/tests/enqueueMessage.test.ts --run` and
`./src/run-queue/tests/ckIndex.test.ts --run` still green (flag off).
### Task 5: nack registration
File: `index.ts`. New command `nackMessageCkVtimeTracked`,
`numberOfKeys: 13` (existing 11 plus the two vtime keys), ARGV plus
`stateTtl`. Body = existing verbatim plus the same NX-register block after
its `-- Rebalance CK index` section. Call-site switch at ~line 2584.
Test first (in `tests/ckVtime.test.ts`):
13. "nack re-registers a GC'd variant": enqueue one message on `ck: a`,
dequeue it (variant now GC'd from both indexes), nack it; assert the
variant is back in `ckIndex` AND in `ckVtime` at the floor, and a
subsequent dequeue serves it (respecting its future score if the nack
applied backoff: use a nack with an immediate retry score).
Closure invariant test:
14. "ckVtime membership tracks ckIndex membership": property-style loop of
~200 random operations (enqueue on 1 of 8 keys, dequeue batch, ack or
nack a random in-flight message); after each step assert every member of
`ckIndex` is a member of `ckVtime` (the converse may transiently not
hold, which is fine; stale `ckVtime` entries GC on scan).
Verify: `pnpm run test ./src/run-queue/tests/ckVtime.test.ts --run` and
`./src/run-queue/tests/nack.test.ts --run` (flag off, untouched).
### Task 6: fairness scenarios on the real batched path
File: `tests/ckVtimeFairness.test.ts` (new). This closes the spike's fidelity
gap: the spike proved the ordering at `maxCount = 1` with driver-side
rescoring; these tests drive the REAL batched Lua (`maxCount = 10`) with the
state advanced inside the script.
Harness design (deterministic, no wall-clock sleeps, no spike imports): a
step loop against one `RunQueue` on testcontainers Redis.
- Enqueue with explicit `timestamp` values in `InputPayload` (all in the
past so everything is time-eligible; the backlog key gets one old shared
timestamp, other keys get strictly increasing later timestamps, mirroring
the ckScenarios head-age reasoning).
- Each step: call the dequeue path once with `maxCount: 10` (via the public
dequeue API used by `ckIndex.test.ts`), record `(step, variant, messageId)`
per served message, then ack each served message after a per-key logical
hold of H steps (keep a small in-flight list and ack entries whose
`servedAt + H <= step`), which is how the env concurrency contends.
- Wait metric per message = serve step minus a per-message logical arrival
step (arrival step derived from the enqueue order). All assertions are on
ratios between flag-on and flag-off runs of the SAME scenario and seed, so
they are stable in CI; use generous factors.
Scenarios (ported shapes from `ckScenarios.ts` and
`capsFairness.bench.test.ts`, scaled down for CI):
- ckSkew: heavy 120 backlog msgs (old shared head), 4 light keys x 10 msgs
(later heads). env limit 4, hold 3 steps. Assert: mean light-key wait with
flag ON <= 0.3 x flag OFF (spike measured ~1100 -> ~15, so 0.3 is very
loose); heavy key's wait may rise (do not assert it down).
- ckTrickle: bulk 120, two trickle keys x 15. Same assertion.
- ckSybil (the case caps cannot fix): 20 attacker keys x 8 msgs each, all
older heads, 1 light key x 10 newer. Assert: flag ON mean light wait
<= 0.7 x flag OFF (spike: 1765 -> 1009), AND light key's first serve
happens within the first 3 steps (reachability at the floor), AND
contention-window share: over the steps where >= 2 keys have queued
backlog, light's served fraction >= 0.5 x its fair share 1/21 (directional,
per the spike's confounding caveat; wait is the headline).
- ckBalanced (no-harm check): 4 symmetric keys x 25. Assert: max per-key
mean wait with flag ON <= 1.25 x flag OFF (fair order must not make the
symmetric case worse).
- ckHeavyIdle (work conservation): single key, 60 msgs. Assert: steps to
drain with flag ON == flag OFF exactly (nothing else contends, so any
extra step is a work-conservation bug).
Also assert in every scenario: total served ON == total served OFF == total
enqueued (no loss, no double-serve; `messageId`s unique).
Verify: `cd internal-packages/run-engine && pnpm run test ./src/run-queue/tests/ckVtimeFairness.test.ts --run`
(all scenarios pass; target < 60s wall time total, scale message counts down
if needed before loosening assertions).
### Task 7: multi-consumer / multi-shard correctness
File: `tests/ckVtimeConcurrency.test.ts` (new).
15. "two consumers, one base queue, no corruption": one `RunQueue` for
enqueues, two more instances (same Redis, same key prefix, flag on) each
running a dequeue loop with `maxCount: 5` concurrently
(`Promise.all` of two loops, acking with a short hold). 6 keys x 30
messages. Assert: every message served exactly once across both
consumers (union of served IDs has no duplicates and equals the enqueued
set); after drain, `ckVtime` is empty and floor equals the max it ever
reached; sample the floor between iterations and assert it never
decreased. The correctness argument is that every mutation happens
inside one Lua script and Redis serialises scripts; this test is the
check that the scripts do not assume cross-call state.
16. "concurrent enqueue during dequeue cannot rewind a tag": interleave
enqueues on a hot key with dequeue batches; after each round assert
`ZSCORE ckVtime <hot>` is non-decreasing (NX registration + advance-only
writes).
17. "op-count budget": using a second plain Redis client, `CONFIG RESETSTAT`,
run 50 identical dequeue calls flag OFF, snapshot
`INFO commandstats` total calls; repeat flag ON with identical data.
Assert `on_total <= off_total + 50 * (6 + 2 * maxCount)` (per call the
vtime path adds at worst: GET floor, ZRANGE min, ZRANGE window, SET
floor, EXPIRE, the pass-2 ZRANGEBYSCORE, plus per serve one ZSCORE and
one ZADD). This pins the per-dequeue overhead the way the caps plan pins
the fairQueue snapshot cost.
Verify: `cd internal-packages/run-engine && pnpm run test ./src/run-queue/tests/ckVtimeConcurrency.test.ts --run`.
### Task 8: default-off regression proof
Location: `tests/ckVtime.test.ts` (final describe block).
18. "flag off creates no vtime keys and matches today's order": with
`ckVirtualTimeScheduling` absent, run a mixed sequence (enqueues across
3 keys with distinct head ages, batched dequeues, one nack, acks), then:
`KEYS *` contains no key matching `*ckVtime*`; the dequeue order equals
the head-timestamp order (re-assert the core expectation of
`ckIndex.test.ts` inside this sequence). The stronger guarantee (same
script text, same SHA) holds by construction: the off path calls the
same command names whose `defineCommand` strings this plan never edits;
say so in a comment rather than pretending a test can diff against an
old build.
19. Run the whole existing run-queue suite with the code in place and flag
off: `cd internal-packages/run-engine && pnpm run test ./src/run-queue/ --run`
(excluding the `fairness-spike*` dirs if they are still present). All
green.
### Task 9: engine and webapp wiring (code-dark rollout)
Files: `engine/types.ts`, `engine/index.ts`, `apps/webapp/app/env.server.ts`,
`apps/webapp/app/v3/runEngine.server.ts`.
- `engine/types.ts`, inside `queue:`:
`ckVirtualTimeScheduling?: RunQueueOptions["ckVirtualTimeScheduling"];`
- `engine/index.ts` (~line 196): pass
`ckVirtualTimeScheduling: options.queue?.ckVirtualTimeScheduling,`.
- `env.server.ts`:
`RUN_ENGINE_CK_VTIME_SCHEDULING_ENABLED: z.string().default("0")`,
`RUN_ENGINE_CK_VTIME_QUANTUM: z.coerce.number().default(1)`,
`RUN_ENGINE_CK_VTIME_WINDOW_MULTIPLIER: z.coerce.number().default(3)`,
`RUN_ENGINE_CK_VTIME_STATE_TTL_SECONDS: z.coerce.number().default(86400)`
(match the file's existing patterns for flag-style vars).
- `runEngine.server.ts` `queue:` block:
```ts
ckVirtualTimeScheduling:
env.RUN_ENGINE_CK_VTIME_SCHEDULING_ENABLED === "1"
? {
enabled: true,
quantum: env.RUN_ENGINE_CK_VTIME_QUANTUM,
scanWindowMultiplier: env.RUN_ENGINE_CK_VTIME_WINDOW_MULTIPLIER,
stateTtlSeconds: env.RUN_ENGINE_CK_VTIME_STATE_TTL_SECONDS,
}
: undefined,
```
Mixed-deploy analysis to record in the PR description (the analogue of the
caps plan's mixed-arity warning): old and new instances coexist safely
because ioredis registers scripts per process, so arity never mixes within a
script call; old-instance enqueues skip registration and old-instance
dequeues neither advance tags nor GC `ckVtime`. Consequences during overlap:
unregistered variants are served via pass 2 (age order, today's behaviour)
and get registered on serve; keys served by old instances gain a temporary
priority bias (tags lag), which the floor bounds and which disappears when
the rollout completes. No state leaks: `ckVtime` entries created during a
rollout that is then rolled BACK are ignored by the old script entirely and
expire via the state TTL. Turning the flag OFF after running ON is the same:
stale vtime keys are inert and expire within `stateTtlSeconds`.
Verify: `pnpm run typecheck --filter @internal/run-engine` and
`pnpm run typecheck --filter webapp`.
### Task 10: ship notes and cleanup
- Add `.server-changes/2026-XX-XX-ck-fair-scheduling.md` per
`.server-changes/README.md` (user-facing wording; it ships dark, so the
note says the fair ordering exists behind a flag and changes nothing by
default). No changeset (no public package touched).
- `pnpm run format && pnpm run lint:fix` before committing.
- The `fairness-spike/` and `fairness-spike-ck/` directories say "delete
before any merge to main" in their own headers. Deleting them is a
separate commit/decision, not part of this implementation branch; do not
import from them (already a global constraint).
## Rollout sequence (after merge)
1. Deploy with the flag off (nothing changes; scripts for the new commands
are registered but never called).
2. Enable on a staging/test cell; watch dequeue latency spans and Redis op
rates against the Task-7 budget; run a manual sybil-shaped workload and
confirm the light key's wait.
3. Enable in production. During the instance-rolling window the behaviour
interpolates between age order and fair order per the mixed-deploy
analysis; both endpoints are safe.
4. Rollback at any point = flip the env var off; stale vtime keys expire via
TTL within 24h.
## Open design decisions (flagged, with recommended defaults)
1. Fast-path enqueue does not advance or register virtual time.
Recommended: keep it that way. The fast path fires only when the variant
queue is empty AND env and queue capacity are free, i.e. when there is no
contention, and fairness only exists under contention. Charging fast-path
serves would need vtime keys touched on the hot uncontended path for no
measurable benefit. Revisit only if a workload alternates fast-path and
queued serves on the same keys at saturation boundaries (the Task-6
ckBalanced no-harm test would catch a regression shape here).
2. Stored tag semantics and quantum. Recommended: store the NEXT start tag
(start of last serve + quantum), quantum 1.0, matching `SfqCk` in
`disciplines.ts` exactly, since that is the vetted logic both spikes
measured. A cost-proportional quantum (e.g. by machine size) is possible
later via the same field.
3. Pass-1 window multiplier. Recommended: default 3 (today's), configurable.
The residual reachability limit (more than `window` at-cap or
future-scheduled low-tag variants hiding an eligible one) is the same
class as today's `*3` limit and pass 2 keeps the call work-conserving;
widening by default would raise per-call cost for a case not yet observed.
4. Registration sites. Recommended: enqueue and nack only, with pass 2 as
the safety net, per the closure argument (only enqueue and nack make a
variant queue non-empty). Adding registration to the sweeper/ack Luas
would touch more scripts for no covered transition.
5. State TTL default. Recommended: 86400s, matching `counterTtlSeconds`'s
precedent and rationale (periodic re-anchor bounds any drift, including
drift from rolling-deploy overlap).
6. Command variants vs ARGV-gated single script. Recommended: separate
`...Vtime...` commands. Byte-identity when off then holds by construction
instead of by test.
7. Equal weights. Recommended: yes, with the `quantum / weight` hook left in
place (weight fixed at 1, named local, comment pointing at a future
sparse `:ckWeights` HASH shaped like Phase-2's `:ckLimits`). No product
surface for weights exists today.
8. Discipline. Recommended: SFQ (stride is arithmetically the same thing
here; DRR would need a ring cursor in Redis and buys nothing per the
spike, where DRR and SFQ tracked each other within noise). Keep DRR as
the documented O(1) fallback if ZSET ops on `ckVtime` ever show up in
profiles, which the Task-7 op budget makes visible.
## Verification summary
```bash
pnpm run typecheck --filter @internal/run-engine
pnpm run typecheck --filter webapp
cd internal-packages/run-engine
pnpm run test ./src/run-queue/tests/keyProducer.test.ts --run
pnpm run test ./src/run-queue/tests/ckVtime.test.ts --run
pnpm run test ./src/run-queue/tests/ckVtimeFairness.test.ts --run
pnpm run test ./src/run-queue/tests/ckVtimeConcurrency.test.ts --run
pnpm run test ./src/run-queue/ --run # full regression, flag off default
```
+43
View File
@@ -0,0 +1,43 @@
# Run-queue multi-tenant fairness: spike references
Reference material for the implementation plan
`docs/superpowers/plans/2026-07-23-ck-virtual-time-scheduling-plan.md`. These are
the findings and the queueing-theory research produced by three throwaway spikes
on RunQueue tenant fairness (#2617). The spikes' harness, bench, and results code
was throwaway and is NOT on this branch; it is archived on the remote branch
`chore/fair-queueing-spike` (never merged, delete-before-anything). Any
`internal-packages/.../fairness-spike*` paths mentioned inside these documents
refer to that archived code.
## The documents
- `run-queue-fairness-research.md` — queueing-theory grounding: SFQ/WFQ and DRR
delay bounds, the Parekh-Gallager result that a worst-case per-flow delay bound
needs BOTH an admission regulator and a scheduler, why CoDel is an AQM and not a
fairness scheduler, and how production systems (Kubernetes APF, YARN, SQL Server
Resource Governor, SQS fair queues) layer caps under a fair order.
- `run-queue-fairness-base-queue-findings.md` — spike 1, base-queue grain: ranked
SFQ / stride / DRR / CoDel against the production age-order baseline. SFQ and
stride fix starvation and are seed-stable; CoDel is a no-op on a fair base and
harmful on an unfair one.
- `run-queue-fairness-ck-findings.md` — spike 2, the real concurrency-key seam:
drove the production `dequeueMessagesFromCkQueueTracked` Lua via `ckIndex`
rescoring. Per-key fairness lives below the selection-strategy interface, in the
CK dequeue scoring; virtual-time ordering fixes it there. Documents the
`maxCount = 1` fidelity limit that the implementation plan's tests must close.
- `run-queue-fairness-caps-vs-scheduling-findings.md` — spike 3, the
reconciliation with the plan of record (which ships concurrency caps): caps and
scheduling are orthogonal knobs. A per-key cap fixes wait when one key floods
but gives no relief once a tenant shards across many keys (the sybil split), and
it is not work-conserving; a total cap is a cross-task knob, not a cross-key
one; fair scheduling fixes every case and stays work-conserving. Ship the caps
first, add the fair order as the general fix, layer them.
## Why the plan follows from these
The recommended fix (score `ckIndex` by SFQ virtual time, inside the batched CK
dequeue, layered under the caps) is the one mechanism the spikes found that
survives key-sharding and stays work-conserving, and the research says the caps
the plan of record ships cannot bound wait on their own. The plan turns that into
a flag-gated, mixed-deploy-safe engine change with a test suite that exercises the
real batched path the spikes could not.
@@ -0,0 +1,168 @@
# Fair-queueing spike: findings
This is a throwaway spike. It ships nothing and should be deleted before any
merge to main; it exists to inform a design decision, not to become code.
Read the caveats section before quoting any single number. Two of them matter up
front: (1) the candidate selectors are handed tenant identity (parsed from the
queue name) and the exact per-tenant weights, and the fairness target is defined
by those same groups and weights, so the candidate win over the tenant-blind
baseline is closer to definitional than discovered. (2) The harness anchors
message scores far in the past, which flattens all queue ages, so the baseline's
production age bias is not exercised here; the baseline measured is closer to a
uniform-random shuffle than the real one.
Bottom line: at the base-queue grain, virtual-time ordering (SFQ) and stride give
tight, seed-stable proportional fairness, honour weights, and cut a starved
tenant's wait hard. DRR lands within noise of them (its small shortfall is a
measurement artifact of the harness, not an intrinsic property). The baseline is
fair on average but seed-variant and has no weight concept. The CoDel wrapper is
not worth shipping as built: it is a forced no-op under bulk arrival and it
actively hurt fairness on the one trickle-arrival workload that could exercise it.
The biggest single result is architectural: per-concurrency-key fairness (the
actual #2617 grain) cannot be expressed through the `RunQueueSelectionStrategy`
interface at all; it lives below that interface, in the CK-dequeue Lua.
Every number comes from the real `RunQueue` against a testcontainers Redis, one
selector per run, real enqueue/dequeue/ack and real concurrency gating. Each
scenario runs over 3 seeds; tables show the mean and min..max spread. Per-tenant
detail (first seed) is in `results/*.json`.
## Grain, and why it is not the concurrency key
A tenant is the fairness group; a tenant owns one or more base queues. The
adversarial scenario gives one tenant 30 queues and the light tenants one each,
which is how the #2617 starvation shows up at the base-queue grain: an ordering
blind to tenant identity lets the many-queue tenant win most of the selection
chances.
The concurrency-key grain #2617 asks for is not reachable through the strategy
interface. `FairQueueSelectionStrategy` reads the master-queue members verbatim,
and CK runs enqueue a single CK-wildcard entry per base queue. The per-CK pick
runs later inside `dequeueMessagesFromCkQueueTracked`, where `ckIndexKey` is a
ZSET of CK-queues scored by head timestamp and the Lua serves them oldest-first.
That age ordering is the unfairness. Fixing it means changing that Lua or the
`ckIndex` scoring, not the selection strategy. That is the follow-on spike.
## How fairness is measured (and its limits)
Because the sim drains every run, final throughput share is fixed by the workload
and cannot tell selectors apart. Two measures do:
- contention share: a tenant's share of dequeues at instants when at least two
tenants have arrived, unserved work, over its expected weighted share.
`contWorstS/W` is the least-served contender; 1.0 is fair, near 0 means starved
while others had work. Getting this right took two corrections a review caught:
it must only count a tenant once its runs have actually arrived (else poisson
arrival looks like starvation), and the virtual-time floor must be monotonic
(else a returning idle tenant monopolises and skews the window). Even so, when
tenants have very different volumes (trickleStale: 30 runs vs 300) the
low-volume tenant can legitimately be over- or under-represented in the window,
so read this metric together with wait, not alone.
- wait: dequeue time minus enqueue time, per tenant, in the JSON. This is the
clean anti-staleness signal. (Note: `worstWaitP99` in the JSON is NOT an
anti-staleness win signal; it is dominated by the highest-volume tenant, which
a fair selector deliberately delays, so a fairer selector scores worse on it.
Use per-tenant wait.)
## Results
`contWorstS/W` mean over 3 seeds (min..max). Higher is fairer.
| scenario | baseline | sfq | drr | stride | codel-sfq | codel-baseline |
| --------------- | ------------------- | ----- | ------------------- | ------ | --------- | -------------- |
| balanced | 0.889 (0.774..0.954)| 0.985 | 0.954 (0.923..0.970)| 0.985 | 0.985 | 0.889 |
| adversarialSkew | 0.288 (0.261..0.310)| 1.000 | 0.978 (0.968..0.984)| 1.000 | 1.000 | 0.288 |
| weighted | 0.703 (0.679..0.719)| 1.000 | 0.990 (0.977..1.000)| 1.000 | 1.000 | 0.703 |
| burst | 0.978 (0.966..0.992)| 0.992 | 0.958 (0.941..0.975)| 0.992 | 0.992 | 0.978 |
| longHold | 0.828 (0.800..0.842)| 0.981 | 0.981 | 0.981 | 0.981 | 0.828 |
| trickleStale | 0.208 (0.179..0.235)| 0.804 (0.769..0.826)| 0.776 (0.769..0.783)| 0.804 | 0.366 | 0.195 |
Per-tenant mean wait (seed-a, logical ms), the anti-staleness signal:
| scenario / selector | low-volume tenant wait | heavy tenant wait |
| ------------------------ | ---------------------- | ----------------- |
| adversarialSkew baseline | 1380 | 805 |
| adversarialSkew sfq | 319 | 1324 |
| trickleStale baseline | 1359 | 1234 |
| trickleStale sfq | 19 | 1353 |
| trickleStale codel-sfq | 213 | 1315 |
Reading these: the fair selectors cut the light tenant's wait (skew 1380 to 319,
trickle 1359 to 19) by making the heavy tenant wait its fair turn. The heavy
tenant is not punished, it stops jumping the queue. CoDel undoes part of the
trickle win (19 back up to 213).
## Verdict per mechanism
- SFQ (start-time virtual time, the start-tag form of WFQ): the strongest result.
Perfect contention fairness under skew and weighting, seed-stable (zero variance
across seeds), and the largest cut to the starved tenant's wait. The floor is
now monotonic (a review found the earlier version let a returning idle tenant
monopolise; fixed). Recommended as the leaf ordering.
- Stride: identical to SFQ to the decimal on every scenario. The spike does not
separate them. Stride carries slightly less state.
- DRR: within noise of SFQ. It trails by a couple of points on balanced (0.954)
and burst (0.958) and matches SFQ elsewhere. That small shortfall is a
measurement artifact, not an intrinsic property: the driver drains a whole
capacity batch from a single strategy snapshot and only advances DRR's deficit
after the batch (via `onServiced`), so DRR's current-winner group, whose queues
it fronts together, grabs several slots before its deficit updates and its
deficit runs negative. Served one-at-a-time DRR is exactly fair (see
`drr.test.ts`). Note the earlier claim that "virtual-time sorts an over-served
group's queues to the back and so avoids this" was wrong: at a tie all of a
group's queues share one clock, so SFQ fronts them together too; the schemes
only separate after their state advances. DRR is O(1) and composes weight
trivially, so it is a fine choice if per-op cost matters, subject to that
caveat.
- CoDel wrapper: do not ship as built. Under bulk arrival it is a forced no-op:
all of a queue's runs share one enqueue timestamp, so every tenant's sojourn is
identical and they all cross the target together, so hoisting everyone collapses
to the base order (this is why codel-sfq equals sfq and codel-baseline equals
baseline to the decimal on those scenarios; it is one workload shape confirming
a null result, not five independent tests). On trickleStale, the one scenario
where sojourns diverge, the sojourn-hoist overshoots: it drops SFQ from 0.804 to
0.366 and pushes the trickle tenant's wait from 19 back to 213. A staleness
monitor may still help on top of an unfair base or behind a hard concurrency
wall, but that needs a different construction and this spike does not support it.
- Baseline (`FairQueueSelectionStrategy`): fair on average on the easy scenarios
but seed-variant (balanced 0.774..0.954), no weight concept (weighted 0.703),
and it starves a light tenant under queue-count skew (0.288) and under trickle
arrival (0.208, trickle wait 1359). Remember its age bias is not exercised here
(see caveats), so this is a floor on its unfairness, not the production picture.
## Caveats
- Grain is base queues, not concurrency keys. The disciplines are grain-agnostic
so the ranking should carry over, but the #2617 gap itself needs the CK-Lua
spike. adversarialSkew is a proxy for that gap, not a measurement of it.
- Definitional advantage: candidates get tenant identity and exact weights the
real interface does not carry; the baseline structurally cannot.
- Baseline age bias inert: scores are anchored ~600s in the past, so all queue
ages are near-equal and the baseline degenerates to near-uniform selection. The
production age bias (which would give a heavy tenant's older heads more weight,
i.e. make skew worse) is not measured.
- Selection-only seam: the driver feeds serviced descriptors back via an
`onServiced` hook; production would advance selector state inside the ack/dequeue
Lua. The spike proves ordering logic, not that wiring.
- Cost was not rigorously measured. `selectionRounds` is roughly equal across
selectors (646..729) but is not comparable between them (a candidate reads all
queues per call; the baseline short-circuits at capacity), and there is no load
benchmark. DRR's "O(1)" advantage is a theory claim, not a spike measurement.
- Scenario quality varies. balanced best shows the baseline's variance;
adversarialSkew and weighted carry the clear separation; longHold and burst
barely separate the candidates; trickleStale's contention number only became
meaningful after two metric fixes and should be read with wait. Per-tenant p99
equals max for the small (20 to 30 run) tenants, so "p99" there is just the max.
- Single Redis shard; single sequential consumer (not the multi-consumer,
Redis-hash-state design the spec sketched); simulated holds on a logical clock.
Three seeds shows the baseline's variance and the virtual-time schemes'
stability but is not a statistical study.
## Recommended direction
Use virtual-time (SFQ, or stride) for leaf ordering and compose weight with it.
DRR is an acceptable O(1) fallback given the batch caveat. Do not adopt the CoDel
wrapper as built. Then run the follow-on spike against the CK-dequeue Lua /
`ckIndex` scoring, because that is where per-tenant fairness actually has to land
in the current design.
@@ -0,0 +1,198 @@
# Caps vs scheduling: reconciliation findings
Throwaway spike. Ships nothing; delete before any merge to main. Relative ranking
on a small simulation, not a statistical or load study: read the verdicts as "what
this harness supports", not proofs. Went through a blind two-model adversarial
review (a third stalled); the review's fixes are folded in below.
Bottom line: the plan-of-record's concurrency CAPS and the earlier spike's fair
SCHEDULING are different knobs, and the data on the real CK-dequeue Lua lines up
with the queueing theory (see `RESEARCH.md`). A per-key cap cuts a starved key's
wait when ONE key floods, because Trigger's CK dequeue is oldest-eligible-first;
it gives no wait improvement once a tenant shards its backlog across many
concurrency keys, and it is not work-conserving. Fair scheduling (SFQ/DRR) is the
only knob here that improves the starved key on every scenario including the
sharded one, and it stays work-conserving. A total (per-task) cap is a
cross-task knob; inside one task it only lowers the ceiling and is not a fairness
lever at all. The mechanisms are complementary, and production systems that need
fairness under saturation layer them (Kubernetes APF: seats + fair queueing).
## How the mechanisms were modelled (fidelity)
- Per-key cap (Phase 2): the REAL Lua gate. `updateQueueConcurrencyLimits` sets
the base queue's concurrencyLimit; the CK-dequeue Lua caps each ck variant's
in-flight at it and skips an at-limit variant (oldest-eligible-first, true age
order, no rescore). Uniform across variants: Phase 2's per-key HGET override
would cap only the heavy key, but a light key never approaches the cap so the
effect is equivalent here. (So "just lower the existing per-queue concurrency
limit" already IS a per-key cap; Phase 2 makes it per-key-specific.)
- Total cap (Phase 1): driver-side. The real Lua has no group gate yet, so the
driver refuses to admit while total in-flight across all variants of the base
queue (= `:groupConcurrency` SCARD in one base queue) is at the cap.
- Ordering disciplines (baseline age order, SFQ, DRR) are unchanged from the CK
scheduling spike, driven through the same real Lua at `maxCount = 1`.
- Two fidelity limits matter for reading the numbers, both driver-independent:
- `maxCount = 1` (same as the CK spike): production dequeues in batches, so a
real per-key/total gate lives inside the batched Lua.
- The `*3` scan window: the real CK Lua reads `ZRANGEBYSCORE ckIndexKey -inf now
LIMIT 0, actualMaxCount*3` (`index.ts:4041/4193`). At `maxCount = 1` that is
the 3 oldest-scored variants per call. So a per-key cap frees the light key
only when the light head lands inside that 3-wide window after the at-cap
variants ahead of it. With one heavy key it does; with many old-headed
attacker variants it never does. This window governs the skew-works /
sharded-fails split, and it scales with `maxCount` in production (batches),
not fixed at 3, so the sharded result's exact severity would differ on the
real batched path (direction not established).
## Results
env=4, per-key cap=2, total cap=2, 3 seeds. Columns: `lightWait` = the starved
key's mean wait (logical ms, the headline where it is not confounded);
`worstWait` = the largest per-group mean wait (i.e. the busiest key, which a fair
discipline deliberately makes wait its turn, so higher here is often correct);
`makespan` = logical time of the last dequeue (a work-conservation signal ONLY on
ckHeavyIdle, arrival-confounded elsewhere); `contWorstS/W` = worst contention
share over weight (directional; volume-confounded and, for per-key cap on sybil,
seed-noisy).
| scenario | treatment | lightWait | worstWait | makespan | contWorstS/W |
| ----------- | ------------------ | --------- | --------- | -------- | ------------ |
| ckSkew | baseline | 1098 | 1261 | 2083 | 0.187 |
| ckSkew | perKeyCap | 20 | 1555 | 3038 | 0.814 |
| ckSkew | totalCap | 2840 | 2974 | 3947 | 0.213 |
| ckSkew | total+perKey | 2840 | 2974 | 3947 | 0.213 |
| ckSkew | sfq | 14 | 1069 | 2083 | 0.723 |
| ckSkew | drr | 17 | 1067 | 2083 | 0.608 |
| ckSkew | perKeyCap+sfq | 7 | 1628 | 3114 | 0.800 |
| ckSkew | total+perKey+sfq | 52 | 2363 | 3939 | 0.803 |
| ckTrickle | baseline | 1107 | 1134 | 1940 | 0.279 |
| ckTrickle | perKeyCap | 19 | 1555 | 3038 | 0.922 |
| ckTrickle | totalCap | 2852 | 2861 | 3905 | 0.279 |
| ckTrickle | total+perKey | 2852 | 2861 | 3905 | 0.279 |
| ckTrickle | sfq | 17 | 1070 | 1942 | 0.909 |
| ckTrickle | drr | 23 | 1069 | 1941 | 0.790 |
| ckTrickle | perKeyCap+sfq | 8 | 1606 | 3097 | 0.658 |
| ckTrickle | total+perKey+sfq | 106 | 2337 | 3911 | 0.883 |
| ckSybil | baseline | 1765 | 1876 | 2070 | 0.000 |
| ckSybil | perKeyCap | 1776 | 1869 | 2128 | 0.403 |
| ckSybil | totalCap | 3793 | 3801 | 4147 | 0.000 |
| ckSybil | total+perKey | 3793 | 3801 | 4147 | 0.000 |
| ckSybil | sfq | 1009 | 1019 | 2065 | 1.000 |
| ckSybil | drr | 1061 | 1068 | 2055 | 0.994 |
| ckSybil | perKeyCap+sfq | 1010 | 1019 | 2072 | 1.000 |
| ckSybil | total+perKey+sfq | 2292 | 2292 | 4146 | 1.000 |
| ckHeavyIdle | baseline | 633 | 633 | 1240 | 1.000 |
| ckHeavyIdle | perKeyCap | 1291 | 1291 | 2507 | 1.000 |
| ckHeavyIdle | totalCap | 1291 | 1291 | 2507 | 1.000 |
| ckHeavyIdle | total+perKey | 1291 | 1291 | 2507 | 1.000 |
| ckHeavyIdle | sfq | 633 | 633 | 1240 | 1.000 |
| ckHeavyIdle | drr | 633 | 633 | 1240 | 1.000 |
| ckHeavyIdle | perKeyCap+sfq | 1291 | 1291 | 2507 | 1.000 |
| ckHeavyIdle | total+perKey+sfq | 1291 | 1291 | 2507 | 1.000 |
(ckHeavyIdle is a single key, so "lightWait" is the heavy key's own wait and the
contention metric is degenerate at 1.0; makespan is the signal there. ckSybil is
20 attacker keys plus one light key.)
## What each mechanism does, at the cross-key grain
- Per-key cap (Phase 2). SUPPORTED for the single-heavy case; NOT a wait fix once
the tenant shards; not work-conserving.
- Single heavy key (ckSkew/ckTrickle): cuts the light key's wait like a
scheduler (1098 to 20, 1107 to 19) because capping the one heavy key frees
slots and the CK Lua serves the light head as the next eligible one. This
depends on the light head being reachable inside the Lua's 3-wide scan window;
with one at-cap variant ahead of it, it is.
- Sharded / sybil (ckSybil, 20 attacker keys): NO wait improvement (baseline
1765, perKeyCap 1776; the difference is within the per-seed spread, baseline
1681..1926, perKeyCap 1672..1861). Contention share nudges up (0.000 to a
mean 0.403) but that mean hides a ~10x seed swing (0.07..0.71), so it is not a
dependable improvement. The reason is structural: the sum of per-key caps is
unbounded relative to the queue when keys are client-chosen, and the 3-wide
scan window is always full of older attacker heads, so the light head is never
reached. Concurrency keys are client-chosen, so this is cheap to trigger.
- Not work-conserving: throttles the capped key even with the env idle
(ckHeavyIdle makespan 1240 to 2507, 2x, the cleanest single result in the
spike; ckSkew 2083 to 3038, though that scenario's makespan is partly arrival-
confounded).
- Total cap (Phase 1) at the cross-key grain: NOT a fairness lever, and the
in-task comparison is capacity-confounded. `totalCap=2` caps the whole task's
aggregate at half of env=4, so it simply halves throughput: light's wait rises
(ckSkew 1098 to 2840) for the same reason heavy's does (both now share half the
server), which is Little's-Law throughput loss, not a fairness effect. It is the
wrong knob for cross-key starvation, measured on a lower ceiling; do not read
the "worse" numbers as "total caps harm fairness."
- Total cap (Phase 1) at the cross-TASK grain: this IS its job, and it works.
Measured in a separate multi-base-queue bench (`crossTaskCaps.bench.test.ts`):
two keyless tasks share one env, a heavy task floods it, and capping the heavy
task (its per-queue concurrency limit, the real native gate, which for a keyless
task equals its total cap) cuts the light TASK's wait from 475 to 2 under the
production `FairQueueSelectionStrategy`. So the total cap protects a light task
from a heavy task, the reservation-isolation role the research describes. It is
still not work-conserving (makespan 2039 to 3039), and SFQ at the task grain
protects the light task too (wait 14) while staying work-conserving (2039). The
fidelity note: this models a KEYLESS task, so the per-queue limit is the total;
a task WITH concurrency keys needs the group SET to sum across variants (the
unbuilt Phase-1 gate).
- Combined total + per-key (the shipped Phase-1+2 config): in this toy the total
cap (2) is below a single per-key cap's reach, so it dominates and the per-key
cap is non-binding (`total+perKey` equals `totalCap` to the digit). This toy
therefore does not exercise the combined config's real regime (total >> per-key,
cross-task). What it does show: adding a fair order on top (`total+perKey+sfq`)
restores fair share within the throttled aggregate (contWorstS/W 0.80..1.0) but
still pays the total cap's throughput loss (makespan ~3900+).
- Scheduling (SFQ/DRR): the only knob that improves the starved key on every
scenario, and work-conserving (makespan stays at the baseline optimum
2083/1240). On the sharded case SFQ takes the light key from fully starved to
its full fair share (contWorstS/W 0.000 to 1.000, seed-stable) and roughly
halves its wait (1765 to 1009); the residual wait is real saturation shared
fairly across 21 keys, not starvation. SFQ and DRR track each other within
noise, as in the CK spike.
- Layered per-key cap + SFQ: best light-key wait on the single-heavy case (7, 8)
and it carries the cap's occupancy bound, at the cap's makespan cost (matches or
slightly exceeds perKeyCap makespan: ckSkew 3038 to 3114). On the sharded case
the cap adds nothing and SFQ does all the work (1010, same as SFQ alone). This
is the Kubernetes-APF shape; APF avoids the work-conservation cost by making the
cap ELASTIC (borrow/lend seats), which a static cap cannot.
## Reconciliation with the earlier spike and the plan of record
Measured here: caps and scheduling fix different things and can be layered
(the per-key-cap+SFQ and total+perKey+sfq rows). Fair scheduling is the only
mechanism in this harness that improves the starved key on the sharded case and
stays work-conserving, which is what the earlier spike recommended (score
`ckIndex` by virtual time).
Interpretation, NOT measured by this benchmark (it measures wait/makespan/share on
a simulation, not engineering cost or rollout risk): shipping the caps first still
reads as defensible. A per-key cap is bounded, operator-controlled, self-healing
(a Redis SET), and it fully fixes the common single-heavy-key case, which is a
smaller engine change than reworking the dequeue scoring. Its limits are real
(no help once a tenant shards its keys, not work-conserving), which is the case
for treating automatic fair scheduling as a later phase rather than never.
The layered end state matches Kubernetes APF, SQL Server Resource Governor, YARN,
and the Parekh-Gallager result that a worst-case delay bound needs BOTH an
admission regulator AND a scheduler: keep the caps for isolation and entitlements,
add a fair dequeue order for the contended region when saturation and key-sharding
make caps alone insufficient. Not either/or.
## Caveats
- Relative ranking on a simulation; single shard, single base queue, single
sequential consumer; simulated holds on a logical clock; 3 seeds; equal weights.
The verdict words ("supported", "not a wait fix") are relative to this harness.
- `maxCount = 1` and the `*3` scan window (see fidelity section); the total cap is
driver-modelled, not the real (unbuilt) group gate.
- Per-key cap is modelled uniformly (real per-queue gate); a per-key-specific
Phase-2 override is equivalent here only because the light key never approaches
the cap.
- makespan is the last dequeue, not completion, and is arrival-confounded on the
poisson scenarios; trust it only on ckHeavyIdle.
- Contention share is volume-confounded for low-volume keys, and for the per-key
cap on the sharded case it is seed-noisy (0.07..0.71); wait is the trustworthy
signal, share is directional.
- Cross-task isolation (the total cap's real purpose) is now measured in
`crossTaskCaps.bench.test.ts` for KEYLESS tasks (per-queue limit = total cap).
A task with concurrency keys needs the unbuilt group-SET gate to sum across
variants; that batched, keyed path is still not exercised.
@@ -0,0 +1,122 @@
# Per-concurrency-key fairness spike: findings
Throwaway spike. Ships nothing; delete before any merge to main.
Bottom line: the base-queue spike's direction carries over to the real seam. At
the concurrency-key grain the production baseline (serve the oldest-head CK
first) starves keys that arrive behind a big backlog, and virtual-time (SFQ) and
stride fix it: they cut the starved key's wait from ~1300ms to ~20ms by making
the backlog key wait its turn. DRR does the same. A CoDel wrapper on the baseline
makes it worse. This was measured by driving the real
`dequeueMessagesFromCkQueueTracked` Lua and only rewriting `ckIndex` scores to
express each discipline. That is enough to say the ordering fix is worth a design
spike, but NOT that a production implementation is proven (see the fidelity
caveat: the spike serves one key per Lua call, and production dequeues in
batches).
## What was driven, and the two things to know before reading numbers
Runs enqueue across many concurrency keys under one base queue via the real
`RunQueue`. The per-CK pick is `ZRANGEBYSCORE ckIndexKey -inf now` in the CK Lua
(lowest score first, score = head timestamp). Candidates rewrite those scores
each round to encode discipline order; the baseline leaves them (production age
order). Enqueue, dequeue, concurrency gating and ack all run through the real
code.
Two caveats a review forced, both load-bearing:
1. Lead with wait, not contention share. The contention-share metric is
volume-confounded for low-volume keys (a key with 15 runs cannot take a third
of a long window even when served instantly), so on these scenarios it lands
around 0.7 to 0.9 for a discipline that has in fact eliminated the starvation.
The per-key wait is the clean signal.
2. The scenarios must give keys genuinely different head ages. An earlier version
enqueued every run at one timestamp; with tied `ckIndex` scores the real Lua
falls back to a lexicographic member-name tie-break, so the "baseline starves
the heavy key's rivals" result was actually "Redis sorts by name" and the
heavy key only won because "heavy" sorts before "light". Fixed: the backlog
key fires at once (persistently old head) and the other keys arrive via
poisson (distinct, later heads), so the baseline now exercises real age order.
## Results
`contWorstS/W` mean over 3 seeds (min..max), and the worst-served key's mean wait
(seed-a, logical ms). Read the wait column as the headline.
| scenario | discipline | contWorstS/W | worst-key wait | backlog-key wait |
| ---------- | ---------- | ------------------- | -------------- | ---------------- |
| ckSkew | baseline | 0.187 (0.186..0.188)| 1321 | 872 |
| ckSkew | sfq | 0.723 (0.655..0.769)| 16 | 1150 |
| ckSkew | drr | 0.608 (0.556..0.648)| 21 | 1149 |
| ckTrickle | baseline | 0.279 (0.254..0.291)| 1339 | 872 |
| ckTrickle | sfq | 0.909 (0.891..0.918)| 24 | 1237 |
| ckTrickle | drr | 0.790 (0.769..0.818)| 30 | 1236 |
Full matrix (contWorstS/W mean over 3 seeds):
| scenario | baseline | sfq | drr | stride | codel(sfq) | codel(baseline) |
| ---------- | ------------------- | ------------------- | ------------------- | ------ | ---------- | ------------------- |
| ckSkew | 0.187 | 0.723 | 0.608 | 0.723 | 0.723 | 0.104 (0.000..0.157)|
| ckBalanced | 0.515 (0.444..0.600)| 0.611 (0.462..0.800)| 0.730 (0.615..0.909)| 0.611 | 0.611 | 0.464 |
| ckTrickle | 0.279 | 0.909 | 0.790 | 0.909 | 0.909 | 0.018 (0.000..0.055)|
## Verdict per discipline (at the concurrency-key grain)
- SFQ / stride: fix the starvation. Contention share improves (skew 0.187 to
0.723, trickle 0.279 to 0.909) and the starved key's wait collapses (skew 1321
to 16, trickle 1339 to 24) because the backlog key now waits its turn (its wait
rises 872 to ~1150 to 1237). Identical to each other on every scenario.
Recommended discipline for the fix.
- DRR: fixes the wait just as well (skew 21, trickle 30) and its contention share
tracks SFQ within noise (sometimes a little lower, sometimes higher, e.g.
balanced 0.730 vs 0.611). Fine.
- CoDel(sfq): no harm, matches SFQ to the decimal. Adds nothing on top of a fair
base.
- CoDel(baseline): harmful. Hoisting stale keys on top of the age-order baseline
drove ckSkew to 0.104 (below baseline's 0.187) and ckTrickle to 0.018 (below
0.279). A staleness monitor is not a substitute for a fair base.
- Baseline (production age order): starves keys that queue behind a backlog
(ckSkew 0.187, worst key waits 1321ms; ckTrickle 0.279, 1339ms). It is roughly
fair when keys are symmetric (ckBalanced 0.515, though seed-variant 0.444 to
0.600). This is the #2617 dynamic at the seam where it lives.
## Fidelity caveat (the reason this is not "proven for production")
The driver dequeues one key per Lua call (`maxCount = 1`) and rescores `ckIndex`
before each call. Production dequeues in batches (`maxCount` default 10). Inside a
batched CK-dequeue call the Lua re-scores each served key back to its head
timestamp as it goes, so a once-per-round rescore would only steer the FIRST pick
of a batch; the rest would follow head-timestamp order again. So this spike
demonstrates the ordering fix only in a one-key-per-call regime, which is not how
production dequeues. A real fix has to advance per-key discipline state inside the
Lua on every serve (and hold that state in Redis, not process memory). This spike
does not exercise that batch path, so the correct claim is "the ordering fix is
worth a design spike", not "a production fix is viable".
## Other caveats
- Contention share is volume-confounded (see above); the wait column is the
trustworthy signal, and the contention numbers should be read as directional.
- The DRR contention-share gap is NOT the base-queue spike's batch-drain artifact
(that harness batched; this one serves one key per call and advances DRR's
deficit every serve). The cause of DRR's slightly lower share here is not
established; its wait result is as good as SFQ's.
- Per-CK concurrency gating never binds in these runs (no per-CK limit is set, so
it collapses to the env limit), so the spike says nothing about the per-CK
concurrency-limit-multiplication half of #2617, which is out of scope.
- A rescore discipline advances its floor/ring state on the final no-op drain
round of an instant (order() is called before the empty dequeue). It is
self-correcting and does not corrupt the event-based metrics, but it is a minor
infidelity to a production per-serve advance.
- Equal weights only; single shard, single base queue, single sequential
consumer; simulated holds on a logical clock; 3 seeds.
## Recommended direction
Score `ckIndex` by a fair discipline (SFQ/stride virtual time, or DRR) instead of
by head timestamp. Both spikes agree on the discipline and this one shows the
ordering fix works through the real dequeue path at `maxCount = 1`. The design
spike past this needs to: advance per-key virtual-time state inside the batched
CK-dequeue Lua (the `maxCount > 1` path this spike did not exercise), hold that
state in Redis for the multi-consumer case, and address the per-CK
concurrency-limit multiplication that is the other half of #2617.
@@ -0,0 +1,133 @@
# Queue-fairness research (grounding for the caps-vs-scheduling reconciliation)
Throwaway spike notes. Five Fable research passes, distilled. Citations kept so
the findings write-up and report can point at real sources. This grounds the
central claim: occupancy caps and fair scheduling are orthogonal knobs, and the
plan-of-record ships the cap knob while the earlier spike measured the scheduler
knob.
## The orthogonality result (theory)
Caps bound occupancy, not wait. A tenant capped at C in-flight with mean service
time S has long-run throughput <= C/S (Little's Law, L = lambda*W). That is an
upper bound on the capped tenant's share; it reserves no lower bound for anyone
else and says nothing about any tenant's waiting time (Little relates averages in
a stable system, not tails, and if the capped tenant's arrival rate exceeds C/S
its queue never stabilises so the law does not even apply to it).
Scheduling bounds wait, not occupancy. WFQ/PGPS tracks GPS within one max job
(Parekh-Gallager finish-time bound L_max/r); SFQ gives a starved flow's head item
a hard wait bound of "one max-size job from every other active tenant" (Goyal-Vin
SFQ Theorem 2), with no server-rate assumption. But all of family A is
work-conserving: a lone backlogged tenant takes 100% of the server. Nothing in a
scheduler limits how many slots a tenant holds.
Parekh-Gallager is the canonical joint statement: a worst-case per-flow delay
bound is the product of arrival regulation (leaky/token bucket = the admission
knob) AND a scheduling discipline (GPS/WFQ = the order knob). Neither alone yields
the bound. Cruz network-calculus caveat: plain FIFO does get a delay bound IF every
input is burstiness-constrained (arrival regulator on ingress) and aggregate rho <
C, but a concurrency cap is not an ingress regulator (it bounds in-flight, not
queue admission, and queue depth stays unbounded), so under adversarial arrival
FIFO wait is unbounded and the orthogonality holds without qualification.
Sources: Little 1961 (Oper. Res. 9:383-387); Parekh & Gallager 1993/1994 (GPS,
IEEE/ACM ToN); Goyal, Vin & Cheng, Start-time Fair Queueing (SIGCOMM'96 / ToN'97);
Shreedhar & Varghese, DRR (SIGCOMM'95); Waldspurger & Weihl, Stride Scheduling
(MIT TM-528, 1995); Cruz, A Calculus for Network Delay (IEEE T-IT 1991); Kingman's
formula; Harchol-Balter, Performance Modeling and Design of Computer Systems (CUP
2013).
## When a cap alone DOES cut a starved tenant's wait (the load-bearing condition)
A per-tenant concurrency cap on heavy tenant H cuts light tenant L's wait to
near-zero iff BOTH:
1. Slot availability: sum of caps of all backlogged tenants other than L is < N
(the binding aggregate limit), so freed slots exist that capped tenants can
never occupy; and
2. Eligibility-aware serve order: the dequeue selects the oldest ELIGIBLE item,
skipping items whose tenant is at cap, so L's head is reachable without
draining H's older items first.
If (2) fails (single global age-ordered list with a head-blocking consumer), the
cap does NOT help L and with strict head-blocking makes L's wait WORSE: H's
backlog drains at k slots instead of N while the freed N-k slots sit idle
(head-of-line blocking; Parekh-Gallager's FCFS-gives-no-isolation remark).
Trigger's CK dequeue is oldest-ELIGIBLE-first: the CK Lua gates each variant at
its per-key concurrencyLimit and skips a variant that is at its limit, moving to
the next-oldest eligible. So condition (2) holds structurally. That is WHY per-key
caps can work for wait here, and would not work on a head-blocking FIFO.
## Where caps fail even with eligibility-aware order (the sybil split)
Concurrency keys are client-chosen. A heavy tenant spreads its backlog across many
CK variants, each under its own per-key cap, all "eligible". The binding
constraint becomes the base-queue total cap (or env limit); oldest-first then
serves the adversary's older backlog across its many keys before a newcomer's
head. Per-key caps bound nothing in aggregate, because sum of per-key caps is
unbounded relative to the queue cap when keys are dynamic. The light tenant's wait
scales with the adversary's total queued backlog, which no per-key cap regulates.
Within a base queue, the fix under adversarial arrival is an order change
(round-robin / fair queueing across keys), not another cap.
## Known static-cap failure modes (practice)
2DFQ (Mace et al., SIGCOMM 2016): "Rate limiters, typically implemented as token
buckets, are not designed to provide fairness at short time intervals ... they can
either underutilize the system or concurrent bursts can overload it without
providing any further fairness guarantees." Their desirable-properties section
requires the scheduler be work-conserving, which "precludes the use of ad-hoc
throttling mechanisms to control misbehaving tenants." Pisces (OSDI 2012) and DRF
(NSDI 2011) both exist because static slot partitioning under/over-utilises under
skewed demand. Netflix concurrency-limits: static limits (Limit = RPS * latency,
i.e. Little's Law) "quickly go out of date"; hence adaptive.
The price of caps when they DO give order-independent wait bounds: they degenerate
into a static partition (sum of caps <= K), which is non-work-conserving
(utilisation ceiling of sum-of-caps even when one tenant could use all K) and
needs bounded, pre-known tenant cardinality.
## Production precedent: caps and scheduling are LAYERED, not either/or
- Kubernetes API Priority & Fairness (the closest analog): total server
concurrency split into per-priority-level "seats" (a cap), THEN shuffle-sharded
fair queueing decides dispatch order within a level. Kubernetes hit exactly the
cap-alone failure with max-inflight before APF, and the fix ADDED fair queueing
on top of the existing cap rather than replacing it. (K8s docs; KEP-1040.)
- SQL Server Resource Governor: pool MIN/MAX/CAP percent (caps + reservation) +
workload-group IMPORTANCE biasing the scheduler's order. Same shape.
- YARN Fair/Capacity scheduler: minShare floor + maxResources cap + weighted
fair-share ordering, with preemption to reclaim the floor.
- Mesos/DRF, Borg: quota/admission caps layered with fair-share or priority order.
- Amazon SQS fair queues: fairness metric is DWELL TIME, fixed by reprioritising
delivery ORDER when a tenant's in-flight share is disproportionate, and
explicitly does NOT rate-limit per tenant. Order is the dwell-time knob;
occupancy limits alone were not it.
- Envoy / gRPC / Postgres connection caps: caps ALONE, and they claim overload
PROTECTION, not fairness. AWS token-bucket quotas claim "fairness" only in the
weaker selective-throttling sense, and rely on an elastic, rarely-saturated
fleet.
Condition for caps-alone to suffice in practice: sum of caps comfortably below (or
elastically kept below) real capacity, and shed/rejected work acceptable, i.e. the
system never holds a contended backlog it must drain in some order. The moment you
hold a queue of admitted-but-waiting work at saturation, the serve order IS the
fairness policy.
## CoDel (confirms the prior spike's "CoDel disproven" verdict)
CoDel is an AQM: it bounds standing-queue delay by DROPPING packets when the
minimum sojourn over a window stays above target (5ms/100ms defaults; RFC 8289).
It is not a scheduler and gives no inter-flow fairness (RFC 7567: queue management
and scheduling are complementary, not substitutes). FQ-CoDel gets all its fairness
from a DRR scheduler; CoDel is only the per-flow AQM inside each sub-queue (RFC
8290). In a durable run queue that can never drop work, CoDel's actuator is gone:
reordering conserves total queue and total work, so hoisting one item's sojourn
down pushes others' up. Hoisting the stalest item to the front of an already
age-ordered queue re-applies the age bias the base already has, so it is a no-op
at best and a dominant-tenant amplifier at worst (a tenant that dumps a big
backlog owns the entire stale set). Facebook's server-side CoDel adaptation ("Fail
at Scale", ACM Queue 2015) keeps the drop (stale requests expire) and reorders
adaptive-LIFO (newest first), the opposite of stalest-first hoisting.