skip to content

In a multi-tenant job platform, one tenant enqueues two million exports at once; how would you keep that backlog from starving every other tenant?

level: principalimportance: should knowfreq 45%

answer

  1. unit of scheduling is the tenant
  2. rotate over active tenants only
  3. credit spent by job cost
  4. caps that lend idle capacity
  5. wait time per tenant, not global

basics

~20 s

Stop dispatching from one global FIFO. Queue work per tenant, choose the next tenant by weighted fair share of worker time, cap each tenant's concurrent jobs, and let those caps flex when others are idle so capacity is not wasted.

solid answer

~50 s

With one global FIFO, the big tenant's two million jobs sit ahead of everyone: at 30 seconds each on 100 workers that is about a week of blocking. The fix is to make the **tenant** the unit of scheduling. Keep a queue per tenant and have the dispatcher rotate over tenants that have work, using weighted round-robin or deficit round-robin, so each gets a turn regardless of backlog size. Add a **per-tenant concurrency cap** so no tenant holds more than, say, 20% of workers while others are waiting, but make it **work-conserving**: when nobody else has work, the big tenant may use idle capacity. Measure fairness in **worker-seconds**, not job count, because a two-hour render and a two-second export are not equal turns. Back this with admission quotas at enqueue and separate pools for very heavy job types, and track queue wait per tenant, since a healthy global average can hide one starved tenant.

code

pseudocode · 12 lines
pseudocode
loop:
    tenant = activeTenants.next()
    tenant.deficit = min(maxDeficit, tenant.deficit + quantum * tenant.weight)
    while freeWorkers() > 0 and tenant.hasQueued() and tenant.running < tenant.cap:
        job = tenant.peek()
        if job.estimatedCost > tenant.deficit: break
        tenant.deficit -= job.estimatedCost
        dispatch(tenant.pop())
        tenant.running += 1
    if not tenant.hasQueued():
        tenant.deficit = 0
        activeTenants.remove(tenant)

go deeper

for a junior

Remember that one shared FIFO lets a single large backlog block every other tenant, and that per-tenant queues fix the ordering.

for a middle

Explain round-robin and deficit round-robin over active tenants, and why dispatch cost depends on tenant count rather than backlog size.

for a senior

Show how concurrency caps, worker-second accounting, separate pools and per-tenant wait metrics work together, and how retries count toward a tenant's share.

for a principal

Own the policy: hard versus work-conserving caps, weights tied to plans, hierarchical sharing, and how fairness targets become commitments you report per tenant.

