$ cat case-studies/high-scale-search.md
An architecture deep-dive into high-scale-search β a search orchestration service designed for 10B+ records at sub-200ms p99, unifying Elasticsearch, ClickHouse, Firestore, Redis, and Kafka behind a fault-tolerant pipeline.
Most side projects that claim to be "high-scale" stop at a README description. The goal here was the opposite: pick one hard, narrow problem β serving search over a dataset too large and too varied for any single datastore to handle well β and implement the actual resilience machinery a real search platform needs, not just the happy path.
Search should never return a hard error. Instead of a single retry-then-fail path, every request falls through a graduated chain of degradation:
| Level | Source | When |
|---|---|---|
| 0 | Redis cache | Cache hit on query fingerprint |
| 1 | Elasticsearch (primary) | Normal operation, behind a circuit breaker + retry |
| 2 | Redis stale cache | Primary ES fails; serve slightly stale results |
| 3 | ClickHouse (degraded) | Both ES and cache unavailable; basic text search |
| 4 | Static popular results | All backends down; pre-loaded popular results |
TTLs are tuned per query type rather than applied uniformly β high-churn data (trending) expires fast, stable data (autocomplete) is cached longer:
| Query type | TTL | Key pattern |
|---|---|---|
| Autocomplete | 10 min | ac:{prefix_hash} |
| Search results | 2 min | sr:{query_hash} |
| Facet counts | 5 min | fc:{category}:{filters_hash} |
| Trending | 60 sec | trend:{region} |
| Stale fallback | 1 hour | sr:stale:{query_hash} |
Every layer emits Prometheus metrics β request latency histograms by intent/source/status, cache hit/miss counters, per-backend query duration, circuit breaker state, indexing lag, and Kafka consumer lag. Queries over 200ms are logged as slow; over 500ms are marked critical and written to a ClickHouse query_performance table for trend analysis. OpenTelemetry traces carry a consistent trace_id across every backend hop, so a single slow request can be followed end-to-end instead of guessed at.
search-{type}-{region}-{yyyy.MM}_source β only searchable fields are indexed; full documents are hydrated from Firestore on read, keeping the index lean_score * (1 + log1p(popularity_score)) so relevance and popularity aren't fought over in separate passesThe fallback chain and circuit breaker were the most valuable parts to build for real rather than describe abstractly β the interesting decisions (backoff caps, half-open probe timing, what "degraded" should even mean for a given query type) only show up once you implement it. If I extended this further, the next real gap is load-testing the fallback transitions themselves β proving level 1→2→3 handoffs stay correct under actual concurrent failure, not just in isolation.