Preface#
Yesterday, we learned how to isolate tenant data. But there's another isolation problem:
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
- Query:
sql
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.
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: 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.
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#
| # | 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#
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#
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?
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#
| 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#
- Layer 1: API GATEWAY — Request rate limiting · Payload size limits · Connection limits
- Blocks: Volume-based attacks
- Layer 2: APPLICATION — Query complexity analysis · Operation quotas · Concurrent operation limits
- Blocks: Expensive operations
- Layer 3: DATABASE — Statement timeouts · Connection limits per tenant · Row limit on queries
- Blocks: Database abuse
- 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 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#
| 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.
Noisy neighbor foundations
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.
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.
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: Implementation#
Chapter 4: Rate Limiting Implementation#
4.1 Token Bucket Algorithm#
# 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#
# 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 limitRate limiting
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.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.
Q3/3
check_rate_limitreturns "allowed" with unlimited tokens when it finds no quota, not even theapi_ratefallback. 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#
# 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, None5.2 Concurrent Request Limiting#
# 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 FalseEstimate: 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?
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#
# 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| 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 | |
| Killed by Pro's 120 s timeout | |
| Killed by Enterprise's 300 s timeout | |
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.
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?
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#
# 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()Quotas, concurrency and query guards
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.Q2/3 A Pro tenant has 40 of its 50 allowed users and adds one more. With
warning_threshold = 0.8, what doescheck_quotareport?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_warningis set and logged. The soft threshold gives the tenant time to react before the hard limit hits.Q3/3 A Pro tenant's export query has an EXPLAIN estimate of 20 million rows. What does
QueryGuarddo?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.
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?
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#
# 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())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).
Fair scheduling
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.
Q2/3 Why does
enqueuereturn False once a tenant hasmax_per_tenantitems 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.
Q3/3 In
FairScheduler, priority is(running_tasks + 1) / weightand 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: Real-World Application#
Chapter 8: Case Studies#
8.1 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 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
- Customer requests increase via console
- AWS reviews request (automated + manual)
- Increase approved or denied with reason
- 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 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-Reasonheader: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#
9.1 Noisy Neighbor Prevention Anti-Patterns#
Mistake 1: No Limits at All
Wrong:
# 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:
@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:
# 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:
@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:
@app.get("/api/reports")
async def get_report():
# No timeout - can run forever
result = await db.fetch("SELECT * FROM events WHERE ...")
return resultProblem: Query runs for 30 minutes, holding connections
Right:
@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:
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:
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:
# 1000 requests/minute for everything
API_RATE_LIMIT = 1000Problem: 1000 export requests = database meltdown; 1000 health checks = no big deal
Right:
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
}Real-world limits and common mistakes
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'.
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.
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: Interview Preparation#
Chapter 10: Interview Tips#
10.1 Noisy Neighbor Discussion Framework#
- 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
- 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
- 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."
- 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
10.2 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:
- How do you detect this is happening?
- How do you prevent it from affecting other tenants?
- 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:
- How do you limit exports without breaking the feature?
- What's the user experience when limited?
- 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#
"In the system you're designing, how would you prevent one customer from affecting others?"
"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..."
%% 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- The API gateway checks the request against its rate limit
- The application checks how many requests the tenant already has in flight
- The query guard analyses the query's complexity before it runs
- Each tenant gets a limited share of the connection pool
- The database's statement timeout stops anything that still runs too long
"What happens when a customer hits their limit?"
"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."
"How would you handle a sudden spike in legitimate usage?"
"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#
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:
- Read
Redis Rate Limiting Patterns: https://redis.io/glossary/rate-limiting/
- Wiki
Token Bucket Algorithm: https://en.wikipedia.org/wiki/Token_bucket
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.
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.
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.
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.
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.

