skip to content

You operate a shared service where many tenants contend for one bounded pool of workers. How do you decide how much fairness to enforce between them, and what would you measure to know the choice was right?

level: principalimportance: nice to knowfreq 28%

answer

  1. Write the guarantee first, then pick the mechanism
  2. Isolation (caps, separate pools) before ordering (FIFO)
  3. Weighted fair queueing on measured cost, not request count
  4. Admission control removes the saturation regime
  5. Measure per-tenant max wait + achieved share, never averages

basics

~20 s

Start from the guarantee you owe each tenant, not from the mechanism. Enforce the weakest policy that bounds the worst case: per-tenant concurrency caps or weighted fair queueing plus admission control, unfair-fast inside a tenant. Measure per-tenant p99/max wait and age-of-oldest, not aggregate throughput.

solid answer

~1 min

Decide from the **guarantee**, then pick the mechanism. 1. **State the contract**: what does the least-favoured tenant get during a saturation event? A minimum share, a bounded wait, or best-effort? That single sentence determines everything. 2. **Prefer isolation to ordering**. Per-tenant concurrency limits or separate queues bound the blast radius without paying handoff costs on every operation; strict global fairness is the expensive last resort. 3. **Layer the policy**: weighted fair queueing or a virtual-time scheduler across tenants (so a heavy tenant cannot starve a light one), unfair-fast inside a tenant where throughput matters and all work is equivalent. 4. **Add admission control**. Starvation requires sustained saturation; a queue cap plus load shedding removes the regime in which fairness even matters, and turns an unbounded wait into a fast, retryable rejection. 5. **Watch for cost asymmetry**: equal request counts is not equal service if requests differ in cost. Charge by measured work (virtual time / stride scheduling), or a cheap-request tenant subsidises an expensive one. **Measure**: per-tenant p99 and max wait, age-of-oldest queued item, share of capacity actually received versus configured weight, rejection rate per tenant, and aggregate throughput as the cost side of the ledger. Averages will hide every problem you care about.

code

text · 7 lines
text
admit(req):
  if queue_age(tenant) > client_timeout: reject(RETRY_AFTER)
  if inflight[tenant] >= cap[tenant]:     reject(BUSY)
  enqueue(req, vtime = now + cost(req) / weight[tenant])   # weighted fair

dequeue():  pick item with smallest vtime      # fair BETWEEN tenants
worker():   take from tenant bucket unordered  # fast WITHIN a tenant

go deeper

for a junior

Recognise that one tenant can consume the whole pool and that a simple per-tenant limit prevents it; you are not expected to design the scheduling policy.

for a middle

Contrast per-tenant caps, separate pools and round-robin service, and note that fairness costs throughput because of handoff and lost batching.

for a senior

Build the layered design — isolation plus weighted service on measured cost, admission control, deadline-aware shedding — and name the per-tenant tail metrics that prove it works.

for a principal

Lead with the contract and the exchange rate: what the least-favoured tenant is guaranteed, what that costs in aggregate throughput, how it is verified with adversarial load tests, and when the decision gets revisited.