## Why a global FIFO starves tenants A **multi-tenant** job platform serves many customers from one shared worker pool. With a single first-in, first-out queue, whoever enqueues first gets served first, and a single large backlog becomes everyone's problem. With illustrative numbers: one tenant enqueues 2,000,000 export jobs of about 30 seconds each, and the pool has 100 workers. That is 2,000,000 x 30 = 60,000,000 worker-seconds, divided by 100 workers = 600,000 seconds, about 6.9 days. Every other tenant's job submitted after that burst waits roughly a week. This is the **noisy neighbour** problem, and it is a scheduling-policy failure, not a capacity failure. ## Per-tenant queues and fair dispatch The fix is to schedule **tenants first, jobs second**: 1. Keep one logical queue per tenant, holding that tenant's jobs in its own order. 2. Keep a rotation of **active tenants**, those with queued work. 3. The dispatcher picks the next tenant from the rotation, takes some of its work, and moves on. Plain round-robin gives each tenant one job per turn. **Weighted** variants give paying or critical tenants more. **Deficit round-robin** also handles uneven job sizes: each tenant earns credit per turn and spends it by the estimated cost of the job it dispatches. ```pseudocode loop: tenant = activeTenants.next() tenant.deficit = min(maxDeficit, tenant.deficit + quantum * tenant.weight) while freeWorkers() > 0 and tenant.hasQueued() and tenant.running < tenant.cap: job = tenant.peek() if job.estimatedCost > tenant.deficit: break tenant.deficit -= job.estimatedCost dispatch(tenant.pop()) tenant.running += 1 if not tenant.hasQueued(): tenant.deficit = 0 activeTenants.remove(tenant) ``` Dispatch cost grows with the number of **active tenants**, not the number of queued jobs, so a two-million-job backlog does not slow the dispatcher. The deficit ceiling stops a capped tenant from building up unlimited credit and then bursting. ## Concurrency caps: hard versus work-conserving Fair ordering alone is not enough: if the big tenant's jobs are long, it can still end up holding most workers once they are running. A **per-tenant concurrency cap** limits how many of its jobs run at once. | Cap style | Behaviour | Trade-off | |---|---|---| | Hard cap | never more than N running | strong isolation, but workers sit idle while the capped tenant still has work | | Work-conserving cap | the cap applies only while others are waiting | uses idle capacity, but a newly arriving tenant must wait for borrowed workers to finish | | Cap plus reserved share | each tier keeps a guaranteed floor, the rest is shared | predictable for premium tenants, more configuration to own | The borrowing delay in the work-conserving row is why long-running job types need **preemption points** or shorter job units: a tenant can only reclaim its share as fast as borrowed jobs finish. ## Fair by what? - **Job count** is simple but unfair when sizes vary: one two-hour render and one two-second export count as equal turns. - **Worker-seconds consumed**, scaled by weight, reflects the real share of the pool; it needs estimated or measured job cost. - **Hierarchical fairness** shares first between organisations and then between their projects, so a customer cannot gain share by splitting into many sub-accounts. - **Within one tenant**, the tenant's own priorities decide order, so fairness between tenants does not dictate which of a tenant's jobs runs first. ## Isolation beyond the dispatcher - **Admission quotas** at enqueue time limit how fast or how much one tenant can submit, turning a burst into back-pressure at the API instead of a hidden week-long queue. - **Separate worker pools** for job types with very different profiles, such as renders versus exports, stop long jobs from occupying the slots short jobs need. - **Shared downstream limits** matter too: fair dispatch is useless if every export hammers the same database, so per-tenant limits may be needed at those dependencies as well. - **Retries count toward the tenant's share**, otherwise a tenant with a failing integration can take extra capacity by retrying. ## Measuring fairness - Track **queue wait time per tenant**, especially the high percentiles. A good global average can hide one tenant waiting hours. - Track **running jobs and worker-seconds per tenant** against their weight or cap. - Alert when any tenant's oldest queued job exceeds its target, not only when the whole queue is deep. - Review weights and caps with product owners: they encode a business decision about who gets served first under contention.

  • Why is round-robin by job count unfair when job sizes vary widely?
    Each turn hands out one job, but one tenant's job may take two hours while another's takes two seconds. The tenant with long jobs ends up holding far more worker time per turn. Charging each tenant by estimated or measured worker-seconds, as deficit round-robin does, makes each turn cost what the job actually uses.
  • How do per-tenant caps interact with autoscaling the worker pool?
    Caps expressed as fixed counts become wrong as the pool grows or shrinks, so express them as a share of current capacity. Autoscaling on total queue depth can also be triggered by one tenant's backlog; scaling on queue wait of tenants below their share avoids buying capacity just to drain one customer's bulk job faster.
  • What stops a tenant from gaining capacity by splitting into many sub-accounts?
    Share capacity hierarchically: first between billing organisations, then between the projects or accounts inside each. Splitting into more sub-accounts then only divides the organisation's own share further instead of adding new rotation slots.

A busy deli serving one customer with a hundred-item order and ten customers with one sandwich each takes the big order in batches between the small ones, rather than making everyone wait until the hundredth sandwich is done.

saying these in an interview costs you the question

  • A single FIFO queue is fair because everyone is served in arrival order.
  • Adding more workers is the real fix for one tenant's backlog.
  • Counting jobs dispatched per tenant is an accurate fairness measure.
  • Hard caps cost nothing because idle workers do no harm.
  • A healthy global average queue wait proves no tenant is starving.