~learn.
?

Day 0242 minPreface+60 XP

Noisy Neighbor Prevention.

System Design Mastery Series — Multi-Tenancy, Security, and Compliance Week

4 stages18 code

watch it first · newThe whole lesson in a 19-minute animated lecture5 quiz checkpoints · ask the AI about any moment · captions, chapters and speed

Preface · 1 min

Preface#

Yesterday, we learned how to isolate tenant data. But there's another isolation problem:

The noisy neighbor disaster

Monday morning, 9:00 AM — dashboard alerts fire:

  • API latency: 200ms → 15,000ms
  • Database CPU: 30% → 98%
  • Error rate: 0.1% → 45%
  • Support tickets: Flooding in
You investigate — top queries by CPU
  1. Query:
    sql4 lines
    SELECT * FROM events
    WHERE tenant_id = 'acme_corp'
    AND created_at > '2020-01-01'
    ORDER BY created_at
    • Duration: 847 seconds (still running)
    • Rows scanned: 2.3 billion
    • Tenant: acme_corp

Source: Someone at Acme Corp ran an "export all data" report

Impact
  • 500 other tenants can't use your product
  • You're losing $50,000/hour in churned customers
  • Your SLA is violated
  • One tenant's legitimate use broke everyone

This is the "noisy neighbor" problem.

Today, we'll learn to prevent one tenant from ruining everyone else's experience through quotas, rate limiting, and fair scheduling.

ExplainerThe whole lesson: noisy neighbour prevention

One tenant's export (847 seconds, 2.3 billion rows) pushes latency from 200 ms to 15,000 ms and takes 95% of the shared capacity, starving everyone else. Defence comes in layers: a per-tenant token bucket at the gateway (429 with Retry-After), concurrency limits and a query guard in the application, per-tenant connection pools and statement timeouts in the database, and CPU, memory and I/O quotas in the infrastructure. Quotas are tiered by plan (60, 1,000 or 10,000 calls a minute), background jobs are scheduled fairly per tenant, and the classic mistakes are a global limiter, silent throttling and one limit for every operation.

Part I · Stage 1 of 4 · 4 min

Part I: Foundations#

Chapter 1: Understanding the Noisy Neighbor Problem#

1.1 What Is a Noisy Neighbor?#

A noisy neighbor is a tenant whose resource consumption negatively impacts other tenants sharing the same infrastructure.

Noisy neighbor visualization — shared resources (100% capacity)

Normal state

  • Tenant A — 15%
  • Tenant B — 25%
  • Tenant C — 20%

Available capacity: 40% — System healthy

Noisy neighbor state

  • Tenant A — 95% ← NOISY NEIGHBOR
  • Tenant B — 3% ← STARVED
  • Tenant C — 2% ← STARVED

Available capacity: 0% — System degraded

1.2 Types of Noisy Neighbor Scenarios#

Noisy neighbor categories
# Category Causes Example
1 CPU-BOUND Complex queries or computations · Infinite loops or bugs · ML model training on shared infra Analytics query scanning billions of rows
2 MEMORY-BOUND Large in-memory operations · Memory leaks · Massive cache fills Loading entire dataset into memory
3 I/O-BOUND Bulk data imports/exports · Large file uploads · Sequential table scans Migrating 10TB of data
4 NETWORK-BOUND High request volume · Large payload transfers · Webhook storms API polling every millisecond
5 STORAGE-BOUND Excessive data accumulation · Log explosion · Never-deleted temp files Storing every event forever
6 CONNECTION-BOUND Connection pool exhaustion · Long-held connections · Connection leaks Opening 1000 database connections

1.3 Why This Is Hard#

The challenge

Unlike data isolation (clear boundaries), resource isolation is fuzzy.

Questions without easy answers
  • How much CPU is "fair" for a $50/month customer vs $5000/month?
  • Should we throttle a customer doing legitimate work?
  • How do we explain limits without frustrating users?
  • What if a customer's spike is temporary?
  • How do we balance fairness with resource efficiency?
The business tension
  • Too strict: Customers feel limited, churn increases
  • Too loose: Noisy neighbors hurt everyone, churn increases
  • Sweet spot: Hard to find and varies by customer
Technical challenges
  • Real-time enforcement at scale
  • Fair allocation across heterogeneous workloads
  • Graceful degradation when limits are hit
  • Visibility into who's using what
  • Predictable behavior for capacity planning

Chapter 2: Resource Isolation Strategies#

Your turn · checkpoint 1not answered

Would a rate limit have saved Monday morning?

Acme's "export all data" report was a single request. Suppose every tenant already had a per-tenant limit of 1,000 API calls a minute. Would that have stopped the incident? If not, what kind of limit would?

Think it through, or jot it down. Then compare.

One strong answer

  • No. One request is far inside 1,000 a minute. A rate limit counts requests; it can't see that this one scans 2.3 billion rows.
  • What stops it is a limit on what a single request may cost: a statement timeout, a check of the query's estimated size before it runs, and a cap on the tenant's connections.
  • That's why the defenses come in layers: the gateway stops volume, and the application and database stop expensive operations the gateway can't see.

2.1 The Resource Isolation Spectrum#