## Start from the promise, not the primitive Fairness is not a virtue to maximise; it is a bound you buy at a price. The first move is to write down what each class of work is owed when the system is saturated. Typical contracts: - *Best-effort*: no guarantee, work may wait arbitrarily. Cheapest, and perfectly fine for internal batch jobs. - *Minimum share*: tenant T receives at least X% of capacity whenever it has demand. This is the usual multi-tenant promise. - *Bounded wait*: no request waits more than D. Strongest and most expensive, because it constrains the tail directly. Only after that sentence exists can you evaluate mechanisms, because each mechanism buys exactly one of those bounds. ## Isolation beats ordering The cheapest way to prevent one tenant starving another is not to order the shared queue more carefully — it is to stop them sharing the contended resource without limit. Concretely: - **Per-tenant concurrency caps**: tenant T may hold at most k workers. A runaway tenant now consumes k, not the pool. Cost: a small counter per tenant; no handoff cost per operation. - **Separate queues or pools per class**: latency-critical and batch work never share a queue. Cost: capacity fragmentation, since an idle pool cannot help a busy one — mitigate with borrowing plus a hard reservation. - **Weighted fair queueing / virtual time**: one shared pool, but each tenant has a weight and a virtual clock; the scheduler always serves the tenant whose virtual finish time is earliest. Every tenant with demand progresses, in proportion to its weight. Cost: bookkeeping per dequeue, and a real need for accurate cost accounting. Strict global FIFO — the most obviously "fair" option — is usually the wrong pick, because it protects nothing except arrival order, and arrival order is exactly what a spamming tenant controls. A tenant submitting a million items still gets a million turns. ## Cost asymmetry is where fairness schemes break Counting requests is fair only when requests cost the same. In practice one tenant's request may be a hundred times heavier. Two disciplines fix this: charge by **measured service time** (update the virtual clock by actual work done, as stride and virtual-time schedulers do), or **normalise units** (cost-weighted tokens, e.g. estimated rows scanned) and reconcile against measured cost afterwards. Without this, a nominally fair policy systematically transfers capacity to the expensive tenant, and your dashboards will show equal request shares while latency for everyone else degrades. ## Admission control removes the regime Starvation and unbounded wait exist only under sustained saturation. Bounded queues, per-tenant rate limits and load shedding remove the regime rather than manage it. A rejected request with a retry-after hint is nearly always better than a request that waits four minutes: it returns control to the caller, it is measurable, and it prevents queue growth from converting a throughput problem into a latency outage. Pair this with **queue-time-based shedding** — drop work whose queue age already exceeds the client's timeout, since completing it produces no value while consuming capacity. This single rule prevents the classic collapse where a saturated pool spends all its capacity on requests nobody is waiting for anymore. ## Inside a tenant, be unfair Once cross-tenant isolation is in place, the intra-tenant policy should optimise throughput: unfair/barging locks, LIFO for cache warmth where latency permits, batching. All work in that bucket is equivalent to the customer, so ordering within it buys nothing and costs handoff latency. This layering — fair between classes, fast within a class — is the general shape of a good answer. ## What to measure Aggregate throughput and mean latency are the two metrics that will hide every fairness failure, so instrument: 1. **Per-tenant p99 and maximum wait time.** Maximum is the one that diverges under starvation. 2. **Age of the oldest queued item, per tenant.** The most diagnostic single number for a queue; it grows without bound under starvation while queue length may look stable. 3. **Achieved share versus configured weight.** If tenant A is configured for 20% and receives 3% under load, the policy is not doing what the config claims — usually a cost-accounting bug. 4. **Rejection/shed rate per tenant**, so you can see whether isolation is protecting or simply denying. 5. **Aggregate throughput and utilisation**, as the cost side: every point of fairness you buy shows up here, and you must be able to state the exchange rate. 6. **Behaviour under a deliberate noisy-neighbour test.** Run a load test where one tenant misbehaves — a hot loop of expensive queries — and verify the others' p99 stays within contract. A fairness policy that has never been tested under an adversarial tenant is a hypothesis, not a control. ## Reviewing the decision Re-evaluate when the tenant mix changes (a new tenant an order of magnitude larger), when request cost distributions shift, or when a fairness incident occurs. Record the contract and the exchange rate in writing: "tenants are guaranteed 10% of the pool each; enforcing this costs about 8% aggregate throughput". That sentence is what makes the tradeoff reviewable later instead of folkloric.

  • Why is a single global FIFO queue a poor fairness mechanism for multi-tenant work?
    FIFO protects arrival order, and arrival order is under the tenants' control. A tenant that enqueues a million items simply owns a million consecutive turns, so a light tenant still waits behind them. FIFO also says nothing about cost per item. Per-tenant queues with weighted service, plus concurrency caps, bound the share regardless of how much anyone submits.
  • How does queue-time-based shedding help, and what does it cost?
    If an item has already waited longer than the client's timeout, completing it produces zero value while consuming a worker, so dropping it recovers capacity immediately and prevents the collapse where a saturated pool serves only abandoned work. The cost is that you must propagate deadlines and be sure the work is safely droppable or idempotent; otherwise you shed work that had side effects the caller expected.
  • You configure a tenant for 20% of capacity but it measures 3% under load. What do you check first?
    Cost accounting. Weighted schemes that charge per request rather than per unit of work systematically under-serve tenants with expensive requests, and over-serve cheap-request tenants. Verify the virtual clock advances by measured service time, then check whether the tenant is blocked behind a non-shared bottleneck such as a connection pool or a single downstream shard, which no pool-level fairness policy can fix.

An emergency department: triage categories (fair between classes) decide who is seen next, but within a category staff take whoever is closest and quickest to treat (fast within a class). Nobody triages by arrival order alone.

saying these in an interview costs you the question

  • Choosing a mechanism ("use a fair lock", "use round-robin") before stating what each tenant is actually promised.
  • Treating request counts as service units when tenants' requests differ wildly in cost.
  • Assuming strict global FIFO delivers fairness, when it simply rewards whoever enqueues the most.
  • Evaluating the policy on aggregate throughput and mean latency, which hide starvation entirely.
  • Enforcing fairness without admission control, so unbounded queues convert saturation into unbounded wait instead of fast rejection.
  • Never testing with an adversarial noisy neighbour, leaving the isolation claim unverified.

context