Isolation approaches
Level Approach How it works Protection / problem
1 NO ISOLATION (Don't do this) Shared resources, first-come-first-served Problem: One tenant can starve all others
2 SOFT LIMITS (Monitoring only) Track usage, alert on abuse, manual intervention Problem: By the time you react, damage is done
3 RATE LIMITING Limit requests per second/minute Protects against: Request volume · Doesn't protect: Expensive operations
4 RESOURCE QUOTAS Hard limits on CPU, memory, connections, storage Protects against: Most noisy neighbors · Challenge: Setting appropriate limits
5 FAIR SCHEDULING Dynamic resource allocation based on demand Protects against: All scenarios · Challenge: Complex to implement
6 PHYSICAL ISOLATION Dedicated resources per tenant Protects against: Everything · Challenge: Cost prohibitive for most

2.2 Multi-Layer Defense#

Defense in depth for noisy neighbors
  1. Layer 1: API GATEWAY — Request rate limiting · Payload size limits · Connection limits
    • Blocks: Volume-based attacks
  2. Layer 2: APPLICATION — Query complexity analysis · Operation quotas · Concurrent operation limits
    • Blocks: Expensive operations
  3. Layer 3: DATABASE — Statement timeouts · Connection limits per tenant · Row limit on queries
    • Blocks: Database abuse
  4. Layer 4: INFRASTRUCTURE — CPU/memory quotas (k8s) · I/O throttling · Network bandwidth limits
    • Blocks: Resource exhaustion

Each layer catches what previous layers missed.

Chapter 3: Quota Design Principles#

3.1 Types of Quotas#

Quota categories
# Quota type Measures Example
1 RATE QUOTAS (per time window) Requests per second · API calls per minute · Events per hour 1000 API calls/minute
2 CONCURRENCY QUOTAS (simultaneous) Active connections · Running queries · Parallel jobs Max 10 concurrent requests
3 VOLUME QUOTAS (total amount) Storage used · Data transferred · Records created 100GB storage limit
4 COMPUTE QUOTAS (processing) CPU seconds · Query execution time · Batch processing time 1000 CPU-seconds/hour
5 FEATURE QUOTAS (capability) Number of users · Number of projects · Number of integrations Max 50 users

3.2 Quota Tiers by Plan#

Example quota structure — quota tiers
Resource Free Pro Enterprise
API calls/minute 60 1,000 10,000
Storage (GB) 1 100 Unlimited*
Users 5 50 Unlimited
Concurrent requests 5 50 200
Query timeout (sec) 30 120 300
Export rows 1,000 100,000 1,000,000
Webhooks 5 50 500
File upload (MB) 10 100 500
Data retention (days) 30 365 Custom

* "Unlimited" = high limit with fair use policy

These are example tiers for a generic SaaS API. Each product sets its own plans and limits: the Week 9 capstone, a document platform, uses different tiers (Standard, Professional, Enterprise) sized for documents and search.

Quick check3 questions · +5 XP each

Noisy neighbor foundations

  1. Q1/3 You already have per-tenant rate limiting. A tenant sends only 5 requests a minute, yet each one starts a query that scans billions of rows. What actually stops the damage?

    Answer & explanation

    Answer: B

    Rate limiting protects against request volume but "doesn't protect: expensive operations". One cheap-looking request can still melt the database. Quotas on compute, connections and query time cap what each request may cost. Soft limits only alert, and by the time you react the damage is done.

  2. Q2/3 A tenant stays well inside its 1,000 calls/minute limit, but every call is a slow report, so dozens run at the same time and hold connections. Which quota type catches this?

    Answer & explanation

    Answer: D

    The problem is how many things run at once, not how many arrive per minute. A concurrency quota (e.g. max 50 concurrent requests on Pro) caps that directly. A rate quota is already being met here, which is exactly why it misses the problem.

  3. Q3/3 Why does the lesson put limits at the gateway, application, database and infrastructure layers instead of only at the API gateway?

    Answer & explanation

    Answer: A

    Each layer blocks a different failure: the gateway stops volume, the app stops expensive operations, the database stops runaway statements and connection hogging, and infrastructure stops resource exhaustion. They are not copies of one rate check; "each layer catches what previous layers missed".

Part II · Stage 2 of 4 · 28 min

Part II: Implementation#

Chapter 4: Rate Limiting Implementation#

4.1 Token Bucket Algorithm#

pythonRateLimitConfig · 253 lines
# noisy_neighbor/rate_limiter.py

"""
Rate limiting implementation using token bucket algorithm.

Token bucket provides:
- Enforces an average rate over time
- Allows short bursts up to the bucket size
- Simple to implement and reason about
"""

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Optional, Tuple
import asyncio
import time


@dataclass
class RateLimitConfig:
    """Configuration for rate limiting."""
    requests_per_second: float
    burst_size: int  # Maximum tokens (allows short bursts)
    
    @property
    def refill_rate(self) -> float:
        """Tokens added per second."""
        return self.requests_per_second


@dataclass
class RateLimitResult:
    """Result of a rate limit check."""
    allowed: bool
    tokens_remaining: float
    retry_after_seconds: Optional[float] = None
    limit: int = 0
    reset_at: Optional[datetime] = None


class TokenBucket:
    """
    Token bucket rate limiter.
    
    Tokens are added at a constant rate (refill_rate).
    Each request consumes one token.
    Requests are rejected when bucket is empty.
    """
    
    def __init__(self, config: RateLimitConfig):
        self.config = config
        self.tokens = float(config.burst_size)
        self.last_refill = time.monotonic()
        self._lock = asyncio.Lock()
    
    async def try_acquire(self, tokens: int = 1) -> RateLimitResult:
        """
        Try to acquire tokens from the bucket.
        
        Returns RateLimitResult indicating if request is allowed.
        """
        async with self._lock:
            self._refill()
            
            if self.tokens >= tokens:
                self.tokens -= tokens
                return RateLimitResult(
                    allowed=True,
                    tokens_remaining=self.tokens,
                    limit=self.config.burst_size
                )
            else:
                # Calculate when tokens will be available
                tokens_needed = tokens - self.tokens
                wait_seconds = tokens_needed / self.config.refill_rate
                
                return RateLimitResult(
                    allowed=False,
                    tokens_remaining=self.tokens,
                    retry_after_seconds=wait_seconds,
                    limit=self.config.burst_size,
                    reset_at=datetime.now(timezone.utc) + timedelta(seconds=wait_seconds)
                )
    
    def _refill(self):
        """Refill tokens based on elapsed time."""
        now = time.monotonic()
        elapsed = now - self.last_refill
        
        # Add tokens based on elapsed time
        tokens_to_add = elapsed * self.config.refill_rate
        self.tokens = min(
            self.config.burst_size,
            self.tokens + tokens_to_add
        )
        
        self.last_refill = now


class DistributedRateLimiter:
    """
    Distributed rate limiter using Redis.
    
    Uses Redis for coordination across multiple app instances.
    Implements sliding window log algorithm for accuracy:
    at most `limit` requests in any `window_seconds` window.
    """
    
    def __init__(self, redis_client, limit: int, window_seconds: float):
        self.redis = redis_client
        self.limit = limit
        self.window_seconds = window_seconds
    
    async def try_acquire(
        self,
        key: str,
        tokens: int = 1
    ) -> RateLimitResult:
        """
        Try to acquire tokens for a given key.
        
        Key format: "ratelimit:{tenant_id}:{resource}"
        """
        now = time.time()
        window_start = now - self.window_seconds
        
        # Lua script for atomic operation
        script = """
        local key = KEYS[1]
        local now = tonumber(ARGV[1])
        local window_start = tonumber(ARGV[2])
        local limit = tonumber(ARGV[3])
        local tokens = tonumber(ARGV[4])
        local window = tonumber(ARGV[5])
        
        -- Remove old entries outside window
        redis.call('ZREMRANGEBYSCORE', key, '-inf', window_start)
        
        -- Count current entries in window
        local current = redis.call('ZCARD', key)
        
        if current + tokens <= limit then
            -- Add new entries
            for i = 1, tokens do
                redis.call('ZADD', key, now, now .. ':' .. i .. ':' .. math.random())
            end
            redis.call('EXPIRE', key, math.ceil(window) + 1)  -- Outlive the window
            return {1, limit - current - tokens}  -- allowed, remaining
        else
            -- Calculate retry after
            local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
            local retry_after = 0
            if #oldest > 0 then
                retry_after = oldest[2] + window - now
            end
            return {0, retry_after}  -- denied, retry_after
        end
        """
        
        # redis-py signature: eval(script, numkeys, *keys_and_args)
        result = await self.redis.eval(
            script,
            1,  # numkeys: KEYS[1] is the only key
            key,
            now, window_start, self.limit, tokens, self.window_seconds
        )
        
        allowed = result[0] == 1
        
        if allowed:
            return RateLimitResult(
                allowed=True,
                tokens_remaining=result[1],
                limit=self.limit
            )
        else:
            return RateLimitResult(
                allowed=False,
                tokens_remaining=0,
                retry_after_seconds=max(0, result[1]),
                limit=self.limit
            )


class TenantRateLimiter:
    """
    Rate limiter with per-tenant configuration.
    
    Different tenants can have different limits based on their plan.
    """
    
    def __init__(self, redis_client, quota_service):
        self.redis = redis_client
        self.quota_service = quota_service
        self._limiters: dict = {}
    
    async def check_rate_limit(
        self,
        tenant_id: str,
        resource: str = "api",
        tokens: int = 1
    ) -> RateLimitResult:
        """
        Check rate limit for a tenant and resource.
        """
        # Quotas are named "<resource>_rate" (e.g. "api_rate", "export_rate");
        # fall back to the general API quota if the resource has none
        quota = (
            await self.quota_service.get_quota(tenant_id, f"{resource}_rate")
            or await self.quota_service.get_quota(tenant_id, "api_rate")
        )
        
        if not quota:
            # No quota configured = unlimited (careful!)
            return RateLimitResult(allowed=True, tokens_remaining=float('inf'))
        
        # Same key the QuotaService reads for rate usage
        limiter_key = f"rate:{tenant_id}:{quota.name}"
        
        # e.g. api_rate: limit=60, window_seconds=60 -> 60 requests/minute
        limiter = DistributedRateLimiter(
            self.redis,
            limit=int(quota.limit),
            window_seconds=quota.window_seconds or 1
        )
        
        result = await limiter.try_acquire(limiter_key, tokens)
        
        # Track usage for analytics
        await self._record_usage(tenant_id, resource, tokens, result.allowed)
        
        return result
    
    async def _record_usage(
        self,
        tenant_id: str,
        resource: str,
        tokens: int,
        allowed: bool
    ):
        """Record usage for monitoring and billing."""
        await self.redis.hincrby(
            f"usage:{tenant_id}:{resource}",
            "total_requests",
            tokens
        )
        
        if not allowed:
            await self.redis.hincrby(
                f"usage:{tenant_id}:{resource}",
                "throttled_requests",
                tokens
            )

4.2 Rate Limiting Middleware#

pythonRateLimitMiddleware · 97 lines
# noisy_neighbor/middleware.py

"""
Middleware for enforcing rate limits on incoming requests.
"""

from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
import logging
import math

logger = logging.getLogger(__name__)


class RateLimitMiddleware(BaseHTTPMiddleware):
    """
    Middleware that enforces rate limits per tenant.
    """
    
    def __init__(self, app, rate_limiter: TenantRateLimiter):
        super().__init__(app)
        self.rate_limiter = rate_limiter
    
    async def dispatch(self, request: Request, call_next):
        # Skip rate limiting for health checks
        if request.url.path in ["/health", "/metrics"]:
            return await call_next(request)
        
        # Get tenant from context (set by auth middleware)
        tenant_id = getattr(request.state, "tenant_id", None)
        
        if not tenant_id:
            # No tenant = no rate limit (or reject, depending on policy)
            return await call_next(request)
        
        # Determine resource type based on endpoint
        resource = self._get_resource_type(request)
        
        # Check rate limit
        result = await self.rate_limiter.check_rate_limit(
            tenant_id=tenant_id,
            resource=resource
        )
        
        if not result.allowed:
            logger.warning(
                "Rate limit exceeded",
                extra={
                    "tenant_id": tenant_id,
                    "resource": resource,
                    "path": request.url.path,
                    "retry_after": result.retry_after_seconds
                }
            )
            
            return JSONResponse(
                status_code=429,
                content={
                    "error": "rate_limit_exceeded",
                    "message": "Too many requests. Please slow down.",
                    "retry_after_seconds": result.retry_after_seconds,
                    "limit": result.limit
                },
                headers={
                    "Retry-After": str(max(1, math.ceil(result.retry_after_seconds or 1))),
                    "X-RateLimit-Limit": str(result.limit),
                    "X-RateLimit-Remaining": str(int(result.tokens_remaining)),
                }
            )
        
        # Process request
        response = await call_next(request)
        
        # Add rate limit headers (skip when unlimited: int(inf) would raise)
        if result.limit:
            response.headers["X-RateLimit-Limit"] = str(result.limit)
            response.headers["X-RateLimit-Remaining"] = str(int(result.tokens_remaining))
        
        return response
    
    def _get_resource_type(self, request: Request) -> str:
        """
        Determine resource type for rate limiting.
        
        Different endpoints might have different limits.
        """
        path = request.url.path
        
        if path.startswith("/api/search"):
            return "search"  # Search might have lower limits
        elif path.startswith("/api/export"):
            return "export"  # Export might have much lower limits
        elif path.startswith("/api/bulk"):
            return "bulk"    # Bulk operations limited
        else:
            return "api"     # Default API limit
Quick check3 questions · +5 XP each

Rate limiting

  1. Q1/3 A token bucket refills at 1 token/second with burst_size = 10. The tenant has been idle for a full minute, then fires 15 requests at once. What happens?

    Answer & explanation

    Answer: C

    Refill is capped at burst_size, so the bucket holds at most 10 tokens no matter how long the tenant was idle. 10 requests take a token each; the other 5 find it empty and get a retry-after. This cap is what allows short bursts while still enforcing the long-run rate.

  2. Q2/3 Why does the distributed limiter run its check in a Redis Lua script instead of each app server keeping its own in-memory TokenBucket?

    Answer & explanation

    Answer: A

    Requests for one tenant spread across many instances. Local buckets each allow the full limit, so the real limit grows with server count. One shared counter in Redis, updated atomically by the script, prevents both that and the race where two servers read the same count and both admit a request.

  3. Q3/3 check_rate_limit returns "allowed" with unlimited tokens when it finds no quota, not even the api_rate fallback. What is the risk the "(careful!)" comment warns about?

    Answer & explanation

    Answer: D

    A new endpoint without its own quota still falls back to api_rate, but if that is missing or misnamed too, this "fail open" default silently removes all protection. That is how a noisy neighbor slips through: nothing errors, the limiter simply never says no.

Chapter 5: Resource Quota Implementation#

5.1 Quota Service#

pythonQuotaType · 388 lines
# noisy_neighbor/quota_service.py

"""
Service for managing and enforcing tenant resource quotas.
"""

from dataclasses import dataclass, field
from typing import Dict, Optional, List, Tuple
from datetime import datetime, timedelta
from enum import Enum
import logging
import time

logger = logging.getLogger(__name__)


class QuotaType(Enum):
    RATE = "rate"           # Per time window
    CONCURRENT = "concurrent"  # Simultaneous
    VOLUME = "volume"       # Total amount
    COMPUTE = "compute"     # Processing time


@dataclass
class QuotaDefinition:
    """Definition of a quota."""
    name: str
    quota_type: QuotaType
    limit: float
    window_seconds: Optional[int] = None  # For rate quotas
    burst_size: Optional[int] = None      # For rate quotas
    
    # Soft limit for warnings (percentage of limit)
    warning_threshold: float = 0.8
    
    # Action when exceeded
    action: str = "reject"  # reject, throttle, warn


@dataclass
class QuotaUsage:
    """Current usage against a quota."""
    quota_name: str
    current: float
    limit: float
    percentage: float
    is_exceeded: bool
    is_warning: bool
    reset_at: Optional[datetime] = None


@dataclass
class TenantQuotas:
    """All quotas for a tenant."""
    tenant_id: str
    plan: str
    quotas: Dict[str, QuotaDefinition] = field(default_factory=dict)


# Default quotas by plan
DEFAULT_QUOTAS = {
    "free": {
        "api_rate": QuotaDefinition(
            name="api_rate",
            quota_type=QuotaType.RATE,
            limit=60,
            window_seconds=60,
            burst_size=10
        ),
        "storage_gb": QuotaDefinition(
            name="storage_gb",
            quota_type=QuotaType.VOLUME,
            limit=1
        ),
        "concurrent_requests": QuotaDefinition(
            name="concurrent_requests",
            quota_type=QuotaType.CONCURRENT,
            limit=5
        ),
        "query_timeout_seconds": QuotaDefinition(
            name="query_timeout_seconds",
            quota_type=QuotaType.COMPUTE,
            limit=30
        ),
        "users": QuotaDefinition(
            name="users",
            quota_type=QuotaType.VOLUME,
            limit=5
        ),
    },
    "pro": {
        "api_rate": QuotaDefinition(
            name="api_rate",
            quota_type=QuotaType.RATE,
            limit=1000,
            window_seconds=60,
            burst_size=100
        ),
        "storage_gb": QuotaDefinition(
            name="storage_gb",
            quota_type=QuotaType.VOLUME,
            limit=100
        ),
        "concurrent_requests": QuotaDefinition(
            name="concurrent_requests",
            quota_type=QuotaType.CONCURRENT,
            limit=50
        ),
        "query_timeout_seconds": QuotaDefinition(
            name="query_timeout_seconds",
            quota_type=QuotaType.COMPUTE,
            limit=120
        ),
        "users": QuotaDefinition(
            name="users",
            quota_type=QuotaType.VOLUME,
            limit=50
        ),
    },
    "enterprise": {
        "api_rate": QuotaDefinition(
            name="api_rate",
            quota_type=QuotaType.RATE,
            limit=10000,
            window_seconds=60,
            burst_size=1000
        ),
        "storage_gb": QuotaDefinition(
            name="storage_gb",
            quota_type=QuotaType.VOLUME,
            limit=10000  # 10TB, essentially unlimited
        ),
        "concurrent_requests": QuotaDefinition(
            name="concurrent_requests",
            quota_type=QuotaType.CONCURRENT,
            limit=200
        ),
        "query_timeout_seconds": QuotaDefinition(
            name="query_timeout_seconds",
            quota_type=QuotaType.COMPUTE,
            limit=300
        ),
        "users": QuotaDefinition(
            name="users",
            quota_type=QuotaType.VOLUME,
            limit=100000  # Essentially unlimited
        ),
    }
}


class QuotaService:
    """
    Service for managing tenant quotas.
    """
    
    def __init__(self, db, cache, metrics_client):
        self.db = db
        self.cache = cache
        self.metrics = metrics_client
    
    async def get_tenant_quotas(self, tenant_id: str) -> TenantQuotas:
        """
        Get all quotas for a tenant.
        """
        # Check cache
        cache_key = f"quotas:{tenant_id}"
        cached = await self.cache.get(cache_key)
        
        if cached:
            return TenantQuotas(**cached)
        
        # Load tenant plan
        tenant = await self.db.fetchrow(
            "SELECT plan, custom_quotas FROM tenants WHERE id = $1",
            tenant_id
        )
        
        if not tenant:
            raise ValueError(f"Tenant not found: {tenant_id}")
        
        # Start with default quotas for plan
        plan = tenant["plan"]
        quotas = DEFAULT_QUOTAS.get(plan, DEFAULT_QUOTAS["free"]).copy()
        
        # Apply any custom overrides
        if tenant["custom_quotas"]:
            for name, override in tenant["custom_quotas"].items():
                if name in quotas:
                    quotas[name] = QuotaDefinition(**{**quotas[name].__dict__, **override})
        
        tenant_quotas = TenantQuotas(
            tenant_id=tenant_id,
            plan=plan,
            quotas=quotas
        )
        
        # Cache for 5 minutes
        await self.cache.set(cache_key, tenant_quotas.__dict__, ttl=300)
        
        return tenant_quotas
    
    async def get_quota(
        self,
        tenant_id: str,
        quota_name: str
    ) -> Optional[QuotaDefinition]:
        """Get a specific quota for a tenant."""
        tenant_quotas = await self.get_tenant_quotas(tenant_id)
        return tenant_quotas.quotas.get(quota_name)
    
    async def check_quota(
        self,
        tenant_id: str,
        quota_name: str,
        requested_amount: float = 1
    ) -> QuotaUsage:
        """
        Check if a quota allows the requested amount.
        """
        quota = await self.get_quota(tenant_id, quota_name)
        
        if not quota:
            # No quota defined = allowed
            return QuotaUsage(
                quota_name=quota_name,
                current=0,
                limit=float('inf'),
                percentage=0,
                is_exceeded=False,
                is_warning=False
            )
        
        # Get current usage based on quota type
        if quota.quota_type == QuotaType.RATE:
            current = await self._get_rate_usage(tenant_id, quota_name, quota)
        elif quota.quota_type == QuotaType.CONCURRENT:
            current = await self._get_concurrent_usage(tenant_id, quota_name)
        elif quota.quota_type == QuotaType.VOLUME:
            current = await self._get_volume_usage(tenant_id, quota_name)
        elif quota.quota_type == QuotaType.COMPUTE:
            current = await self._get_compute_usage(tenant_id, quota_name, quota)
        else:
            current = 0
        
        percentage = (current + requested_amount) / quota.limit
        is_exceeded = (current + requested_amount) > quota.limit
        is_warning = percentage >= quota.warning_threshold
        
        usage = QuotaUsage(
            quota_name=quota_name,
            current=current,
            limit=quota.limit,
            percentage=percentage,
            is_exceeded=is_exceeded,
            is_warning=is_warning
        )
        
        # Record metrics
        await self._record_quota_check(tenant_id, usage)
        
        return usage
    
    async def _get_rate_usage(
        self,
        tenant_id: str,
        quota_name: str,
        quota: QuotaDefinition
    ) -> float:
        """Get rate usage from sliding window (same key as the rate limiter)."""
        key = f"rate:{tenant_id}:{quota_name}"
        window_start = time.time() - quota.window_seconds
        
        # Count requests in window
        count = await self.cache.zcount(
            key,
            window_start,
            '+inf'
        )
        
        return count
    
    async def _get_concurrent_usage(
        self,
        tenant_id: str,
        quota_name: str
    ) -> float:
        """Get current concurrent operations (ConcurrencyLimiter's sorted set)."""
        key = f"concurrent:{tenant_id}"
        # Members are scored by expiry time; count the unexpired ones
        return await self.cache.zcount(key, time.time(), '+inf')
    
    async def _get_volume_usage(
        self,
        tenant_id: str,
        quota_name: str
    ) -> float:
        """Get total volume usage from database."""
        # Different queries based on quota type
        if quota_name == "storage_gb":
            result = await self.db.fetchrow(
                """
                SELECT COALESCE(SUM(size_bytes), 0) / 1073741824.0 as usage_gb
                FROM files WHERE tenant_id = $1
                """,
                tenant_id
            )
            return result["usage_gb"]
        
        elif quota_name == "users":
            result = await self.db.fetchrow(
                "SELECT COUNT(*) as count FROM users WHERE tenant_id = $1",
                tenant_id
            )
            return result["count"]
        
        return 0
    
    async def _get_compute_usage(
        self,
        tenant_id: str,
        quota_name: str,
        quota: QuotaDefinition
    ) -> float:
        """Get compute usage in current window."""
        # Compute usage tracked per query, not cumulative
        # For query timeout, we return the timeout limit itself
        return 0  # Single query can use up to the limit
    
    async def _record_quota_check(self, tenant_id: str, usage: QuotaUsage):
        """Record quota check for monitoring."""
        self.metrics.gauge(
            "quota_usage_percentage",
            usage.percentage * 100,
            tags={
                "tenant_id": tenant_id,
                "quota_name": usage.quota_name
            }
        )
        
        if usage.is_warning:
            logger.warning(
                "Quota warning threshold reached",
                extra={
                    "tenant_id": tenant_id,
                    "quota_name": usage.quota_name,
                    "percentage": usage.percentage
                }
            )


class QuotaEnforcer:
    """
    Enforces quotas by rejecting or throttling operations.
    """
    
    def __init__(self, quota_service: QuotaService):
        self.quota_service = quota_service
    
    async def enforce(
        self,
        tenant_id: str,
        quota_name: str,
        requested_amount: float = 1
    ) -> Tuple[bool, Optional[str]]:
        """
        Enforce a quota.
        
        Returns (allowed, error_message).
        """
        usage = await self.quota_service.check_quota(
            tenant_id,
            quota_name,
            requested_amount
        )
        
        if usage.is_exceeded:
            quota = await self.quota_service.get_quota(tenant_id, quota_name)
            
            error_message = (
                f"Quota exceeded for {quota_name}. "
                f"Current: {usage.current:.1f}, Limit: {usage.limit:.1f}. "
                f"Please upgrade your plan or wait for quota reset."
            )
            
            return False, error_message
        
        return True, None

5.2 Concurrent Request Limiting#

pythonConcurrencyLimiter · 138 lines
# noisy_neighbor/concurrency_limiter.py

"""
Concurrency limiting to prevent too many simultaneous operations.
"""

from typing import Optional
import asyncio


class ConcurrencyLimiter:
    """
    Limits concurrent operations per tenant.
    
    Prevents a tenant from overwhelming the system with
    many parallel requests.
    """
    
    def __init__(self, redis_client, default_limit: int = 10):
        self.redis = redis_client
        self.default_limit = default_limit
    
    async def try_acquire(
        self,
        tenant_id: str,
        operation_id: str,
        limit: Optional[int] = None,
        ttl_seconds: int = 300
    ) -> bool:
        """
        Try to acquire a concurrency slot.
        
        Args:
            tenant_id: Tenant identifier
            operation_id: Unique ID for this operation
            limit: Max concurrent operations (None = use default)
            ttl_seconds: Auto-release after this time (prevents leaks)
        
        Returns:
            True if slot acquired, False if limit reached
        """
        limit = limit or self.default_limit
        key = f"concurrent:{tenant_id}"
        
        # Lua script for atomic check-and-set
        script = """
        local key = KEYS[1]
        local operation_id = ARGV[1]
        local limit = tonumber(ARGV[2])
        local ttl = tonumber(ARGV[3])
        local now = tonumber(ARGV[4])
        
        -- Remove expired entries
        redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
        
        -- Check current count
        local current = redis.call('ZCARD', key)
        
        if current < limit then
            -- Add this operation with expiry timestamp
            redis.call('ZADD', key, now + ttl, operation_id)
            return 1
        else
            return 0
        end
        """
        
        import time
        now = time.time()
        
        # redis-py signature: eval(script, numkeys, *keys_and_args)
        result = await self.redis.eval(
            script,
            1,  # numkeys: KEYS[1] is the only key
            key,
            operation_id, limit, ttl_seconds, now
        )
        
        return result == 1
    
    async def release(self, tenant_id: str, operation_id: str):
        """
        Release a concurrency slot.
        
        Call this when operation completes.
        """
        key = f"concurrent:{tenant_id}"
        await self.redis.zrem(key, operation_id)
    
    async def get_current_count(self, tenant_id: str) -> int:
        """Get current concurrent operation count."""
        key = f"concurrent:{tenant_id}"
        
        import time
        now = time.time()
        
        # Clean up expired and count
        await self.redis.zremrangebyscore(key, '-inf', now)
        return await self.redis.zcard(key)


class ConcurrencyContext:
    """
    Context manager for concurrent operations.
    
    Usage:
        async with ConcurrencyContext(limiter, tenant_id, op_id) as acquired:
            if acquired:
                # Do operation
            else:
                # Handle limit exceeded
    """
    
    def __init__(
        self,
        limiter: ConcurrencyLimiter,
        tenant_id: str,
        operation_id: str,
        limit: Optional[int] = None
    ):
        self.limiter = limiter
        self.tenant_id = tenant_id
        self.operation_id = operation_id
        self.limit = limit
        self.acquired = False
    
    async def __aenter__(self) -> bool:
        self.acquired = await self.limiter.try_acquire(
            self.tenant_id,
            self.operation_id,
            self.limit
        )
        return self.acquired
    
    async def __aexit__(self, exc_type, exc_val, exc_tb):
        if self.acquired:
            await self.limiter.release(self.tenant_id, self.operation_id)
        return False
Your turn · checkpoint 2not answered

Estimate: what would a timeout have saved?

Monday's query scanned 2.3 billion rows in 847 seconds and the outage cost $50,000 an hour. Assume both rates were steady. How many rows a second is that, and what would the incident have cost if the query had been killed by Pro's 120-second timeout instead?

Think it through, or jot it down. Then compare.

One strong answer

  • Scan rate: 2.3 billion ÷ 847 s ≈ 2.7 million rows a second.
  • Cost so far: $50,000 ÷ 3,600 ≈ $14 a second × 847 s ≈ $11,800, and the query was still running.
  • With a 120 s timeout: ~325 million rows and ~$1,700. Better, but everyone still suffers for two minutes, which is why the lesson also checks the plan's estimate before the query runs.

Chapter 6: Query Complexity and Timeout Management#

6.1 Query Analyzer#

pythonQueryComplexity · 187 lines
# noisy_neighbor/query_analyzer.py

"""
Analyze query complexity to prevent expensive operations.
"""

from dataclasses import dataclass
from typing import List, Optional
from enum import Enum
import json
import re


class QueryComplexity(Enum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"
    VERY_HIGH = "very_high"
    BLOCKED = "blocked"


@dataclass
class QueryAnalysis:
    """Result of query analysis."""
    complexity: QueryComplexity
    estimated_rows: int
    estimated_time_ms: int
    warnings: List[str]
    blocked_reason: Optional[str] = None


class QueryComplexityAnalyzer:
    """
    Analyzes SQL queries for complexity.
    
    Used to prevent queries that would consume too many resources.
    """
    
    # Patterns that indicate expensive queries
    EXPENSIVE_PATTERNS = [
        (r'SELECT\s+\*', 'SELECT * can be expensive, specify columns'),
        (r'(?i)CROSS\s+JOIN', 'CROSS JOIN can explode row count'),
        (r'(?i)(?<!NOT\s)LIKE\s+[\'"]%', 'Leading wildcard LIKE prevents index use'),
        (r'(?i)ORDER\s+BY.*RANDOM', 'ORDER BY RANDOM is expensive'),
        (r'(?i)GROUP\s+BY.*HAVING', 'Complex GROUP BY with HAVING'),
    ]
    
    # Patterns that should be blocked entirely
    BLOCKED_PATTERNS = [
        (r'(?i)SELECT.*FROM.*,.*,.*,.*,', 'Too many table joins'),
        (r'(?is)^\s*(DELETE|UPDATE)\b(?!.*\bWHERE\b)', 'DELETE/UPDATE without WHERE'),
    ]
    
    def __init__(self, db):
        self.db = db
    
    async def analyze(
        self,
        query: str,
        tenant_id: str
    ) -> QueryAnalysis:
        """
        Analyze a query for complexity.
        """
        warnings = []
        
        # Check for blocked patterns
        for pattern, reason in self.BLOCKED_PATTERNS:
            if re.search(pattern, query):
                return QueryAnalysis(
                    complexity=QueryComplexity.BLOCKED,
                    estimated_rows=0,
                    estimated_time_ms=0,
                    warnings=[],
                    blocked_reason=reason
                )
        
        # Check for expensive patterns
        for pattern, warning in self.EXPENSIVE_PATTERNS:
            if re.search(pattern, query):
                warnings.append(warning)
        
        # Use EXPLAIN to estimate cost
        explain_result = await self._explain_query(query, tenant_id)
        
        estimated_rows = explain_result.get("rows", 0)
        estimated_cost = explain_result.get("cost", 0)
        
        # Determine complexity based on estimates
        if estimated_rows > 10_000_000 or estimated_cost > 100000:
            complexity = QueryComplexity.VERY_HIGH
        elif estimated_rows > 1_000_000 or estimated_cost > 10000:
            complexity = QueryComplexity.HIGH
        elif estimated_rows > 100_000 or estimated_cost > 1000:
            complexity = QueryComplexity.MEDIUM
        else:
            complexity = QueryComplexity.LOW
        
        # Estimate time (very rough)
        estimated_time_ms = int(estimated_cost * 0.1)  # Rough estimate
        
        return QueryAnalysis(
            complexity=complexity,
            estimated_rows=estimated_rows,
            estimated_time_ms=estimated_time_ms,
            warnings=warnings
        )
    
    async def _explain_query(self, query: str, tenant_id: str) -> dict:
        """Run EXPLAIN on the query."""
        try:
            # Plain EXPLAIN plans without executing (never EXPLAIN ANALYZE
            # here); run it on a tenant-scoped (RLS) connection
            explain_query = f"EXPLAIN (FORMAT JSON) {query}"
            
            result = await self.db.fetchrow(explain_query)
            
            if result:
                # asyncpg returns the json column as text: parse it first
                plan = json.loads(result[0])[0]["Plan"]
                return {
                    "rows": plan.get("Plan Rows", 0),
                    "cost": plan.get("Total Cost", 0)
                }
        except Exception as e:
            # If EXPLAIN fails, assume worst case (classifies as VERY_HIGH)
            return {"rows": 100_000_000, "cost": 1_000_000}
        
        return {"rows": 0, "cost": 0}


class QueryGuard:
    """
    Guards query execution with complexity and timeout limits.
    """
    
    def __init__(
        self,
        analyzer: QueryComplexityAnalyzer,
        quota_service: QuotaService
    ):
        self.analyzer = analyzer
        self.quota_service = quota_service
    
    async def guard_query(
        self,
        query: str,
        tenant_id: str
    ) -> tuple[bool, Optional[str], Optional[int]]:
        """
        Check if query should be allowed.
        
        Returns:
            (allowed, error_message, timeout_seconds)
        """
        # Get tenant's query timeout quota
        timeout_quota = await self.quota_service.get_quota(
            tenant_id,
            "query_timeout_seconds"
        )
        timeout = int(timeout_quota.limit) if timeout_quota else 30
        
        # Analyze query complexity
        analysis = await self.analyzer.analyze(query, tenant_id)
        
        if analysis.blocked_reason:
            return False, f"Query blocked: {analysis.blocked_reason}", None
        
        if analysis.complexity == QueryComplexity.VERY_HIGH:
            # Check if tenant has permission for very expensive queries
            tenant_quotas = await self.quota_service.get_tenant_quotas(tenant_id)
            
            if tenant_quotas.plan != "enterprise":
                return (
                    False,
                    "Query too complex for your plan. Estimated rows: "
                    f"{analysis.estimated_rows:,}. Consider adding filters or "
                    "upgrading to Enterprise.",
                    None
                )
        
        # Add warnings to response headers
        for warning in analysis.warnings:
            # These would be added to response headers
            pass
        
        return True, None, timeout
WorksheetMonday's runaway export against each defense (steady rates are assumed)
Value
Rows scanned so far (from the incident) 2.3 billion in 847 s
Scan rate (assumed steady) 2.3 billion ÷ 847 ≈ 2.7 million rows/s
Cost of the outage (from the incident, assumed steady) $50,000/hour ≈ $14/s → ~$11,800 after 847 s
Killed by Free's 30 s timeout 81 million rows, **$420**
Killed by Pro's 120 s timeout 325 million rows, **$1,700**
Killed by Enterprise's 300 s timeout 815 million rows, **$4,200**
Stopped by QueryGuard (estimate over 10 million rows) rejected before it runs, on every plan but Enterprise

Conclusion: a timeout caps the damage but still lets the query hurt everyone until it fires; the estimate check costs nothing because the query never starts. Enterprise tenants may run VERY_HIGH queries, so for them the timeout is the only guard, which is one more reason to give them dedicated resources.

Your turn · checkpoint 3not answered

What breaks when one tenant leaks connections?

All tenants share one pool of database connections. One tenant's integration opens a connection per request and never closes it. What do the other tenants see after a few minutes, and how does giving each tenant its own capped pool change that?

Think it through, or jot it down. Then compare.

One strong answer

  • Shared pool: the leaking tenant slowly takes every connection. Everyone else's requests wait for a connection that never comes, then time out.
  • Per-tenant pool: the leak can only use that tenant's own max_size (5 on Free). Its requests start failing; nobody else's do.
  • The statement timeout set on each pool also stops one long query from holding a connection forever.

6.2 Database Connection Pool per Tenant#

pythonPoolConfig · 127 lines
# noisy_neighbor/connection_pool.py

"""
Per-tenant connection pool management.

Prevents one tenant from exhausting all database connections.
"""

from dataclasses import dataclass
from typing import Dict, Optional
import asyncio
import asyncpg
import logging

logger = logging.getLogger(__name__)


@dataclass
class PoolConfig:
    """Configuration for a connection pool."""
    min_size: int
    max_size: int
    statement_timeout_ms: int


class TenantConnectionPoolManager:
    """
    Manages database connection pools per tenant.
    
    Each tenant gets their own pool with limits, preventing
    one tenant from exhausting shared connections.
    """
    
    # Pool configurations by plan
    POOL_CONFIGS = {
        "free": PoolConfig(min_size=1, max_size=5, statement_timeout_ms=30000),
        "pro": PoolConfig(min_size=2, max_size=20, statement_timeout_ms=120000),
        "enterprise": PoolConfig(min_size=5, max_size=50, statement_timeout_ms=300000),
    }
    
    def __init__(self, dsn: str, tenant_service):
        self.dsn = dsn
        self.tenant_service = tenant_service
        self._pools: Dict[str, asyncpg.Pool] = {}
        self._lock = asyncio.Lock()
    
    async def get_pool(self, tenant_id: str) -> asyncpg.Pool:
        """
        Get connection pool for a tenant.
        
        Creates pool on first access.
        """
        if tenant_id in self._pools:
            return self._pools[tenant_id]
        
        async with self._lock:
            # Double-check after acquiring lock
            if tenant_id in self._pools:
                return self._pools[tenant_id]
            
            # Get tenant's plan
            tenant = await self.tenant_service.get_tenant(tenant_id)
            plan = tenant.plan if tenant else "free"
            config = self.POOL_CONFIGS.get(plan, self.POOL_CONFIGS["free"])
            
            # Create pool with tenant-specific limits
            pool = await asyncpg.create_pool(
                self.dsn,
                min_size=config.min_size,
                max_size=config.max_size,
                # Server-side: PostgreSQL cancels the statement itself
                server_settings={
                    "statement_timeout": str(config.statement_timeout_ms)
                },
                # Client-side backstop (asyncpg stops waiting)
                command_timeout=config.statement_timeout_ms / 1000,
                setup=self._setup_connection
            )
            
            self._pools[tenant_id] = pool
            
            logger.info(
                f"Created connection pool for tenant {tenant_id}",
                extra={
                    "tenant_id": tenant_id,
                    "plan": plan,
                    "max_connections": config.max_size
                }
            )
            
            return pool
    
    async def _setup_connection(self, conn):
        """Per-connection setup hook."""
        # Note: statement_timeout is set at pool creation via server_settings
        pass
    
    async def get_pool_stats(self, tenant_id: str) -> dict:
        """Get statistics for a tenant's pool."""
        pool = self._pools.get(tenant_id)
        
        if not pool:
            return {"exists": False}
        
        return {
            "exists": True,
            "size": pool.get_size(),
            "free_size": pool.get_idle_size(),
            "used_size": pool.get_size() - pool.get_idle_size(),
            "min_size": pool.get_min_size(),
            "max_size": pool.get_max_size(),
        }
    
    async def close_pool(self, tenant_id: str):
        """Close a tenant's connection pool."""
        pool = self._pools.pop(tenant_id, None)
        
        if pool:
            await pool.close()
            logger.info(f"Closed connection pool for tenant {tenant_id}")
    
    async def close_all(self):
        """Close all connection pools."""
        for tenant_id, pool in list(self._pools.items()):
            await pool.close()
        
        self._pools.clear()
Quick check3 questions · +5 XP each

Quotas, concurrency and query guards

  1. Q1/3 A worker takes a concurrency slot and then crashes before calling release(). What happens to that slot?

    Answer & explanation

    Answer: B

    Each operation is added to the sorted set with score now + ttl, and every acquire first removes entries whose expiry has passed. A leaked slot comes back after at most 300 seconds by default. Redis does not tie the entry to the client connection, so nothing frees it sooner.

  2. Q2/3 A Pro tenant has 40 of its 50 allowed users and adds one more. With warning_threshold = 0.8, what does check_quota report?

    Answer & explanation

    Answer: C

    Exceeded only becomes true when current + requested goes above the limit (50). Usage of 41/50 = 0.82 is at least 0.8, so is_warning is set and logged. The soft threshold gives the tenant time to react before the hard limit hits.

  3. Q3/3 A Pro tenant's export query has an EXPLAIN estimate of 20 million rows. What does QueryGuard do?

    Answer & explanation

    Answer: A

    Over 10 million estimated rows is VERY_HIGH complexity, and only Enterprise may run those. Pro gets an error with advice instead of a running query. Checking the plan before running stops the runaway query from ever touching shared CPU; timeouts are the backstop for queries that pass this check.

Your turn · checkpoint 4not answered

Design it: one worker pool, two tenants

Background jobs go into one first-in-first-out queue served by 10 workers. At 9:00 Tenant A queues 10,000 export jobs; at 9:01 Tenant B queues one password-reset email. When does B's job run, and how would you change the queue so B isn't stuck behind A?

Think it through, or jot it down. Then compare.

One strong answer

  • With one FIFO queue, B's job runs after all 10,000 of A's, possibly hours later.
  • Give each tenant its own queue and take one job from each tenant in turn (round-robin). B waits for about one of A's jobs, not all of them.
  • Cap each tenant's queue so one tenant can't fill memory, and weight by plan if paying tiers should get a bigger share, without letting any tenant starve.

Chapter 7: Fair Scheduling#

7.1 Weighted Fair Queue#

pythonScheduledTask · 232 lines
# noisy_neighbor/fair_scheduler.py

"""
Fair scheduling to ensure all tenants get reasonable service.
"""

from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any, Callable
from datetime import datetime, timezone
import asyncio
import heapq
import logging

logger = logging.getLogger(__name__)


@dataclass
class ScheduledTask:
    """A task scheduled for execution."""
    task_id: str
    tenant_id: str
    priority: int
    enqueued_at: datetime
    payload: Any
    callback: Callable
    
    def __lt__(self, other):
        # Lower priority number = higher priority
        # If same priority, earlier enqueue time wins
        if self.priority != other.priority:
            return self.priority < other.priority
        return self.enqueued_at < other.enqueued_at


class FairScheduler:
    """
    Fair scheduler that ensures no tenant monopolizes resources.
    
    Uses weighted fair queuing:
    - Each tenant gets a weight based on their plan
    - Tasks are ordered by (running tasks + 1) / weight, then enqueue time
    - Caveat: the priority is fixed at submit time, so sustained high-weight
      traffic can starve free tenants. Add aging (improve priority with
      wait time) or use round-robin (TenantPriorityQueue) to bound waits.
    """
    
    # Weight by plan (higher = more priority)
    PLAN_WEIGHTS = {
        "free": 1,
        "pro": 5,
        "enterprise": 20,
    }
    
    def __init__(self, max_concurrent: int = 10):
        self.max_concurrent = max_concurrent
        self._queue: List[ScheduledTask] = []
        self._tenant_usage: Dict[str, int] = {}  # Running tasks per tenant
        self._tenant_weights: Dict[str, int] = {}
        self._running = 0
        self._lock = asyncio.Lock()
        self._condition = asyncio.Condition()
    
    async def submit(
        self,
        tenant_id: str,
        task_id: str,
        payload: Any,
        callback: Callable,
        plan: str = "free"
    ):
        """
        Submit a task for fair scheduling.
        """
        weight = self.PLAN_WEIGHTS.get(plan, 1)
        self._tenant_weights[tenant_id] = weight
        
        # Calculate priority based on weight and current usage
        # Lower number = higher priority
        current_usage = self._tenant_usage.get(tenant_id, 0)
        priority = (current_usage + 1) / weight  # More usage = lower priority
        
        task = ScheduledTask(
            task_id=task_id,
            tenant_id=tenant_id,
            priority=int(priority * 1000),  # Scale for heap
            enqueued_at=datetime.now(timezone.utc),
            payload=payload,
            callback=callback
        )
        
        async with self._lock:
            heapq.heappush(self._queue, task)
        
        # Signal that new work is available
        async with self._condition:
            self._condition.notify()
    
    async def run(self):
        """
        Main scheduler loop.
        """
        while True:
            task = await self._get_next_task()
            
            if task:
                # Execute task in background
                asyncio.create_task(self._execute_task(task))
    
    async def _get_next_task(self) -> Optional[ScheduledTask]:
        """Get the next task to execute."""
        async with self._condition:
            while True:
                # Wait if at max concurrency
                while self._running >= self.max_concurrent:
                    await self._condition.wait()
                
                # Try to get a task
                async with self._lock:
                    if self._queue:
                        task = heapq.heappop(self._queue)
                        self._running += 1
                        self._tenant_usage[task.tenant_id] = \
                            self._tenant_usage.get(task.tenant_id, 0) + 1
                        return task
                
                # No tasks, wait for signal
                await self._condition.wait()
    
    async def _execute_task(self, task: ScheduledTask):
        """Execute a task and update counts."""
        try:
            await task.callback(task.payload)
        except Exception as e:
            logger.error(
                f"Task failed: {e}",
                extra={
                    "task_id": task.task_id,
                    "tenant_id": task.tenant_id
                }
            )
        finally:
            async with self._condition:
                self._running -= 1
                self._tenant_usage[task.tenant_id] = \
                    max(0, self._tenant_usage.get(task.tenant_id, 0) - 1)
                self._condition.notify()
    
    def get_stats(self) -> dict:
        """Get scheduler statistics."""
        return {
            "queue_length": len(self._queue),
            "running": self._running,
            "max_concurrent": self.max_concurrent,
            "tenant_usage": dict(self._tenant_usage)
        }


class TenantPriorityQueue:
    """
    Priority queue with per-tenant fairness.
    
    Ensures that a tenant with many queued tasks doesn't
    starve tenants with fewer tasks.
    """
    
    def __init__(self, max_per_tenant: int = 100):
        self.max_per_tenant = max_per_tenant
        self._queues: Dict[str, List[Any]] = {}  # Per-tenant queues
        self._round_robin_index = 0
        self._tenant_order: List[str] = []
        self._lock = asyncio.Lock()
    
    async def enqueue(
        self,
        tenant_id: str,
        item: Any
    ) -> bool:
        """
        Enqueue an item for a tenant.
        
        Returns False if tenant's queue is full.
        """
        async with self._lock:
            if tenant_id not in self._queues:
                self._queues[tenant_id] = []
                self._tenant_order.append(tenant_id)
            
            if len(self._queues[tenant_id]) >= self.max_per_tenant:
                return False
            
            self._queues[tenant_id].append(item)
            return True
    
    async def dequeue(self) -> Optional[tuple[str, Any]]:
        """
        Dequeue an item using round-robin across tenants.
        
        Returns (tenant_id, item) or None if empty.
        """
        async with self._lock:
            if not self._tenant_order:
                return None
            
            # Try each tenant in round-robin order
            attempts = len(self._tenant_order)
            
            for _ in range(attempts):
                tenant_id = self._tenant_order[self._round_robin_index]
                self._round_robin_index = \
                    (self._round_robin_index + 1) % len(self._tenant_order)
                
                if self._queues[tenant_id]:
                    item = self._queues[tenant_id].pop(0)
                    
                    # Remove tenant if queue empty
                    if not self._queues[tenant_id]:
                        del self._queues[tenant_id]
                        self._tenant_order.remove(tenant_id)
                        if self._round_robin_index >= len(self._tenant_order):
                            self._round_robin_index = 0
                    
                    return tenant_id, item
            
            return None
    
    def get_queue_depth(self, tenant_id: str) -> int:
        """Get queue depth for a tenant."""
        return len(self._queues.get(tenant_id, []))
    
    def get_total_depth(self) -> int:
        """Get total queue depth across all tenants."""
        return sum(len(q) for q in self._queues.values())
ExplainerFair scheduling across tenants

Tenant A queues 100 jobs, then Tenant B queues 1. A shared FIFO makes B wait for all 100. Per-tenant queues with round-robin dequeue serve B after about one A job and cap each tenant at 100 queued. Weighted fair queuing scores (running + 1) / weight, so an enterprise task (10/20 = 0.5) runs before a free one (1/1 = 1.0).

Quick check3 questions · +5 XP each

Fair scheduling

  1. Q1/3 Tenant A queues 100 jobs. A moment later Tenant B queues 1 job. With TenantPriorityQueue, roughly when does B's job run?

    Answer & explanation

    Answer: D

    Dequeue rotates across tenants, taking one item from each in turn, so B waits for at most a pass over the other tenants, not for A's whole backlog. A single FIFO queue would make B wait behind all 100 jobs, which is exactly the starvation this design prevents.

  2. Q2/3 Why does enqueue return False once a tenant has max_per_tenant items queued?

    Answer & explanation

    Answer: B

    Without a cap, one flooding tenant could grow the queue and memory without limit. The cap applies backpressure only to the tenant over its share. Other tenants can keep enqueuing normally.

  3. Q3/3 In FairScheduler, priority is (running_tasks + 1) / weight and lower is served first. A free tenant (weight 1) has 0 running tasks; an enterprise tenant (weight 20) has 9 running. Whose next task wins?

    Answer & explanation

    Answer: C

    The enterprise priority is 0.5 (500 after scaling) against 1.0 (1000) for free, so enterprise goes first even with 9 tasks running. Weight divides the score, so paying tiers get more share, while each extra running task raises a tenant's score and slows it down.

Part III · Stage 3 of 4 · 4 min

Part III: Real-World Application#

Chapter 8: Case Studies#

8.1 Salesforce Governor Limits#

Salesforce governor limits

Salesforce is well known for aggressive resource limiting in multi-tenant SaaS.

Per-transaction limits
  • Total SOQL queries: 100
  • Total SOQL query rows: 50,000
  • Total DML statements: 150
  • Total DML rows: 10,000
  • CPU time: 10,000 ms
  • Heap size: 6 MB (sync) / 12 MB (async)
  • Callouts: 100
Per-day limits (varies by edition)
  • API calls: 15,000 - 1,000,000+
  • Batch Apex: 250,000 executions
  • Email sends: 1,000 - 5,000
  • Data storage: 10 GB base + per-user allocation
How they enforce
  • Hard limits - transaction fails immediately
  • Soft limits - warnings in logs
  • Monitoring - usage dashboards in UI
  • Governor limit exceptions with stack trace
Results
  • A very large customer base on shared infrastructure
  • Predictable performance
  • Forces efficient code
  • Clear upgrade path (higher limits = higher tier)
Lessons for us
  • Hard limits are better than soft limits
  • Make limits visible to developers
  • Provide clear error messages
  • Design tiered limits into pricing
  • Limits become a feature, not a bug

8.2 AWS Service Quotas#

AWS service quota model

AWS uses quotas to protect shared services.

EC2 quotas (per region)
  • Running On-Demand instances: varies by type
  • EBS snapshots: 100,000
  • Elastic IPs: 5
  • VPC security groups per region: 2,500
How AWS handles quotas
  • Default quotas per account
  • Quotas dashboard in console
  • API to check current usage
  • Request quota increase via support
  • Automatic increase for some quotas
  • Service Quotas service for management
Quota increase process
  1. Customer requests increase via console
  2. AWS reviews request (automated + manual)
  3. Increase approved or denied with reason
  4. Some increases require account review
Key insights
  • Quotas are per-account AND per-region
  • Most quotas can be increased
  • Some hard limits exist (physics/security)
  • Transparency builds trust
  • Self-service where possible
Lessons for us
  • Make quota increases possible
  • Provide self-service for simple increases
  • Document why limits exist
  • Regional quotas for data residency
  • API for quota checking

8.3 Stripe Rate Limiting#

Stripe rate limiting model

Stripe uses sophisticated rate limiting.

Default limits
  • Sandbox (test) mode: 25 requests/second
  • Live mode: 100 requests/second (can be increased via support)
  • Most individual endpoints: 25 requests/second
  • Files API / Search API: Separate limits (20 reads/second)
  • Concurrency limit: ~50 in-flight requests (live mode)
Response on limit
  • HTTP 429 Too Many Requests
  • Stripe-Rate-Limited-Reason header: global-rate, endpoint-rate, global-concurrency, endpoint-concurrency, resource-specific
  • Detailed error message
  • Request ID for support
Intelligent features
  • Separate rate and concurrency limits
  • Per-API limits (not all endpoints equal)
  • Docs recommend exponential backoff with jitter (and client-side token buckets)
  • Limit increases on request, with notice for large events
Lessons for us
  • Include rate limit headers in ALL responses
  • Make SDK handle retries automatically
  • Different limits for different operations
  • Burst allowance improves UX
  • Clear communication when limited

Chapter 9: Common Mistakes#

✗ Pitfall

9.1 Noisy Neighbor Prevention Anti-Patterns#

Common mistakes
Mistake 1: No Limits at All

Wrong:

pythonimport_data · 5 lines
# Accept any request, any size, any frequency
@app.post("/api/import")
async def import_data(data: List[Record]):
    for record in data:  # Could be millions
        await db.insert(record)

Problem: One import of 10M records blocks everything

Right:

pythonimport_data · 8 lines
@app.post("/api/import")
async def import_data(data: List[Record]):
    if len(data) > 10000:
        raise HTTPException(400, "Max 10,000 records per import")
    
    # Use background job for large imports
    job_id = await queue_import_job(data)
    return {"job_id": job_id}
Mistake 2: Global Limits Instead of Per-Tenant

Wrong:

pythonrate_limit · 8 lines
# One rate limiter for all tenants
rate_limiter = RateLimiter(requests_per_second=1000)

@app.middleware("http")
async def rate_limit(request, call_next):
    if not rate_limiter.allow():
        return Response(status_code=429)
    return await call_next(request)

Problem: One tenant doing 900 req/sec leaves only 100 for everyone else

Right:

pythonrate_limit · 6 lines
@app.middleware("http")
async def rate_limit(request, call_next):
    tenant_id = request.state.tenant_id
    if not await tenant_rate_limiter.allow(tenant_id):
        return Response(status_code=429)
    return await call_next(request)
Mistake 3: No Query Timeouts

Wrong:

pythonget_report · 5 lines
@app.get("/api/reports")
async def get_report():
    # No timeout - can run forever
    result = await db.fetch("SELECT * FROM events WHERE ...")
    return result

Problem: Query runs for 30 minutes, holding connections

Right:

pythonget_report · 9 lines
@app.get("/api/reports")
async def get_report():
    try:
        async with asyncio.timeout(30):  # 30 second limit
            result = await db.fetch("SELECT * FROM events WHERE ...")
        return result
    except asyncio.TimeoutError:
        # 503, not 408: 408 means the *client* was too slow sending its request
        raise HTTPException(503, "Query timed out. Try adding filters.")
Mistake 4: Silent Throttling

Wrong:

python4 lines
if rate_limited:
    # Just delay the request silently
    await asyncio.sleep(10)
    return await process_request()

Problem: User doesn't know why things are slow. Holding connections during sleep.

Right:

python11 lines
if rate_limited:
    return JSONResponse(
        status_code=429,
        content={
            "error": "rate_limit_exceeded",
            "message": "You've exceeded your rate limit of 100 req/min",
            "retry_after": 30,
            "upgrade_url": "https://example.com/pricing"
        },
        headers={"Retry-After": "30"}
    )
Mistake 5: Same Limits for All Operations

Wrong:

python2 lines
# 1000 requests/minute for everything
API_RATE_LIMIT = 1000

Problem: 1000 export requests = database meltdown; 1000 health checks = no big deal

Right:

python7 lines
RATE_LIMITS = {
    "default": 1000,
    "search": 100,     # Search is expensive
    "export": 10,      # Export is very expensive
    "bulk_create": 5,  # Bulk operations limited
    "health": 10000,   # Health checks are cheap
}
Quick check3 questions · +5 XP each

Real-world limits and common mistakes

  1. Q1/3 You protect the API with one global limiter at 1,000 req/sec. One tenant starts sending 900 req/sec. What do the other tenants experience?

    Answer & explanation

    Answer: A

    A global limiter has no idea who is sending what, so the noisy tenant uses most of the budget and everyone else is throttled. Only per-tenant limits keep each tenant's traffic from eating into the others'.

  2. Q2/3 Instead of rejecting over-limit requests, a service just sleeps 10 seconds and then processes them. Why is that worse than an HTTP 429?

    Answer & explanation

    Answer: C

    Silent throttling hides the limit from the client and ties up server resources during the wait, which makes the noisy-neighbor problem worse. A 429 with a clear message and Retry-After frees the resources at once and tells the client exactly when to try again.

  3. Q3/3 Your API allows 1,000 requests/minute for every endpoint. Which change best follows the lesson's advice?

    Answer & explanation

    Answer: B

    Operations differ hugely in cost: 1,000 exports can melt the database while 1,000 health checks are nothing. Limits per operation (search 100, export 10, bulk 5) match the limit to the cost, as Stripe does with per-API limits.

Part IV · Stage 4 of 4 · 5 min

Part IV: Interview Preparation#

Chapter 10: Interview Tips#

10.1 Noisy Neighbor Discussion Framework#

Discussing noisy neighbors in interviews
  1. IDENTIFY THE RESOURCES AT RISK — "In this system, the main resources that could be exhausted are:"
    • Database connections and CPU
    • API server capacity
    • Network bandwidth
    • Storage I/O
  2. PROPOSE MULTI-LAYER DEFENSE — "I'd implement protection at multiple layers:"
    • API Gateway: Request rate limiting
    • Application: Concurrency limits, query analysis
    • Database: Connection pools, statement timeouts
    • Infrastructure: Resource quotas per tenant
  3. EXPLAIN QUOTA DESIGN — "Quotas would vary by pricing tier:"
    • Free: 60 API calls/minute, 5 concurrent
    • Pro: 1000 API calls/minute, 50 concurrent
    • Enterprise: 10,000 API calls/minute, 200 concurrent
    • This creates upgrade incentive while protecting the platform."
  4. ADDRESS USER EXPERIENCE — "When limits are hit, clear communication is crucial:"
    • HTTP 429 with retry-after header
    • Dashboard showing current usage
    • Proactive alerts before hitting limits
    • Clear upgrade path
❯ Say this

10.2 Key Phrases#

Noisy neighbor key phrases
On Rate Limiting

"For rate limiting, I'd use token bucket algorithm with per-tenant buckets. This allows short bursts while maintaining overall limits. The key is making limits visible - rate limit headers in every response."

On Resource Quotas

"Resource quotas need to be enforced at multiple levels. API rate limits catch volume attacks, but a single expensive query can still cause damage. That's why we also need query timeouts, connection limits, and compute quotas."

On Fair Scheduling

"To ensure fairness, I'd use weighted fair queuing. Each tenant gets a weight based on their plan, and tasks are scheduled to prevent any tenant from monopolizing resources. This is especially important for background job processing."

On Graceful Degradation

"When a tenant hits their limit, the system should degrade gracefully. Rather than error immediately, we can queue requests, return cached data, or provide reduced functionality. The goal is to never completely block a paying customer."

On Monitoring

"Visibility is crucial for noisy neighbor prevention. I'd track per-tenant resource usage in real-time, with dashboards for both operators and customers. Alerts would fire when tenants approach 80% of their quota, allowing proactive intervention."

Chapter 11: Practice Problems#

Practice · problem 1

Problem 1: Database Connection Exhaustion#

Scenario: Your multi-tenant SaaS has 1,000 tenants sharing a PostgreSQL database with max 500 connections. One tenant opens a connection for each API request and doesn't close them properly.

Questions:

  1. How do you detect this is happening?
  2. How do you prevent it from affecting other tenants?
  3. What's your long-term solution?
Click for hints
  • Monitor connections per tenant
  • Per-tenant connection pools with max size
  • Connection timeout/idle timeout
  • Circuit breaker when pool exhausted
  • Alert on connection leak patterns

Practice · problem 2

Problem 2: Export Feature Abuse#

Scenario: You have an "Export to CSV" feature. A tenant triggers exports of their entire 10GB dataset every 5 minutes, causing database performance issues.

Questions:

  1. How do you limit exports without breaking the feature?
  2. What's the user experience when limited?
  3. How would you handle legitimate large exports?
Click for hints
  • Rate limit exports (e.g., 5/hour)
  • Row limits per export
  • Background job for large exports
  • Incremental/streaming exports
  • Caching of repeated exports
  • Charge for large exports

Chapter 12: Sample Interview Dialogue#

Transcript
Interviewer

"In the system you're designing, how would you prevent one customer from affecting others?"

You

"Great question — this is the noisy neighbor problem. I'd implement protection at multiple layers.

First, at the API layer, per-tenant rate limiting using token bucket algorithm. Different limits by plan — maybe 60/minute for free, 1000/minute for pro. Every response includes rate limit headers so clients can track their usage.

Second, at the application layer, I'd add concurrency limits. A tenant might be within their rate limit but running 50 expensive operations simultaneously. I'd limit concurrent requests to 5 for free tier and 50 for pro, as in the quota table.

Third, at the database layer, per-tenant connection pools and query timeouts. Free tier gets max 5 connections with 30-second timeout. Enterprise gets 50 connections with 5-minute timeout. This prevents both connection exhaustion and runaway queries.

Let me draw the flow..."

DiagramRequest path through the protection layers
%% title: Request path through the protection layers
%% steps:
%% R --> GW: The API gateway checks the request against its rate limit
%% GW --> APP: The application checks how many requests the tenant already has in flight
%% APP --> QG: The query guard analyses the query's complexity before it runs
%% QG --> CP: Each tenant gets a limited share of the connection pool
%% CP --> DB: The database's statement timeout stops anything that still runs too long
flowchart TD
  R(["Request"]):::client --> GW@{ icon: "sd:gateway", label: "API Gateway<br/>(rate limit check)" }
  GW --> APP["Application<br/>(concurrency check)"]:::core
  APP --> QG@{ icon: "sd:shield", label: "Query Guard<br/>(complexity analysis)" }
  QG --> CP["Connection Pool<br/>(per-tenant limit)"]:::service
  CP --> DB@{ icon: "sd:database", label: "Database<br/>(statement timeout)" }
  class GW edge
  class QG service
  class DB store
  1. The API gateway checks the request against its rate limit
  2. The application checks how many requests the tenant already has in flight
  3. The query guard analyses the query's complexity before it runs
  4. Each tenant gets a limited share of the connection pool
  5. The database's statement timeout stops anything that still runs too long
Transcript
Interviewer

"What happens when a customer hits their limit?"

You

"The user experience is critical. When rate limited, they get HTTP 429 with a clear message: 'You've made 60 requests in the last minute. Your limit is 60/minute. Retry in 30 seconds.' The response includes Retry-After header.

For proactive communication, the dashboard shows current usage against limits. We'd send email alerts at 80% usage: 'You're approaching your API limit. Consider upgrading or optimizing your integration.'

We'd also have a soft limit before the hard limit — at 80%, requests still succeed but include a warning header. This gives developers time to react before hitting the wall."

Interviewer

"How would you handle a sudden spike in legitimate usage?"

You

"I'd design for burst tolerance. Token bucket allows short bursts — if a tenant normally uses 50% of their limit, they've accumulated tokens and can burst to maybe 150% briefly.

For sustained increases, we could offer temporary quota increases via API. The customer calls an endpoint to request a 2x quota for the next hour, explaining why. Simple requests auto-approve; large requests queue for review.

For enterprise customers, we might offer dedicated resources — their own database or worker pool — so their spikes only affect themselves."

Summary#

Day 2 key takeaways
Noisy neighbor categories
  • CPU-bound (expensive queries)
  • Memory-bound (large operations)
  • I/O-bound (bulk imports)
  • Network-bound (high request volume)
  • Connection-bound (pool exhaustion)
  • Storage-bound (data accumulation)
Defense layers
  • API Gateway: Rate limiting, payload limits
  • Application: Concurrency, query analysis
  • Database: Timeouts, connection pools
  • Infrastructure: CPU/memory quotas
Rate limiting
  • Token bucket for smooth limiting
  • Per-tenant, not global
  • Different limits for different operations
  • Include headers in every response
  • Burst allowance for UX
Quota design
  • Rate quotas (per time window)
  • Concurrency quotas (simultaneous)
  • Volume quotas (total amount)
  • Compute quotas (processing time)
  • Tiered by pricing plan
Fair scheduling
  • Weighted by plan
  • Round-robin across tenants
  • Prevent starvation
  • Priority for paying customers
User experience
  • Clear error messages
  • Retry-After headers
  • Usage dashboards
  • Proactive alerts
  • Upgrade path
Default approach
  • Start with per-tenant rate limits
  • Add query timeouts early
  • Monitor before you enforce
  • Communicate limits clearly

Further Reading#

Documentation:

Engineering Blogs:

  • Docs

    Stripe: "Scaling your API with rate limiters"

  • Read

    Figma: "How Figma's multiplayer technology works"

  • Read

    Cloudflare: "How we built rate limiting capable of scaling to millions of domains"

Tools:

  • Read

    Redis Cell (rate limiting module)

  • Read

    resilience4j (Java circuit breaker/rate limiter)

this lesson in shorts · 2

play them all →

One idea from this lesson in about a minute each, made for your phone.

End of Day 02 · Noisy Neighbor Prevention
Finish Day 2Stages clear as you read them (0/4 so far). Skipping ahead doesn't count: marking complete unlocks after ~7 more min of reading.

Go deeper · reading trail

all reading →

Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.

AmazonBlog post · ~30 min

Fairness in multi-tenant systems

David Yanacek · 2020
What
How Amazon keeps shared services fair when one tenant's traffic spikes.
Key idea
Rate limiting (admission control), per-tenant quotas and workload isolation stop one customer's load from degrading others, with care about where limits are enforced.
Why here
The quota design principles and rate limiting chapters of this lesson, from services that serve millions of tenants.
taught here: Quota design
AmazonBlog post · ~9 min

Workload isolation using shuffle-sharding

Colm MacCárthaigh · 2019
What
The technique Route 53 uses to contain the damage from one abusive customer.
Key idea
Give each customer a near-unique combination of a few workers out of many, so a customer that overwhelms its shard rarely shares all of them with anyone else.
Why here
A stronger form of the resource isolation in this lesson: limit blast radius without one silo per tenant.
taught here: Resource isolation

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…