Practice · High Level Design · Read-Heavy: Serve It Fast
Design Search Autocomplete
Suggestions on every keystroke in under 100 ms: a trie with top-K per prefix, building it offline from search logs, and caching close to the user.
Learn mode: the full solution is shown below.
Problem introduction
| Difficulty | Intermediate |
| Time | 2–3 hours to design and build the core; about 45 minutes in an interview |
| Prerequisites | Storage and search, caching and CDNs, what a trie is |
| Concepts | Latency budgets, tries with precomputed top-K, offline batch building vs online serving, immutable snapshots and atomic swaps, versioned cache keys, edge caching, sharding by prefix range, trending with time decay |
| Real world | The search box of any big e-commerce site, Google and YouTube suggestions, app stores, maps ("dhanmondi 27..."), the "to" field of a bus or train ticket site |
Autocomplete looks like a small feature: a dropdown under a search box. It's a great HLD problem because the numbers force a design that's very different from "query the database": every keystroke is a request, the answer must arrive before the user types the next letter, and the data (what people search for) changes all day. The core lesson is one you'll reuse everywhere: split the system into a slow offline path that prepares answers and a fast online path that only reads them.
Problem statement
Someone in Mirpur opens a big shopping site and starts typing "dha". Before they finish the word, a dropdown already shows "dhaka weather", "dhakaiya biryani", "dhaka traffic". They tap one and skip typing the rest. On a slow phone keyboard, that saves real effort, and it steers people to queries that actually have results.
What we are building: a service that, given what a user has typed so far (a prefix), returns the 10 most popular complete queries that start with it, fast enough to feel instant.
Who uses it: everyone who types in the search box, on every keystroke. Indirectly, the search team, who need the suggestions to be fresh (a new metro line, an Eid sale, breaking news) and clean (no offensive or personal suggestions).
What it solves: less typing, fewer spelling mistakes, and searches that land on things that exist.
What makes it technically interesting:
- Latency: the answer must come back in well under 100 ms end to end, including a mobile network, or the dropdown lags behind the typing and is useless.
- Volume: one search becomes several requests (one per pause in typing), so suggestion traffic is many times search traffic.
- Short prefixes are huge: "d" matches millions of queries. Sorting them on every keystroke is impossible.
- Freshness vs speed: popularity changes during the day, but rebuilding answers on the request path would kill latency.
- Content safety: whatever people type can become a suggestion shown to millions, including slurs, rumours and phone numbers.
Requirements
Functional requirements
- Suggest: for a prefix, return up to 10 complete queries that start with it, most popular first.
- Only real searches: suggestions come from queries people actually submitted (pressed Enter), not from every keystroke.
- Freshness: a query that becomes popular shows up within about an hour; fast-breaking trends within about 15 minutes (stretch goal).
- Filtering: blocked words, offensive content and anything that looks like personal data are never suggested; a suggestion can be removed within minutes after a complaint.
- Out of scope: spelling correction ("dhkaa"), personalised suggestions, and the search results page itself. We discuss the first two in the interview section.
Non-functional requirements
| Requirement | Target | Why it matters here |
|---|---|---|
| Latency | p99 under 10 ms at the server; the dropdown appears under ~100 ms after the user pauses | Slower than the next keystroke means the user never sees it |
| Throughput | ~60,000 requests/s at peak (estimated below) | Every pause in typing is a request |
| Availability | 99.9% | Autocomplete is helpful, not critical: if it fails, search still works |
| Freshness | Popular new queries within ~1 hour | Stale suggestions miss the Eid sale and the news |
| Consistency | Eventual; one request must see one version of the data | A few minutes of staleness is fine, a mix of two versions in one answer is confusing |
| Durability | Search logs may lose a little data; the built tries can always be rebuilt | Counts are statistics, not money |
| Safety and privacy | No blocked or personal suggestions; fast takedown | One bad suggestion is a news story |
Latency and throughput drive the design. Note what's not strict: freshness is measured in minutes, durability is relaxed, and availability can be 99.9% because the feature degrades gracefully. That freedom is what lets us precompute everything.
Hint 1: the time budget
If the whole round trip must fit in under 100 ms and the mobile network takes most of that, how many milliseconds does the server really have? What kind of work fits in that?
Hint 2: the short-prefix problem
"d" matches millions of queries. You can't sort them on every keystroke. When could that sorting happen instead?
Hint 3: where popularity comes from
Which events should count: keystrokes, or submitted searches? How fresh does the count need to be, and does that work need to happen while the user waits?
Hint 4: fewer requests
Typing "dhaka metro" is 11 keystrokes. Does the server need to see all 11? What can the browser and the CDN do?
Capacity estimation
Assumptions (said out loud, round numbers):
- 50 million daily active users, each submitting 6 searches a day.
- A typed query is about 18 characters, but with a short debounce the browser sends about 6 suggestion requests per search.
- The busiest hour is about 3× the average.
- Only queries searched by at least 20 different people in 30 days can become suggestions (this is also a privacy rule, see Security). That leaves about 10 million suggestible queries, 20 bytes long on average.
- We store the top 10 per prefix.
Traffic
| Formula | Result | |
|---|---|---|
| Searches per day | 50M × 6 | 300 million |
| Suggestion requests per day | 300M × 6 | 1.8 billion |
| Suggestion requests per second (avg) | 1.8B ÷ 86,400 | ≈ 21,000 / s |
| Suggestion requests per second (peak) | 21,000 × 3 | ≈ 63,000 / s |
| Search log events per second (avg / peak) | 300M ÷ 86,400 | ≈ 3,500 / s, ≈ 10,500 / s |
Storage
| Formula | Result | |
|---|---|---|
| Search log per day | 300M × 200 B per event | 60 GB / day |
| Log kept 90 days, 3 copies | 60 GB × 90 × 3 | ≈ 16 TB |
| Query text | 10M × 20 B | 200 MB |
| Trie nodes | under 10M × 20 (shared prefixes cut it to ~60M) | ≈ 60 million nodes |
| Trie size | 60M nodes × ~80 B (children + 10 query IDs of 4 B + overhead) | ≈ 5 GB |
Servers: a trie lookup in memory takes microseconds; the HTTP work around it dominates. Assuming a conservative 10,000 requests/s per server, peak needs about 7 servers. Run 10 across two zones so we can lose a zone and still be fine. Each server holds the whole 5 GB trie, so any server can answer any prefix.
Bandwidth: a response with 10 suggestions is about 400 bytes of JSON plus ~300 bytes of headers. 63,000 × 700 B ≈ 44 MB/s (~350 Mbit/s) at peak. Noticeable but fine, and gzip and the CDN cut it further.
Cache size: prefixes of 1 to 3 characters number about 37 + 37² + 37³ ≈ 52,000 (letters, digits, space). At 700 B each that's 36 MB, and these short prefixes are by far the most repeated, so they're perfect for a CDN edge cache.
What the numbers say: the serving data (5 GB) fits in one machine's memory, so a lookup can be a pointer walk with no database at all. The expensive part (counting 300 million searches and ranking every prefix) can happen offline, in batches, because freshness is measured in minutes. That's the whole design in two sentences.
API design
| Method and path | Request | Success | Errors |
|---|---|---|---|
GET /v1/suggest?q=dha&lang=en&limit=10 | none | 200 {"q": "dha", "version": 412, "suggestions": ["dhaka weather", "dhakaiya biryani", "dhaka traffic"]} with Cache-Control: public, max-age=60 | 400 prefix too long or bad lang, 429 too many requests |
POST /v1/search (the search service, not ours) | {"q": "dhaka metro rail", "event_id": "..."} | results page | logged as a search event for counting |
POST /admin/v1/blocked-terms | {"term": "...", "reason": "..."} (staff only) | 201 | 403 |
- Why GET with the prefix in the URL: it makes the response cacheable by the browser and the CDN, keyed by the URL. That's the most important property of this API.
- No counts in the response: popularity numbers are internal; exposing them invites manipulation.
- Authentication: suggestions are anonymous. That's deliberate: a cookie or user ID in the request would make the response personal and uncacheable. Rate limiting is per IP and per client app.
- Idempotency:
GETis naturally idempotent. Search events carry anevent_idso the counting pipeline can drop duplicates when the log delivers an event twice. versiontells the client which trie answered; useful for debugging "why did I see this suggestion", and it lets the client ignore a response from an older version that arrives late.- Client contract: the browser waits a short pause (about 50–100 ms) before sending, cancels the previous request when a new key is pressed, and caches answers for the session. These three rules cut server traffic a lot.
Database design
Access pattern: the online path reads "top 10 for this exact prefix", tens of thousands of times a second, and never writes. The offline path appends search events all day and, once an hour, rewrites all the counts.
Why not just query a database? SELECT query FROM queries WHERE query LIKE 'd%' ORDER BY count DESC LIMIT 10 can use a B-tree index for the prefix, but for "d" it still has to read millions of matching rows to sort them by count. That's fine for a site with 1,000 queries and hopeless at our scale. The fix is to precompute the answer for every prefix, and a trie is a compact way to store those answers.
So the "database" for serving is the trie itself, in memory, loaded from a file. The other stores are ordinary:
-- Raw search events: an append-only log (Kafka topic), then hourly files in object storage
-- (Parquet, partitioned by hour). Not a SQL table; shown as columns for clarity.
-- event_id, query, lang, region, user_hash, ts
-- Aggregated counts, rewritten by every batch (warehouse table or Parquet files).
CREATE TABLE query_counts (
query TEXT NOT NULL,
lang TEXT NOT NULL,
score BIGINT NOT NULL, -- decayed count: old score / 2 + new searches
users_30d INT NOT NULL, -- distinct users, for the privacy threshold
PRIMARY KEY (lang, query)
);
-- Which trie version each shard should serve (small, PostgreSQL).
CREATE TABLE trie_versions (
version BIGINT PRIMARY KEY,
shard TEXT NOT NULL, -- e.g. 'a-c', 'd-k', 'l-z'
path TEXT NOT NULL, -- object storage key of the snapshot file
checksum TEXT NOT NULL,
queries INT NOT NULL,
status TEXT NOT NULL, -- building, validated, live, rolled_back
built_at TIMESTAMPTZ NOT NULL
);
-- Blocked words and removed suggestions (small, PostgreSQL, edited by staff).
CREATE TABLE blocked_terms (
term TEXT PRIMARY KEY,
reason TEXT NOT NULL,
added_by BIGINT NOT NULL,
added_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
- The log is append-only and partitioned by time, so writing 10,000 events a second is cheap and an hour's data is one directory to read.
- Counts are recomputed, not updated in place: a batch reads the previous counts plus the new hour and writes a fresh set. No row is ever contended.
trie_versionsis the control plane: servers poll it (or get notified), download the file for their shard, verify the checksum and swap. Rolling back is setting an older row back tolive.- Replication and partitioning: the log is replicated by Kafka and object storage; the trie is "replicated" by loading the same file on every server; shards are prefix ranges (see Scaling). The small PostgreSQL tables need only a standby.
Visual architecture
| Component | Why it's here | Connections |
|---|---|---|
| Browser | Debounces, cancels stale requests, caches answers for the session | → CDN / LB with GET /v1/suggest |
| CDN edge (in the final design) | Answers the most repeated short prefixes close to the user | caches by URL for 60 s |
| Load balancer | Spreads requests over suggest servers, removes unhealthy ones | → suggest servers |
| Suggest servers | Hold the whole trie in RAM; answer a prefix with a pointer walk | read-only; load snapshots |
| Prefix cache | Skips the walk for repeated prefixes; key = version:prefix | in-process LRU first; a shared Redis only when answers are expensive to compute |
| Search API | Logs a search event when the user submits a query | → query log |
| Query log | Durable, append-only stream of search events | → aggregator |
| Aggregator | Hourly batch: counts searches, halves old scores, applies blocklist and privacy threshold | → trie builder |
| Trie builder | Builds the trie and every node's top 10, validates it | → snapshot store |
| Snapshot store | Versioned trie files in object storage | → suggest servers download |
On the prefix cache: a trie walk in RAM is already microseconds, so a shared Redis in front of it would add a network hop. Our cache here is an in-process LRU that saves the walk and the JSON encoding for hot prefixes. A shared Redis becomes worth it later, when an answer has to be merged from several shards or mixed with personal history. That's the caching strategy: browser first, CDN second, in-process third, Redis only for expensive merged answers.
We deliberately don't have: a database on the read path, any write on the read path, or a stream processor on day one (hourly batches meet the freshness requirement).
Request lifecycle
Typing a prefix (cache miss)
- User types
d,h,a. The browser waits ~100 ms after the last key, so it sends only one request:GET /v1/suggest?q=dha. If a request fordhis still in flight, it's cancelled. - Browser cache, then CDN: if anyone nearby asked for
dhain the last 60 seconds, the edge answers and our servers never see it. - LB → any suggest server. The server normalises the prefix (lowercase, single spaces, max 50 characters; a trailing space is kept because
dhakaanddhakaare different prefixes). - It reads the current trie pointer once (version 1) and asks the cache for
1:dha. Miss. - It walks
d → h → ain the trie: three steps. The top-10 list is already stored at that node, so there's nothing to search or sort. - It stores the list under
1:dhaand returns200withCache-Control: public, max-age=60. Total server time: well under a millisecond.
A search being counted (the offline path)
- The user presses Enter on "dhaka metro rail". The search service logs
{event_id, query, lang, user_hash, ts}to the query log. The search itself doesn't wait for this. - Every hour, the aggregator reads the last hour of events, drops duplicate
event_ids, normalises queries and counts them. - It merges with the previous scores:
score = old_score / 2 + new_count(exponential decay, so last week fades and a new trend can overtake it), removes blocked words and queries below the 20-distinct-users threshold. - The builder makes a new trie, computes every node's top 10 bottom-up, and runs validation (size within ±20% of the last build, a set of "golden" prefixes still return sane results, no blocked terms).
- The snapshot file goes to object storage with a checksum; a
trie_versionsrow marks itvalidated, thenlive. - Each suggest server downloads the file, loads it into memory next to the old trie, and swaps one pointer. Requests already running finish on the old trie; new ones use the new one. Old cache entries have the old version in their key, so they're never served again and simply age out.
Component deep dive
The trie with top-K at every node
A plain trie answers "which queries start with d?" by walking the whole subtree under d: millions of nodes. We don't do that at request time. Instead, while building offline, each node stores the best K queries in its subtree. They're computed bottom-up: a node's list is its own query (if one ends there) merged with its children's lists, sorted, cut to K. Children's lists are already the best of their subtrees, so the merge only looks at K × children items, not the whole subtree.
| Without precomputed lists | With top-K at each node | |
|---|---|---|
| Lookup | walk prefix + scan the whole subtree + sort | walk prefix, return list |
| Cost for "d" | millions of nodes | 1 step |
| Cost in general | O(subtree size) | O(prefix length) |
| Extra memory | none | K entries per node (store query IDs, not strings) |
| Updating one count | cheap | must refresh lists on the path to the root, so we rebuild in batches instead |
Memory tricks for production: store 4-byte query IDs instead of strings in the lists; compress chains of single-child nodes (a radix tree); only keep lists on nodes up to some depth and fall back to a small scan below it; or use a finite-state transducer (what Lucene's suggesters use), which shares suffixes as well as prefixes.
Immutable snapshots and the atomic swap
The trie is never edited in place. Each build produces a complete new trie; a server loads it alongside the current one and then swaps a single pointer. That gives us three things for free:
- No locks on the read path: readers never see a half-updated trie, because nobody updates it.
- Consistent answers: a request reads the pointer once at the start, so its whole answer comes from one version (the Go test
TestReadersDuringSwapschecks exactly this under-race). - Instant rollback: the previous file is still in object storage; swapping back is the same operation.
The cost is memory: during a swap a server briefly holds two tries (2 × 5 GB). Size servers for that, or load shards one at a time.
Versioned cache keys
If the cache key were just the prefix, then after a swap the cache would keep serving the old list until each entry expired. Purging every key across servers on each swap is slow and racy. Instead the key contains the trie version: 412:dha. After the swap to 413, every lookup uses 413:... keys, so old entries are simply never hit again and the LRU evicts them. The CDN can't use our version in its key (it caches by URL), so there we accept up to max-age (60 s) of staleness, which the freshness requirement allows.
Counting with decay, and trends
The batch computes score = old_score / 2 + this_hour (in production the halving is tuned, e.g. a half-life of a few days). Without decay, last year's "eid ul adha 2025" would beat this week's news forever. With it, a query that suddenly gets searched a lot overtakes old favourites within one or two builds. The demo shows exactly this: "dhaka metro rail" goes from 1 search to the top of d after one day of heavy searching.
For "breaking news in 15 minutes", hourly batches aren't enough. The final design adds a small stream job that keeps counts for the last 15 minutes, finds queries rising much faster than usual, and pushes a short "boost list" to the servers, which merge it into answers until the next batch absorbs it. It's a separate, simpler path so the main build stays boring and reliable.
The core, runnable
This program is the heart of the design, small enough to read in one sitting: normalisation, an aggregator that counts submitted searches, a merge with decay and a blocklist, a trie that precomputes the top K of every prefix, an LRU cache keyed by version:prefix, and a service that answers keystrokes and swaps in new tries atomically.
autocomplete/
├── go.mod
├── trie.go Trie: Build (with top K per node), Top
├── aggregate.go Normalize, Aggregator, Merge (decay + blocklist)
├── cache.go LRU cache of version:prefix -> suggestions
├── service.go Service: Suggest, Publish (atomic swap)
├── autocomplete_test.go top K vs brute force, swaps under -race, versioned cache, merge
└── cmd/demo/main.go two days of searches, one user typing, a rebuild and a swap
// trie.go
package autocomplete
import "sort"
// Suggestion is one query and how often people searched for it.
type Suggestion struct {
Query string
Count int
}
// better orders suggestions: more searches first, then alphabetically, so every build and
// every language gives the same list.
func better(a, b Suggestion) bool {
if a.Count != b.Count {
return a.Count > b.Count
}
return a.Query < b.Query
}
type node struct {
kids map[byte]*node
count int // > 0 when a whole query ends here
top []Suggestion // the best k queries that start with this prefix, precomputed
}
// Trie is a read-only snapshot built offline from search counts. Every node already knows
// its top k, so a lookup is just "walk the prefix, return the list": no searching at query time.
type Trie struct {
root *node
k int
Version int
Queries int
Nodes int
}
// Build makes a trie from query counts and fills in the top k of every prefix.
func Build(version int, counts map[string]int, k int) *Trie {
t := &Trie{root: &node{kids: map[byte]*node{}}, k: k, Version: version, Nodes: 1}
for q, c := range counts {
if c <= 0 || q == "" {
continue
}
n := t.root
for i := 0; i < len(q); i++ {
next, ok := n.kids[q[i]]
if !ok {
next = &node{kids: map[byte]*node{}}
n.kids[q[i]] = next
t.Nodes++
}
n = next
}
n.count = c
t.Queries++
}
t.fill(t.root, nil)
return t
}
// fill computes top k bottom-up: a node's list is its own query plus its children's lists.
func (t *Trie) fill(n *node, path []byte) {
var all []Suggestion
if n.count > 0 {
all = append(all, Suggestion{string(path), n.count})
}
for c, kid := range n.kids {
t.fill(kid, append(path, c))
all = append(all, kid.top...)
}
sort.Slice(all, func(i, j int) bool { return better(all[i], all[j]) })
if len(all) > t.k {
all = all[:t.k]
}
n.top = all
}
// Top returns the precomputed best k for a prefix (nil if nobody searched it). The slice
// belongs to the snapshot: callers must not change it.
func (t *Trie) Top(prefix string) []Suggestion {
n := t.root
for i := 0; i < len(prefix); i++ {
if n = n.kids[prefix[i]]; n == nil {
return nil
}
}
return n.top
}
// aggregate.go
package autocomplete
import (
"strings"
"sync"
)
// MaxLen is the longest query we store or look up, in bytes.
const MaxLen = 50
// Normalize makes " Dhaka WEATHER " and "dhaka weather" the same query: lowercase, single
// spaces, trimmed, at most MaxLen bytes. (This demo uses ASCII; production also normalises
// Unicode, and Bangla queries work the same way.)
func Normalize(s string) string {
s = strings.Join(strings.Fields(strings.ToLower(s)), " ")
if len(s) > MaxLen {
s = strings.TrimRight(s[:MaxLen], " ")
}
return s
}
// Aggregator counts searches as they happen, like the log pipeline that feeds the builder.
type Aggregator struct {
mu sync.Mutex
counts map[string]int
Seen int
}
func NewAggregator() *Aggregator { return &Aggregator{counts: map[string]int{}} }
// Add records one search that the user actually submitted.
func (a *Aggregator) Add(raw string) {
q := Normalize(raw)
if q == "" {
return
}
a.mu.Lock()
defer a.mu.Unlock()
a.counts[q]++
a.Seen++
}
// Drain hands over the counts collected so far and starts a new period.
func (a *Aggregator) Drain() map[string]int {
a.mu.Lock()
defer a.mu.Unlock()
out := a.counts
a.counts = map[string]int{}
a.Seen = 0
return out
}
// Merge prepares the next build: old counts are halved (so last week fades and new trends can
// win), this period's counts are added, and queries with a blocked word are removed.
func Merge(old, recent map[string]int, blocked map[string]bool) (counts map[string]int, removed int) {
counts = map[string]int{}
for q, c := range old {
if c/2 > 0 {
counts[q] = c / 2
}
}
for q, c := range recent {
counts[q] += c
}
for q := range counts {
for _, w := range strings.Fields(q) {
if blocked[w] {
delete(counts, q)
removed++
break
}
}
}
return counts, removed
}
// cache.go
package autocomplete
import (
"container/list"
"sync"
)
// Cache is a small LRU cache of "version:prefix" -> suggestions, like Redis (or a CDN edge)
// in front of the trie servers.
type Cache struct {
mu sync.Mutex
cap int
order *list.List // front = most recently used
items map[string]*list.Element
Hits, Misses int
}
type entry struct {
key string
list []Suggestion
}
func NewCache(capacity int) *Cache {
return &Cache{cap: capacity, order: list.New(), items: map[string]*list.Element{}}
}
func (c *Cache) Get(key string) ([]Suggestion, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.items[key]
if !ok {
c.Misses++
return nil, false
}
c.Hits++
c.order.MoveToFront(e)
return e.Value.(entry).list, true
}
func (c *Cache) Put(key string, list []Suggestion) {
c.mu.Lock()
defer c.mu.Unlock()
if e, ok := c.items[key]; ok {
c.order.MoveToFront(e)
return
}
if c.order.Len() == c.cap {
old := c.order.Back()
c.order.Remove(old)
delete(c.items, old.Value.(entry).key)
}
c.items[key] = c.order.PushFront(entry{key, list})
}
// service.go
// Package autocomplete is the core of a search autocomplete service: searches are counted,
// a trie with the top k per prefix is built offline, swapped in atomically, and served
// through a cache whose keys carry the trie's version.
package autocomplete
import (
"strconv"
"strings"
"sync/atomic"
)
// Service is one suggestion server. Lookups never lock the trie: it is read-only, and a new
// build replaces it with one atomic pointer swap.
type Service struct {
trie atomic.Pointer[Trie]
cache *Cache
lookups atomic.Int64 // requests that reached the trie (cache misses)
}
func NewService(t *Trie, cache *Cache) *Service {
s := &Service{cache: cache}
s.trie.Store(t)
return s
}
// Publish swaps in a freshly built trie. Requests already running finish on the old one.
func (s *Service) Publish(t *Trie) { s.trie.Store(t) }
// Version is the version of the trie being served now.
func (s *Service) Version() int { return s.trie.Load().Version }
// Lookups is how many requests missed the cache and walked the trie.
func (s *Service) Lookups() int64 { return s.lookups.Load() }
// Suggest answers one keystroke. The prefix is normalised like stored queries, except that a
// trailing space is kept ("dhaka " should not suggest "dhakaiya"). The cache key includes the
// version, so after a swap old answers are never served; they simply age out of the LRU.
func (s *Service) Suggest(raw string) []Suggestion {
p := Normalize(raw)
if p == "" {
return nil
}
if strings.HasSuffix(raw, " ") && len(p) < MaxLen {
p += " "
}
t := s.trie.Load() // one snapshot for the whole request
key := strconv.Itoa(t.Version) + ":" + p
if list, ok := s.cache.Get(key); ok {
return list
}
s.lookups.Add(1)
list := t.Top(p)
s.cache.Put(key, list)
return list
}
// cmd/demo/main.go
// Command demo counts a day of searches, builds a trie, serves keystrokes through a cache,
// then counts a second day, rebuilds and swaps the new trie in.
package main
import (
"fmt"
"strings"
"autocomplete"
)
func show(s *autocomplete.Service, typed string) {
list := s.Suggest(typed)
parts := []string{}
for _, x := range list {
parts = append(parts, fmt.Sprintf("%s %d", x.Query, x.Count))
}
if len(parts) == 0 {
parts = append(parts, "(no suggestions)")
}
fmt.Printf(" %-12s -> %s\n", `"`+typed+`"`, strings.Join(parts, " | "))
}
func searches(a *autocomplete.Aggregator, q string, times int) {
for i := 0; i < times; i++ {
a.Add(q)
}
}
func main() {
blocked := map[string]bool{"fake": true}
agg := autocomplete.NewAggregator()
// Day 1: what people submitted, messy spelling and all.
searches(agg, "dhaka weather", 3)
searches(agg, "Dhaka Weather", 1)
searches(agg, " dhaka weather ", 1)
searches(agg, "dhakaiya biryani", 4)
searches(agg, "dhaka fake certificate", 4)
searches(agg, "dhaka traffic", 3)
searches(agg, "dhaka to chittagong bus", 2)
searches(agg, "dal recipe", 2)
searches(agg, "dhaka metro rail", 1)
searches(agg, "eid sale", 3)
searches(agg, "egg price", 1)
fmt.Printf("day 1: %d searches logged\n", agg.Seen)
counts, removed := autocomplete.Merge(nil, agg.Drain(), blocked)
t1 := autocomplete.Build(1, counts, 3)
fmt.Printf("build v1: %d queries (%d blocked), %d trie nodes, top 3 kept per prefix\n", t1.Queries, removed, t1.Nodes)
cache := autocomplete.NewCache(8)
s := autocomplete.NewService(t1, cache)
fmt.Println("user 1 types \"dhaka m\", one request per keystroke:")
for _, p := range []string{"d", "dh", "dha", "dhak", "dhaka", "dhaka ", "dhaka m"} {
show(s, p)
}
fmt.Println("user 2 and a few others:")
for _, p := range []string{"d", "dh", "dha", " DHAKA ", "e", "xyz"} {
show(s, p)
}
fmt.Printf("cache: %d hits, %d misses; trie lookups: %d\n", cache.Hits, cache.Misses, s.Lookups())
// Day 2: a new metro line opens and everyone searches for it.
searches(agg, "dhaka metro rail", 9)
searches(agg, "dhaka weather", 1)
searches(agg, "eid sale", 2)
fmt.Printf("day 2: %d searches logged\n", agg.Seen)
counts, _ = autocomplete.Merge(counts, agg.Drain(), blocked)
t2 := autocomplete.Build(2, counts, 3)
s.Publish(t2)
fmt.Printf("build v2: old counts halved + day 2 added -> %d queries; now serving v%d\n", t2.Queries, s.Version())
for _, p := range []string{"d", "dhaka ", "e"} {
show(s, p)
}
fmt.Printf("cache: %d hits, %d misses; trie lookups: %d\n", cache.Hits, cache.Misses, s.Lookups())
}
Running the demo:
$ go run ./cmd/demo
day 1: 25 searches logged
build v1: 8 queries (1 blocked), 83 trie nodes, top 3 kept per prefix
user 1 types "dhaka m", one request per keystroke:
"d" -> dhaka weather 5 | dhakaiya biryani 4 | dhaka traffic 3
"dh" -> dhaka weather 5 | dhakaiya biryani 4 | dhaka traffic 3
"dha" -> dhaka weather 5 | dhakaiya biryani 4 | dhaka traffic 3
"dhak" -> dhaka weather 5 | dhakaiya biryani 4 | dhaka traffic 3
"dhaka" -> dhaka weather 5 | dhakaiya biryani 4 | dhaka traffic 3
"dhaka " -> dhaka weather 5 | dhaka traffic 3 | dhaka to chittagong bus 2
"dhaka m" -> dhaka metro rail 1
user 2 and a few others:
"d" -> dhaka weather 5 | dhakaiya biryani 4 | dhaka traffic 3
"dh" -> dhaka weather 5 | dhakaiya biryani 4 | dhaka traffic 3
"dha" -> dhaka weather 5 | dhakaiya biryani 4 | dhaka traffic 3
" DHAKA " -> dhaka weather 5 | dhaka traffic 3 | dhaka to chittagong bus 2
"e" -> eid sale 3 | egg price 1
"xyz" -> (no suggestions)
cache: 4 hits, 9 misses; trie lookups: 9
day 2: 12 searches logged
build v2: old counts halved + day 2 added -> 7 queries; now serving v2
"d" -> dhaka metro rail 9 | dhaka weather 3 | dhakaiya biryani 2
"dhaka " -> dhaka metro rail 9 | dhaka weather 3 | dhaka to chittagong bus 1
"e" -> eid sale 3
cache: 4 hits, 12 misses; trie lookups: 12
Reading the output:
- Day 1 and the build: 25 searches were logged, but "Dhaka Weather" and " dhaka weather " were normalised into "dhaka weather", so they count as one query with 5 searches. "dhaka fake certificate" was searched 4 times and would have been the second suggestion under
dhaka; the blocklist removed it before the build. 8 queries made a trie of 83 nodes. - Typing "dhaka m":
dtodhakaall show the same three suggestions: "dhaka weather" (5), "dhakaiya biryani" (4), "dhaka traffic" (3). That's right: those are the best queries under every one of those prefixes. The moment a space is typed,dhakadrops "dhakaiya biryani" (it doesn't start with "dhaka ") and "dhaka to chittagong bus" moves in.dhaka mleaves only "dhaka metro rail". - User 2:
d,dh,dhaare cache hits: the first user already asked for them.DHAKAnormalises todhaka(the trailing space is kept) and is a hit too.xyzreturns nothing, which is cached as well, so a bot can't hammer the trie with junk prefixes. - Cache numbers: 4 hits and 9 misses so far; only the misses walked the trie.
- Day 2: a new metro line opens and "dhaka metro rail" is searched 9 times. The merge halves day 1 (weather 5 → 2, plus 1 new = 3) and adds day 2. "egg price" (1 → 0) disappears. In v2 "dhaka metro rail" (9) tops both
danddhaka. - After the swap:
d,dhakaandeare misses even though they were cached a minute ago, because the keys are now2:d, not1:d. Nobody sees a stale list, and nobody had to purge anything.
The tests check what matters most: the precomputed top K equals a brute-force answer for random data, normalisation and the trailing space, the merge's decay and blocklist, that a swap never serves an old cached list, that readers running during 49 swaps always get an answer from exactly one version (run with -race), and that concurrent counting doesn't lose searches. Replacing the atomic pointer with a plain one makes -race report a data race in the swap test, so the test really guards the swap.
$ go test -race -v ./...
=== RUN TestTopKMatchesBruteForce
--- PASS: TestTopKMatchesBruteForce (0.00s)
=== RUN TestNormalize
--- PASS: TestNormalize (0.00s)
=== RUN TestMergeDecaysAndBlocks
--- PASS: TestMergeDecaysAndBlocks (0.00s)
=== RUN TestSwapNeverServesOldCacheEntries
--- PASS: TestSwapNeverServesOldCacheEntries (0.00s)
=== RUN TestTrailingSpaceMatters
--- PASS: TestTrailingSpaceMatters (0.00s)
=== RUN TestReadersDuringSwaps
--- PASS: TestReadersDuringSwaps (0.00s)
=== RUN TestAggregatorConcurrentAdds
--- PASS: TestAggregatorConcurrentAdds (0.01s)
PASS
ok autocomplete 1.022s
? autocomplete/cmd/demo [no test files]
The same core in five more languages
Single-threaded versions with the same classes and the same output. The swap is still one assignment of a reference; what changes is how each language makes that safe when threads are involved:
- Python: assigning
self.trie = new_trieis atomic under the GIL, and a request keeps its own referencet, so the old trie lives until that request finishes. - TypeScript: Node runs requests on one event loop, so the swap can't interleave with a lookup. (The class is called
SuggestCachebecauseCacheis a browser global.) - Rust: the service holds an
Rc<Trie>and each request clones it. With threads you'd useArc<Trie>behind an atomic swap (for example thearc-swapcrate), and the compiler would refuse the non-thread-safe version. - Java:
AtomicReference<Trie>is the direct equivalent of Go'satomic.Pointer, andLinkedHashMapin access order is a ready-made LRU. - C++: the service holds a
std::shared_ptr<const Trie>; each request copies it, which keeps the old trie alive. With threads, usestd::atomic<std::shared_ptr<const Trie>>(C++20) for the swap.
"""Search autocomplete's core: searches are counted, a trie with the top k per prefix is built
offline, swapped in as a whole, and served through a cache whose keys carry the trie's version."""
from collections import OrderedDict
from dataclasses import dataclass, field
MAX_LEN = 50
@dataclass(frozen=True)
class Suggestion:
query: str
count: int
def rank(s: Suggestion) -> tuple[int, str]:
"""More searches first, then alphabetical: the same order in every language."""
return (-s.count, s.query)
@dataclass
class Node:
kids: dict[str, "Node"] = field(default_factory=dict)
count: int = 0 # > 0 when a whole query ends here
top: list[Suggestion] = field(default_factory=list) # best k with this prefix, precomputed
class Trie:
"""A read-only snapshot built offline. Every node already knows its top k."""
def __init__(self, version: int, counts: dict[str, int], k: int):
self.root, self.k, self.version = Node(), k, version
self.queries, self.nodes = 0, 1
for q, c in counts.items():
if c <= 0 or not q:
continue
n = self.root
for ch in q:
if ch not in n.kids:
n.kids[ch] = Node()
self.nodes += 1
n = n.kids[ch]
n.count = c
self.queries += 1
self._fill(self.root, "")
def _fill(self, n: Node, path: str) -> None:
"""Bottom-up: a node's list is its own query plus its children's lists."""
everything = [Suggestion(path, n.count)] if n.count > 0 else []
for ch, kid in n.kids.items():
self._fill(kid, path + ch)
everything += kid.top
n.top = sorted(everything, key=rank)[: self.k]
def top(self, prefix: str) -> list[Suggestion]:
n = self.root
for ch in prefix:
n = n.kids.get(ch)
if n is None:
return []
return n.top
def normalize(s: str) -> str:
"""Lowercase, single spaces, trimmed, at most MAX_LEN characters."""
s = " ".join(s.lower().split())
return s[:MAX_LEN].rstrip(" ")
class Aggregator:
"""Counts submitted searches, like the log pipeline that feeds the builder."""
def __init__(self):
self.counts: dict[str, int] = {}
self.seen = 0
def add(self, raw: str) -> None:
q = normalize(raw)
if q:
self.counts[q] = self.counts.get(q, 0) + 1
self.seen += 1
def drain(self) -> dict[str, int]:
out, self.counts, self.seen = self.counts, {}, 0
return out
def merge(old: dict[str, int], recent: dict[str, int], blocked: set[str]) -> tuple[dict[str, int], int]:
"""Old counts halved, recent counts added, queries with a blocked word removed."""
counts = {q: c // 2 for q, c in old.items() if c // 2 > 0}
for q, c in recent.items():
counts[q] = counts.get(q, 0) + c
kept = {q: c for q, c in counts.items() if not any(w in blocked for w in q.split())}
return kept, len(counts) - len(kept)
class Cache:
"""LRU cache of "version:prefix" -> suggestions, like Redis in front of the trie servers."""
def __init__(self, capacity: int):
self.cap, self.items = capacity, OrderedDict()
self.hits = self.misses = 0
def get(self, key: str) -> list[Suggestion] | None:
if key not in self.items:
self.misses += 1
return None
self.hits += 1
self.items.move_to_end(key)
return self.items[key]
def put(self, key: str, value: list[Suggestion]) -> None:
if key in self.items:
self.items.move_to_end(key)
return
if len(self.items) == self.cap:
self.items.popitem(last=False)
self.items[key] = value
class Service:
"""One suggestion server. A new build replaces the trie in one assignment."""
def __init__(self, trie: Trie, cache: Cache):
self.trie, self.cache, self.lookups = trie, cache, 0
def publish(self, trie: Trie) -> None:
self.trie = trie
def suggest(self, raw: str) -> list[Suggestion]:
p = normalize(raw)
if not p:
return []
if raw.endswith(" ") and len(p) < MAX_LEN:
p += " " # "dhaka " must not suggest "dhakaiya"
t = self.trie # one snapshot for the whole request
key = f"{t.version}:{p}"
cached = self.cache.get(key)
if cached is not None:
return cached
self.lookups += 1
found = t.top(p)
self.cache.put(key, found)
return found
def show(s: Service, typed: str) -> None:
parts = [f"{x.query} {x.count}" for x in s.suggest(typed)] or ["(no suggestions)"]
label = '"' + typed + '"'
print(f" {label:<12} -> {' | '.join(parts)}")
def searches(a: Aggregator, q: str, times: int) -> None:
for _ in range(times):
a.add(q)
def main() -> None:
blocked = {"fake"}
agg = Aggregator()
# Day 1: what people submitted, messy spelling and all.
searches(agg, "dhaka weather", 3)
searches(agg, "Dhaka Weather", 1)
searches(agg, " dhaka weather ", 1)
searches(agg, "dhakaiya biryani", 4)
searches(agg, "dhaka fake certificate", 4)
searches(agg, "dhaka traffic", 3)
searches(agg, "dhaka to chittagong bus", 2)
searches(agg, "dal recipe", 2)
searches(agg, "dhaka metro rail", 1)
searches(agg, "eid sale", 3)
searches(agg, "egg price", 1)
print(f"day 1: {agg.seen} searches logged")
counts, removed = merge({}, agg.drain(), blocked)
t1 = Trie(1, counts, 3)
print(f"build v1: {t1.queries} queries ({removed} blocked), {t1.nodes} trie nodes, top 3 kept per prefix")
cache = Cache(8)
s = Service(t1, cache)
print('user 1 types "dhaka m", one request per keystroke:')
for p in ["d", "dh", "dha", "dhak", "dhaka", "dhaka ", "dhaka m"]:
show(s, p)
print("user 2 and a few others:")
for p in ["d", "dh", "dha", " DHAKA ", "e", "xyz"]:
show(s, p)
print(f"cache: {cache.hits} hits, {cache.misses} misses; trie lookups: {s.lookups}")
# Day 2: a new metro line opens and everyone searches for it.
searches(agg, "dhaka metro rail", 9)
searches(agg, "dhaka weather", 1)
searches(agg, "eid sale", 2)
print(f"day 2: {agg.seen} searches logged")
counts, _ = merge(counts, agg.drain(), blocked)
t2 = Trie(2, counts, 3)
s.publish(t2)
print(f"build v2: old counts halved + day 2 added -> {t2.queries} queries; now serving v{s.trie.version}")
for p in ["d", "dhaka ", "e"]:
show(s, p)
print(f"cache: {cache.hits} hits, {cache.misses} misses; trie lookups: {s.lookups}")
if __name__ == "__main__":
main()// Search autocomplete's core: searches are counted, a trie with the top k per prefix is built
// offline, swapped in as a whole, and served through a cache whose keys carry the trie's version.
const MAX_LEN = 50;
type Suggestion = { query: string; count: number };
/** More searches first, then alphabetical: the same order in every language. */
function better(a: Suggestion, b: Suggestion): number {
if (a.count !== b.count) return b.count - a.count;
return a.query < b.query ? -1 : a.query > b.query ? 1 : 0;
}
class TrieNode {
kids = new Map<string, TrieNode>();
count = 0; // > 0 when a whole query ends here
top: Suggestion[] = []; // best k with this prefix, precomputed
}
/** A read-only snapshot built offline. Every node already knows its top k. */
class Trie {
root = new TrieNode();
queries = 0;
nodes = 1;
readonly version: number;
readonly k: number;
constructor(version: number, counts: Map<string, number>, k: number) {
this.version = version;
this.k = k;
for (const [q, c] of counts) {
if (c <= 0 || q === "") continue;
let n = this.root;
for (const ch of q) {
let next = n.kids.get(ch);
if (!next) {
next = new TrieNode();
n.kids.set(ch, next);
this.nodes++;
}
n = next;
}
n.count = c;
this.queries++;
}
this.fill(this.root, "");
}
/** Bottom-up: a node's list is its own query plus its children's lists. */
private fill(n: TrieNode, path: string): void {
const all: Suggestion[] = n.count > 0 ? [{ query: path, count: n.count }] : [];
for (const [ch, kid] of n.kids) {
this.fill(kid, path + ch);
all.push(...kid.top);
}
n.top = all.sort(better).slice(0, this.k);
}
top(prefix: string): Suggestion[] {
let n: TrieNode | undefined = this.root;
for (const ch of prefix) {
n = n.kids.get(ch);
if (!n) return [];
}
return n.top;
}
}
/** Lowercase, single spaces, trimmed, at most MAX_LEN characters. */
function normalize(s: string): string {
const one = s.toLowerCase().split(/\s+/).filter((w) => w !== "").join(" ");
return one.slice(0, MAX_LEN).replace(/ +$/, "");
}
/** Counts submitted searches, like the log pipeline that feeds the builder. */
class Aggregator {
counts = new Map<string, number>();
seen = 0;
add(raw: string): void {
const q = normalize(raw);
if (q === "") return;
this.counts.set(q, (this.counts.get(q) ?? 0) + 1);
this.seen++;
}
drain(): Map<string, number> {
const out = this.counts;
this.counts = new Map();
this.seen = 0;
return out;
}
}
/** Old counts halved, recent counts added, queries with a blocked word removed. */
function merge(old: Map<string, number>, recent: Map<string, number>, blocked: Set<string>): [Map<string, number>, number] {
const counts = new Map<string, number>();
for (const [q, c] of old) if (Math.floor(c / 2) > 0) counts.set(q, Math.floor(c / 2));
for (const [q, c] of recent) counts.set(q, (counts.get(q) ?? 0) + c);
let removed = 0;
for (const q of [...counts.keys()]) {
if (q.split(" ").some((w) => blocked.has(w))) {
counts.delete(q);
removed++;
}
}
return [counts, removed];
}
/** LRU cache of "version:prefix" -> suggestions, like Redis in front of the trie servers.
* (Not called Cache: the DOM already has a global with that name.) */
class SuggestCache {
items = new Map<string, Suggestion[]>(); // insertion order = least recently used first
hits = 0;
misses = 0;
readonly cap: number;
constructor(capacity: number) {
this.cap = capacity;
}
get(key: string): Suggestion[] | undefined {
const v = this.items.get(key);
if (v === undefined) {
this.misses++;
return undefined;
}
this.hits++;
this.items.delete(key);
this.items.set(key, v);
return v;
}
put(key: string, value: Suggestion[]): void {
if (this.items.has(key)) {
this.items.delete(key);
this.items.set(key, value);
return;
}
if (this.items.size === this.cap) this.items.delete(this.items.keys().next().value!);
this.items.set(key, value);
}
}
/** One suggestion server. A new build replaces the trie in one assignment. */
class Service {
trie: Trie;
lookups = 0;
readonly cache: SuggestCache;
constructor(trie: Trie, cache: SuggestCache) {
this.trie = trie;
this.cache = cache;
}
publish(trie: Trie): void {
this.trie = trie;
}
suggest(raw: string): Suggestion[] {
let p = normalize(raw);
if (p === "") return [];
if (raw.endsWith(" ") && p.length < MAX_LEN) p += " "; // "dhaka " must not suggest "dhakaiya"
const t = this.trie; // one snapshot for the whole request
const key = `${t.version}:${p}`;
const cached = this.cache.get(key);
if (cached !== undefined) return cached;
this.lookups++;
const found = t.top(p);
this.cache.put(key, found);
return found;
}
}
function show(s: Service, typed: string): void {
const parts = s.suggest(typed).map((x) => `${x.query} ${x.count}`);
if (parts.length === 0) parts.push("(no suggestions)");
console.log(` ${`"${typed}"`.padEnd(12)} -> ${parts.join(" | ")}`);
}
function searches(a: Aggregator, q: string, times: number): void {
for (let i = 0; i < times; i++) a.add(q);
}
const blocked = new Set(["fake"]);
const agg = new Aggregator();
// Day 1: what people submitted, messy spelling and all.
searches(agg, "dhaka weather", 3);
searches(agg, "Dhaka Weather", 1);
searches(agg, " dhaka weather ", 1);
searches(agg, "dhakaiya biryani", 4);
searches(agg, "dhaka fake certificate", 4);
searches(agg, "dhaka traffic", 3);
searches(agg, "dhaka to chittagong bus", 2);
searches(agg, "dal recipe", 2);
searches(agg, "dhaka metro rail", 1);
searches(agg, "eid sale", 3);
searches(agg, "egg price", 1);
console.log(`day 1: ${agg.seen} searches logged`);
let [counts, removed] = merge(new Map(), agg.drain(), blocked);
const t1 = new Trie(1, counts, 3);
console.log(`build v1: ${t1.queries} queries (${removed} blocked), ${t1.nodes} trie nodes, top 3 kept per prefix`);
const cache = new SuggestCache(8);
const s = new Service(t1, cache);
console.log('user 1 types "dhaka m", one request per keystroke:');
for (const p of ["d", "dh", "dha", "dhak", "dhaka", "dhaka ", "dhaka m"]) show(s, p);
console.log("user 2 and a few others:");
for (const p of ["d", "dh", "dha", " DHAKA ", "e", "xyz"]) show(s, p);
console.log(`cache: ${cache.hits} hits, ${cache.misses} misses; trie lookups: ${s.lookups}`);
// Day 2: a new metro line opens and everyone searches for it.
searches(agg, "dhaka metro rail", 9);
searches(agg, "dhaka weather", 1);
searches(agg, "eid sale", 2);
console.log(`day 2: ${agg.seen} searches logged`);
[counts] = merge(counts, agg.drain(), blocked);
const t2 = new Trie(2, counts, 3);
s.publish(t2);
console.log(`build v2: old counts halved + day 2 added -> ${t2.queries} queries; now serving v${s.trie.version}`);
for (const p of ["d", "dhaka ", "e"]) show(s, p);
console.log(`cache: ${cache.hits} hits, ${cache.misses} misses; trie lookups: ${s.lookups}`);// Search autocomplete's core: searches are counted, a trie with the top k per prefix is built
// offline, swapped in as a whole, and served through a cache whose keys carry the trie's version.
use std::collections::{HashMap, HashSet, VecDeque};
use std::rc::Rc;
const MAX_LEN: usize = 50;
#[derive(Clone, Debug, PartialEq)]
struct Suggestion {
query: String,
count: u32,
}
/// More searches first, then alphabetical: the same order in every language.
fn better(a: &Suggestion, b: &Suggestion) -> std::cmp::Ordering {
b.count.cmp(&a.count).then_with(|| a.query.cmp(&b.query))
}
#[derive(Default)]
struct Node {
kids: HashMap<u8, Node>,
count: u32, // > 0 when a whole query ends here
top: Vec<Suggestion>, // best k with this prefix, precomputed
}
/// A read-only snapshot built offline. Every node already knows its top k.
struct Trie {
root: Node,
version: u32,
queries: usize,
nodes: usize,
}
impl Trie {
fn build(version: u32, counts: &HashMap<String, u32>, k: usize) -> Trie {
let mut t = Trie { root: Node::default(), version, queries: 0, nodes: 1 };
for (q, &c) in counts {
if c == 0 || q.is_empty() {
continue;
}
let mut n = &mut t.root;
for &b in q.as_bytes() {
if !n.kids.contains_key(&b) {
t.nodes += 1;
}
n = n.kids.entry(b).or_default();
}
n.count = c;
t.queries += 1;
}
fill(&mut t.root, &mut Vec::new(), k);
t
}
fn top(&self, prefix: &str) -> &[Suggestion] {
let mut n = &self.root;
for b in prefix.as_bytes() {
match n.kids.get(b) {
Some(next) => n = next,
None => return &[],
}
}
&n.top
}
}
/// Bottom-up: a node's list is its own query plus its children's lists.
fn fill(n: &mut Node, path: &mut Vec<u8>, k: usize) {
let mut all = Vec::new();
if n.count > 0 {
all.push(Suggestion { query: String::from_utf8(path.clone()).unwrap(), count: n.count });
}
for (&b, kid) in n.kids.iter_mut() {
path.push(b);
fill(kid, path, k);
path.pop();
all.extend(kid.top.iter().cloned());
}
all.sort_by(better);
all.truncate(k);
n.top = all;
}
/// Lowercase, single spaces, trimmed, at most MAX_LEN bytes (ASCII in this demo).
fn normalize(s: &str) -> String {
let one = s.to_lowercase().split_whitespace().collect::<Vec<_>>().join(" ");
one[..one.len().min(MAX_LEN)].trim_end_matches(' ').to_string()
}
/// Counts submitted searches, like the log pipeline that feeds the builder.
#[derive(Default)]
struct Aggregator {
counts: HashMap<String, u32>,
seen: usize,
}
impl Aggregator {
fn add(&mut self, raw: &str) {
let q = normalize(raw);
if !q.is_empty() {
*self.counts.entry(q).or_insert(0) += 1;
self.seen += 1;
}
}
fn drain(&mut self) -> HashMap<String, u32> {
self.seen = 0;
std::mem::take(&mut self.counts)
}
}
/// Old counts halved, recent counts added, queries with a blocked word removed.
fn merge(old: &HashMap<String, u32>, recent: HashMap<String, u32>, blocked: &HashSet<&str>) -> (HashMap<String, u32>, usize) {
let mut counts: HashMap<String, u32> = old.iter().filter(|(_, &c)| c / 2 > 0).map(|(q, &c)| (q.clone(), c / 2)).collect();
for (q, c) in recent {
*counts.entry(q).or_insert(0) += c;
}
let before = counts.len();
counts.retain(|q, _| !q.split(' ').any(|w| blocked.contains(w)));
let removed = before - counts.len();
(counts, removed)
}
/// LRU cache of "version:prefix" -> suggestions, like Redis in front of the trie servers.
struct Cache {
cap: usize,
items: HashMap<String, Vec<Suggestion>>,
order: VecDeque<String>, // front = least recently used (fine for a tiny demo cache)
hits: usize,
misses: usize,
}
impl Cache {
fn new(cap: usize) -> Cache {
Cache { cap, items: HashMap::new(), order: VecDeque::new(), hits: 0, misses: 0 }
}
fn touch(&mut self, key: &str) {
self.order.retain(|k| k != key);
self.order.push_back(key.to_string());
}
fn get(&mut self, key: &str) -> Option<Vec<Suggestion>> {
match self.items.get(key).cloned() {
None => {
self.misses += 1;
None
}
Some(v) => {
self.hits += 1;
self.touch(key);
Some(v)
}
}
}
fn put(&mut self, key: &str, value: Vec<Suggestion>) {
if !self.items.contains_key(key) && self.items.len() == self.cap {
if let Some(old) = self.order.pop_front() {
self.items.remove(&old);
}
}
self.items.entry(key.to_string()).or_insert(value);
self.touch(key);
}
}
/// One suggestion server. A new build replaces the trie by swapping one shared pointer;
/// a request holds its own Rc, so it finishes on the trie it started with.
struct Service {
trie: Rc<Trie>,
cache: Cache,
lookups: usize,
}
impl Service {
fn publish(&mut self, t: Trie) {
self.trie = Rc::new(t);
}
fn suggest(&mut self, raw: &str) -> Vec<Suggestion> {
let mut p = normalize(raw);
if p.is_empty() {
return Vec::new();
}
if raw.ends_with(' ') && p.len() < MAX_LEN {
p.push(' '); // "dhaka " must not suggest "dhakaiya"
}
let t = Rc::clone(&self.trie); // one snapshot for the whole request
let key = format!("{}:{}", t.version, p);
if let Some(v) = self.cache.get(&key) {
return v;
}
self.lookups += 1;
let found = t.top(&p).to_vec();
self.cache.put(&key, found.clone());
found
}
}
fn show(s: &mut Service, typed: &str) {
let mut parts: Vec<String> = s.suggest(typed).iter().map(|x| format!("{} {}", x.query, x.count)).collect();
if parts.is_empty() {
parts.push("(no suggestions)".to_string());
}
println!(" {:<12} -> {}", format!("\"{}\"", typed), parts.join(" | "));
}
fn searches(a: &mut Aggregator, q: &str, times: u32) {
for _ in 0..times {
a.add(q);
}
}
fn main() {
let blocked: HashSet<&str> = ["fake"].into_iter().collect();
let mut agg = Aggregator::default();
// Day 1: what people submitted, messy spelling and all.
searches(&mut agg, "dhaka weather", 3);
searches(&mut agg, "Dhaka Weather", 1);
searches(&mut agg, " dhaka weather ", 1);
searches(&mut agg, "dhakaiya biryani", 4);
searches(&mut agg, "dhaka fake certificate", 4);
searches(&mut agg, "dhaka traffic", 3);
searches(&mut agg, "dhaka to chittagong bus", 2);
searches(&mut agg, "dal recipe", 2);
searches(&mut agg, "dhaka metro rail", 1);
searches(&mut agg, "eid sale", 3);
searches(&mut agg, "egg price", 1);
println!("day 1: {} searches logged", agg.seen);
let (counts, removed) = merge(&HashMap::new(), agg.drain(), &blocked);
let t1 = Trie::build(1, &counts, 3);
println!("build v1: {} queries ({} blocked), {} trie nodes, top 3 kept per prefix", t1.queries, removed, t1.nodes);
let mut s = Service { trie: Rc::new(t1), cache: Cache::new(8), lookups: 0 };
println!("user 1 types \"dhaka m\", one request per keystroke:");
for p in ["d", "dh", "dha", "dhak", "dhaka", "dhaka ", "dhaka m"] {
show(&mut s, p);
}
println!("user 2 and a few others:");
for p in ["d", "dh", "dha", " DHAKA ", "e", "xyz"] {
show(&mut s, p);
}
println!("cache: {} hits, {} misses; trie lookups: {}", s.cache.hits, s.cache.misses, s.lookups);
// Day 2: a new metro line opens and everyone searches for it.
searches(&mut agg, "dhaka metro rail", 9);
searches(&mut agg, "dhaka weather", 1);
searches(&mut agg, "eid sale", 2);
println!("day 2: {} searches logged", agg.seen);
let (counts, _) = merge(&counts, agg.drain(), &blocked);
let t2 = Trie::build(2, &counts, 3);
let queries = t2.queries;
s.publish(t2);
println!("build v2: old counts halved + day 2 added -> {} queries; now serving v{}", queries, s.trie.version);
for p in ["d", "dhaka ", "e"] {
show(&mut s, p);
}
println!("cache: {} hits, {} misses; trie lookups: {}", s.cache.hits, s.cache.misses, s.lookups);
}import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
// Search autocomplete's core: searches are counted, a trie with the top k per prefix is built
// offline, swapped in as a whole, and served through a cache whose keys carry the trie's version.
public class Autocomplete {
static final int MAX_LEN = 50;
record Suggestion(String query, int count) {}
/** More searches first, then alphabetical: the same order in every language. */
static final Comparator<Suggestion> BETTER =
Comparator.comparingInt((Suggestion s) -> -s.count()).thenComparing(Suggestion::query);
static final class Node {
final Map<Character, Node> kids = new HashMap<>();
int count; // > 0 when a whole query ends here
List<Suggestion> top = List.of(); // best k with this prefix, precomputed
}
/** A read-only snapshot built offline. Every node already knows its top k. */
static final class Trie {
final Node root = new Node();
final int version, k;
int queries, nodes = 1;
Trie(int version, Map<String, Integer> counts, int k) {
this.version = version;
this.k = k;
for (var e : counts.entrySet()) {
String q = e.getKey();
if (e.getValue() <= 0 || q.isEmpty()) continue;
Node n = root;
for (char c : q.toCharArray()) {
Node next = n.kids.get(c);
if (next == null) {
next = new Node();
n.kids.put(c, next);
nodes++;
}
n = next;
}
n.count = e.getValue();
queries++;
}
fill(root, "");
}
/** Bottom-up: a node's list is its own query plus its children's lists. */
private void fill(Node n, String path) {
List<Suggestion> all = new ArrayList<>();
if (n.count > 0) all.add(new Suggestion(path, n.count));
for (var e : n.kids.entrySet()) {
fill(e.getValue(), path + e.getKey());
all.addAll(e.getValue().top);
}
all.sort(BETTER);
n.top = List.copyOf(all.subList(0, Math.min(k, all.size())));
}
List<Suggestion> top(String prefix) {
Node n = root;
for (char c : prefix.toCharArray()) {
n = n.kids.get(c);
if (n == null) return List.of();
}
return n.top;
}
}
/** Lowercase, single spaces, trimmed, at most MAX_LEN characters. */
static String normalize(String s) {
String one = String.join(" ", Arrays.stream(s.toLowerCase().trim().split("\\s+")).filter(w -> !w.isEmpty()).toList());
return one.substring(0, Math.min(one.length(), MAX_LEN)).stripTrailing();
}
/** Counts submitted searches, like the log pipeline that feeds the builder. */
static final class Aggregator {
Map<String, Integer> counts = new HashMap<>();
int seen;
void add(String raw) {
String q = normalize(raw);
if (q.isEmpty()) return;
counts.merge(q, 1, Integer::sum);
seen++;
}
Map<String, Integer> drain() {
Map<String, Integer> out = counts;
counts = new HashMap<>();
seen = 0;
return out;
}
}
record Merged(Map<String, Integer> counts, int removed) {}
/** Old counts halved, recent counts added, queries with a blocked word removed. */
static Merged merge(Map<String, Integer> old, Map<String, Integer> recent, Set<String> blocked) {
Map<String, Integer> counts = new HashMap<>();
old.forEach((q, c) -> { if (c / 2 > 0) counts.put(q, c / 2); });
recent.forEach((q, c) -> counts.merge(q, c, Integer::sum));
int before = counts.size();
counts.keySet().removeIf(q -> Arrays.stream(q.split(" ")).anyMatch(blocked::contains));
return new Merged(counts, before - counts.size());
}
/** LRU cache of "version:prefix" -> suggestions, like Redis in front of the trie servers. */
static final class Cache {
final int cap;
int hits, misses;
final LinkedHashMap<String, List<Suggestion>> items = new LinkedHashMap<>(16, 0.75f, true);
Cache(int cap) { this.cap = cap; }
List<Suggestion> get(String key) {
List<Suggestion> v = items.get(key); // access order: get() moves it to the back
if (v == null) misses++; else hits++;
return v;
}
void put(String key, List<Suggestion> value) {
if (!items.containsKey(key) && items.size() == cap) items.remove(items.keySet().iterator().next());
items.putIfAbsent(key, value);
}
}
/** One suggestion server. A new build replaces the trie with one atomic swap. */
static final class Service {
final AtomicReference<Trie> trie;
final Cache cache;
int lookups;
Service(Trie t, Cache cache) {
this.trie = new AtomicReference<>(t);
this.cache = cache;
}
void publish(Trie t) { trie.set(t); }
List<Suggestion> suggest(String raw) {
String p = normalize(raw);
if (p.isEmpty()) return List.of();
if (raw.endsWith(" ") && p.length() < MAX_LEN) p += " "; // "dhaka " must not suggest "dhakaiya"
Trie t = trie.get(); // one snapshot for the whole request
String key = t.version + ":" + p;
List<Suggestion> cached = cache.get(key);
if (cached != null) return cached;
lookups++;
List<Suggestion> found = t.top(p);
cache.put(key, found);
return found;
}
}
static void show(Service s, String typed) {
List<String> parts = new ArrayList<>();
for (Suggestion x : s.suggest(typed)) parts.add(x.query() + " " + x.count());
if (parts.isEmpty()) parts.add("(no suggestions)");
System.out.printf(" %-12s -> %s%n", "\"" + typed + "\"", String.join(" | ", parts));
}
static void searches(Aggregator a, String q, int times) {
for (int i = 0; i < times; i++) a.add(q);
}
public static void main(String[] args) {
Set<String> blocked = Set.of("fake");
Aggregator agg = new Aggregator();
// Day 1: what people submitted, messy spelling and all.
searches(agg, "dhaka weather", 3);
searches(agg, "Dhaka Weather", 1);
searches(agg, " dhaka weather ", 1);
searches(agg, "dhakaiya biryani", 4);
searches(agg, "dhaka fake certificate", 4);
searches(agg, "dhaka traffic", 3);
searches(agg, "dhaka to chittagong bus", 2);
searches(agg, "dal recipe", 2);
searches(agg, "dhaka metro rail", 1);
searches(agg, "eid sale", 3);
searches(agg, "egg price", 1);
System.out.printf("day 1: %d searches logged%n", agg.seen);
Merged m = merge(Map.of(), agg.drain(), blocked);
Trie t1 = new Trie(1, m.counts(), 3);
System.out.printf("build v1: %d queries (%d blocked), %d trie nodes, top 3 kept per prefix%n", t1.queries, m.removed(), t1.nodes);
Cache cache = new Cache(8);
Service s = new Service(t1, cache);
System.out.println("user 1 types \"dhaka m\", one request per keystroke:");
for (String p : List.of("d", "dh", "dha", "dhak", "dhaka", "dhaka ", "dhaka m")) show(s, p);
System.out.println("user 2 and a few others:");
for (String p : List.of("d", "dh", "dha", " DHAKA ", "e", "xyz")) show(s, p);
System.out.printf("cache: %d hits, %d misses; trie lookups: %d%n", cache.hits, cache.misses, s.lookups);
// Day 2: a new metro line opens and everyone searches for it.
searches(agg, "dhaka metro rail", 9);
searches(agg, "dhaka weather", 1);
searches(agg, "eid sale", 2);
System.out.printf("day 2: %d searches logged%n", agg.seen);
Merged m2 = merge(m.counts(), agg.drain(), blocked);
Trie t2 = new Trie(2, m2.counts(), 3);
s.publish(t2);
System.out.printf("build v2: old counts halved + day 2 added -> %d queries; now serving v%d%n", t2.queries, s.trie.get().version);
for (String p : List.of("d", "dhaka ", "e")) show(s, p);
System.out.printf("cache: %d hits, %d misses; trie lookups: %d%n", cache.hits, cache.misses, s.lookups);
}
}// Search autocomplete's core: searches are counted, a trie with the top k per prefix is built
// offline, swapped in as a whole, and served through a cache whose keys carry the trie's version.
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <list>
#include <map>
#include <memory>
#include <set>
#include <sstream>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
constexpr size_t kMaxLen = 50;
struct Suggestion {
std::string query;
int count;
};
// More searches first, then alphabetical: the same order in every language.
bool better(const Suggestion& a, const Suggestion& b) {
if (a.count != b.count) return a.count > b.count;
return a.query < b.query;
}
struct Node {
std::unordered_map<char, std::unique_ptr<Node>> kids;
int count = 0; // > 0 when a whole query ends here
std::vector<Suggestion> top; // best k with this prefix, precomputed
};
using Counts = std::unordered_map<std::string, int>;
// A read-only snapshot built offline. Every node already knows its top k.
class Trie {
public:
Trie(int version, const Counts& counts, size_t k) : version(version), k_(k) {
for (const auto& [q, c] : counts) {
if (c <= 0 || q.empty()) continue;
Node* n = &root_;
for (char ch : q) {
auto& next = n->kids[ch];
if (!next) {
next = std::make_unique<Node>();
nodes++;
}
n = next.get();
}
n->count = c;
queries++;
}
std::string path;
fill(root_, path);
}
const std::vector<Suggestion>& top(const std::string& prefix) const {
static const std::vector<Suggestion> none;
const Node* n = &root_;
for (char ch : prefix) {
auto it = n->kids.find(ch);
if (it == n->kids.end()) return none;
n = it->second.get();
}
return n->top;
}
const int version;
int queries = 0, nodes = 1;
private:
// Bottom-up: a node's list is its own query plus its children's lists.
void fill(Node& n, std::string& path) {
std::vector<Suggestion> all;
if (n.count > 0) all.push_back({path, n.count});
for (auto& [ch, kid] : n.kids) {
path.push_back(ch);
fill(*kid, path);
path.pop_back();
all.insert(all.end(), kid->top.begin(), kid->top.end());
}
std::sort(all.begin(), all.end(), better);
if (all.size() > k_) all.resize(k_);
n.top = std::move(all);
}
Node root_;
size_t k_;
};
// Lowercase, single spaces, trimmed, at most kMaxLen bytes (ASCII in this demo).
std::string normalize(const std::string& s) {
std::istringstream in(s);
std::string word, out;
while (in >> word) out += (out.empty() ? "" : " ") + word;
for (char& c : out) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
if (out.size() > kMaxLen) out.resize(kMaxLen);
while (!out.empty() && out.back() == ' ') out.pop_back();
return out;
}
// Counts submitted searches, like the log pipeline that feeds the builder.
struct Aggregator {
Counts counts;
int seen = 0;
void add(const std::string& raw) {
std::string q = normalize(raw);
if (q.empty()) return;
counts[q]++;
seen++;
}
Counts drain() {
seen = 0;
return std::exchange(counts, {});
}
};
// Old counts halved, recent counts added, queries with a blocked word removed.
std::pair<Counts, int> merge(const Counts& old, const Counts& recent, const std::set<std::string>& blocked) {
Counts counts;
for (const auto& [q, c] : old)
if (c / 2 > 0) counts[q] = c / 2;
for (const auto& [q, c] : recent) counts[q] += c;
int removed = 0;
for (auto it = counts.begin(); it != counts.end();) {
std::istringstream words(it->first);
std::string w;
bool bad = false;
while (words >> w) bad = bad || blocked.count(w);
if (bad) {
it = counts.erase(it);
removed++;
} else {
++it;
}
}
return {counts, removed};
}
// LRU cache of "version:prefix" -> suggestions, like Redis in front of the trie servers.
class Cache {
public:
explicit Cache(size_t cap) : cap_(cap) {}
const std::vector<Suggestion>* get(const std::string& key) {
auto it = items_.find(key);
if (it == items_.end()) {
misses++;
return nullptr;
}
hits++;
order_.splice(order_.begin(), order_, it->second);
return &it->second->second;
}
void put(const std::string& key, const std::vector<Suggestion>& value) {
if (auto it = items_.find(key); it != items_.end()) {
order_.splice(order_.begin(), order_, it->second);
return;
}
if (items_.size() == cap_) {
items_.erase(order_.back().first);
order_.pop_back();
}
order_.emplace_front(key, value);
items_[key] = order_.begin();
}
int hits = 0, misses = 0;
private:
using Entry = std::pair<std::string, std::vector<Suggestion>>;
size_t cap_;
std::list<Entry> order_; // front = most recently used
std::unordered_map<std::string, std::list<Entry>::iterator> items_;
};
// One suggestion server. A new build replaces the trie by swapping one shared_ptr;
// a request holds its own copy, so it finishes on the trie it started with.
class Service {
public:
Service(std::shared_ptr<const Trie> t, Cache& cache) : trie(std::move(t)), cache_(cache) {}
void publish(std::shared_ptr<const Trie> t) { trie = std::move(t); }
std::vector<Suggestion> suggest(const std::string& raw) {
std::string p = normalize(raw);
if (p.empty()) return {};
if (raw.back() == ' ' && p.size() < kMaxLen) p += ' '; // "dhaka " must not suggest "dhakaiya"
std::shared_ptr<const Trie> t = trie; // one snapshot for the whole request
std::string key = std::to_string(t->version) + ":" + p;
if (const auto* cached = cache_.get(key)) return *cached;
lookups++;
const auto& found = t->top(p);
cache_.put(key, found);
return found;
}
std::shared_ptr<const Trie> trie;
int lookups = 0;
private:
Cache& cache_;
};
void show(Service& s, const std::string& typed) {
std::string parts;
for (const auto& x : s.suggest(typed)) parts += (parts.empty() ? "" : " | ") + x.query + " " + std::to_string(x.count);
if (parts.empty()) parts = "(no suggestions)";
std::printf(" %-12s -> %s\n", ("\"" + typed + "\"").c_str(), parts.c_str());
}
void searches(Aggregator& a, const std::string& q, int times) {
for (int i = 0; i < times; i++) a.add(q);
}
int main() {
const std::set<std::string> blocked{"fake"};
Aggregator agg;
// Day 1: what people submitted, messy spelling and all.
searches(agg, "dhaka weather", 3);
searches(agg, "Dhaka Weather", 1);
searches(agg, " dhaka weather ", 1);
searches(agg, "dhakaiya biryani", 4);
searches(agg, "dhaka fake certificate", 4);
searches(agg, "dhaka traffic", 3);
searches(agg, "dhaka to chittagong bus", 2);
searches(agg, "dal recipe", 2);
searches(agg, "dhaka metro rail", 1);
searches(agg, "eid sale", 3);
searches(agg, "egg price", 1);
std::printf("day 1: %d searches logged\n", agg.seen);
auto [counts, removed] = merge({}, agg.drain(), blocked);
auto t1 = std::make_shared<const Trie>(1, counts, 3);
std::printf("build v1: %d queries (%d blocked), %d trie nodes, top 3 kept per prefix\n", t1->queries, removed, t1->nodes);
Cache cache(8);
Service s(t1, cache);
std::printf("user 1 types \"dhaka m\", one request per keystroke:\n");
for (const char* p : {"d", "dh", "dha", "dhak", "dhaka", "dhaka ", "dhaka m"}) show(s, p);
std::printf("user 2 and a few others:\n");
for (const char* p : {"d", "dh", "dha", " DHAKA ", "e", "xyz"}) show(s, p);
std::printf("cache: %d hits, %d misses; trie lookups: %d\n", cache.hits, cache.misses, s.lookups);
// Day 2: a new metro line opens and everyone searches for it.
searches(agg, "dhaka metro rail", 9);
searches(agg, "dhaka weather", 1);
searches(agg, "eid sale", 2);
std::printf("day 2: %d searches logged\n", agg.seen);
auto merged = merge(counts, agg.drain(), blocked);
auto t2 = std::make_shared<const Trie>(2, merged.first, 3);
s.publish(t2);
std::printf("build v2: old counts halved + day 2 added -> %d queries; now serving v%d\n", t2->queries, s.trie->version);
for (const char* p : {"d", "dhaka ", "e"}) show(s, p);
std::printf("cache: %d hits, %d misses; trie lookups: %d\n", cache.hits, cache.misses, s.lookups);
}Scaling strategy
These stages are illustrative; real thresholds depend on hardware and traffic shape.
| Stage | Traffic | What breaks first | What changes |
|---|---|---|---|
| 1. 100 users | A few suggestions a minute | Nothing | No special system: LIKE 'dha%' ORDER BY count LIMIT 10 on a small query_counts table with an index, counts recomputed nightly. |
| 2. 10K users | Tens per second | Short prefixes like d scan many rows | Precompute a prefix → top 10 table nightly and read it by primary key; add Cache-Control so browsers cache answers. |
| 3. 100K users | Hundreds per second | The database on every keystroke; nightly freshness | A small suggest service with the trie in memory, built hourly from logs and loaded from a file; client debounce. |
| 4. 1M users | Thousands per second | One server's CPU; risky deploys of new data | Several replicas behind an LB, a CDN for short prefixes, a Kafka log, a batch job, snapshot validation and versioned rollout with rollback. |
| 5. 10M+ users | Tens of thousands per second, several languages and regions | The trie outgrows one machine; far users get slow answers; hourly is too slow for news | Shard by prefix range (ranges sized by traffic, so d may be its own shard while x-z share one), a trie per language, snapshots copied to every region, a stream job for trends. |
Notice that stages 1 and 2 are perfectly good designs for small products. A trie service, Kafka and a stream job are stage 4 and 5 tools; building them for a site with 100 users is cost and on-call pain with no benefit.
Why shard by prefix range and not by hash? A lookup is always for one exact prefix, and the prefix decides which part of the trie it needs. With range shards (a-c, d-k, l-z), the router looks at the first character(s) and sends the request to the one shard that holds that subtree. Hashing the prefix string also works if you store a flat prefix → list table (every prefix is its own key), at the cost of more storage, since each prefix's list is stored separately. The catch with ranges is skew: far more queries start with d or s than with x, so ranges must be cut by measured traffic and size, not by alphabet.
Failure scenarios
| Failure | What happens | Detection | Recovery | Data safety | User impact |
|---|---|---|---|---|---|
| Suggest server crash | Its in-flight requests fail | LB health checks | LB stops routing to it; a new server loads the current snapshot, warms up, then reports ready | Nothing to lose: the trie is a copy of a file | A few failed dropdowns; the next keystroke works |
| Bad build (empty, huge, or poisoned trie) | Garbage or no suggestions | Validation before going live: size vs last build, golden prefixes, blocklist scan; canary on one server | Never mark it live; if it slipped through, set the previous version live again (one row) | Old snapshots kept for days | None if caught by validation; minutes of odd suggestions otherwise |
| Batch pipeline or log down | No new builds | Age of the live version (alert if over 3 hours) | Fix and rerun; the log keeps events (Kafka retention) so nothing is skipped | Events are retained; counts catch up | Suggestions get stale but keep working |
| Cache failure | Every request walks the trie | Hit rate drop | Nothing urgent: the in-process cache refills; a failed Redis is bypassed | No unique data in caches | Slightly higher CPU, same answers |
| Network partition between regions | A region can't fetch new snapshots | Version skew between regions | Each region serves its last good snapshot until the link returns | Read-only data, nothing to corrupt | Slightly staler suggestions in one region |
| Duplicate log events | A search is counted twice | Duplicate event_ids in the batch | Batch drops duplicates by event_id | Counts stay correct | None |
| Traffic spike (breaking news) | Many users type the same short prefix | Requests per second, CPU | CDN absorbs repeated prefixes; autoscale replicas (they only need the snapshot file) | Read-only, no risk | None if the edge absorbs it |
| Manipulation (bots searching one phrase) | A fake query climbs into suggestions | Spike from few users or IPs; distinct-user threshold | Count distinct users, not raw searches; rate limit; block term | Blocklist and threshold applied at build time | One bad suggestion until removed |
The common thread: the serving side is read-only copies of a file, so almost every failure ends with "serve the last good version" and the user notices little. Autocomplete should also fail open on the client: if the request takes more than ~300 ms, the browser just doesn't show a dropdown, and search still works.
Reliability and observability
- SLIs: suggest latency (p50, p99) at the server and as measured by real browsers; success rate; freshness (age of the live trie version); fraction of requests answered by the edge.
- SLOs: 99.9% of suggest requests succeed and 99% finish under 10 ms at the server, per 30 days; the live trie is never older than 3 hours.
- SLA: usually none; this is an internal feature. If sold as an API, keep it looser than the SLO.
- Metrics: requests by status, cache hit rate (in-process and CDN), trie version per server (to catch skew), build duration and size, number of queries removed by the blocklist and threshold, zero-result rate per prefix length.
- Product metrics: suggestion click-through rate and how many characters people type before picking one. These tell you whether ranking is any good.
- Alerts: page on error-budget burn and on "no successful build for 3 hours"; ticket on version skew across servers over 15 minutes and on build size changing more than 20%.
- Logs: sample suggest requests (prefixes can be personal, so keep them short-lived and never join them to user IDs); full logs for builds and swaps.
- Tracing: sample traces through CDN → LB → server; mostly useful when latency comes from the network rather than our code.
- Health checks: readiness only after the trie is loaded and a self-test of golden prefixes passes; liveness checks the process.
- Disaster recovery: snapshots are files in object storage (versioned, copied to a second region); a new region can serve as soon as it downloads one. Counts can be rebuilt from the retained log.
Security
- Content safety is the main risk. Anything people type can be shown to millions. Apply the blocklist at build time, and also at serving time for emergency removals (a small deny-set checked on every answer, so a takedown works in minutes without a rebuild).
- Privacy: never suggest a query unless at least N different users (e.g. 20) searched it; this keeps out phone numbers, names and addresses people typed once. Also filter patterns that look like phone numbers, emails or ID numbers. Search logs store a hashed user ID, have short retention, and aren't joined to accounts for this pipeline.
- Manipulation: count distinct users rather than raw searches, rate limit search submission per user and IP, and watch for sudden spikes from few sources.
- Input validation: limit prefix length (50), strip control characters, normalise Unicode before lookup, and allow only known
langvalues. - Rate limiting: per IP and per client on
/v1/suggest; caching at the edge also shields us from floods of repeated prefixes. Junk prefixes are cached as empty answers. - Authorization: only staff can edit blocked terms or roll versions; every change is audited.
- Encryption: TLS everywhere; snapshots and logs encrypted at rest.
Trade-off analysis
| Decision | Chosen | Alternative | Why chosen | When to switch |
|---|---|---|---|---|
| Serving structure | Trie in RAM with top-K per node | Elasticsearch/OpenSearch completion suggester (FST) | Simplest fastest path, full control of ranking, data fits in memory | You also need fuzzy matching, many fields, or don't want to run a custom service |
| When to rank | Precompute top-K offline | Rank at query time from the subtree | Short prefixes would scan millions of nodes per keystroke | Tiny datasets, or highly personalised ranking that can't be precomputed |
| Freshness | Hourly batch + small trend stream | Fully real-time updates to the trie | Immutable snapshots are simple, lock-free and easy to roll back | When minutes of delay really cost money (e.g. live sports, stock tickers) |
| Data layout | Trie snapshots per range shard | Flat prefix → list table in a key-value store | Compact (shared prefixes), one file to ship | You want easy hash sharding and per-key updates more than compact storage |
| Caching | Browser + CDN (60 s) + in-process LRU, versioned keys | No CDN; or a shared Redis first | Short prefixes repeat hugely; versioned keys need no purges | Personalised answers can't use the CDN; merged multi-shard answers justify Redis |
| Counting | Distinct-user threshold + decay | Raw counts, all time | Privacy, manipulation resistance and trends in one rule | Very small sites where thresholds would leave nothing |
Operational cost grows as you move right: FST services, real-time tries and per-key stores each add a system to run. Each is the right call under a different requirement, and saying which requirement is what interviewers want to hear.
Final architecture
This design fits the requirements because each part answers a number or a rule: 63,000 requests a second and a 10 ms budget are met by in-memory lookups with no database on the read path; the huge repetition of short prefixes is absorbed by the CDN; "fresh within an hour" allows a batch pipeline, and immutable versioned snapshots make that pipeline safe to run every hour with instant rollback; content and privacy rules are enforced where the data is built, with a serving-time deny-set for emergencies. Sharding and the trend stream are drawn because they're where the design goes at stage 5, not because a new product needs them.
Interview discussion
1. Why not just run LIKE 'prefix%' against the database?
Answer
An index finds the matching rows, but for a short prefix like "d" there are millions of them, and you still have to sort them by popularity to take the top 10. At tens of thousands of requests a second that's impossible. Precomputing the top 10 for every prefix turns each request into a key lookup. For a small site, the database query is fine, and you should say so.
2. How big is the trie, and does it fit in memory?
Answer
Estimate: 10 million suggestible queries × ~20 characters gives at most 200 million nodes, about 60 million with shared prefixes. With 10 four-byte query IDs per node and some overhead, that's roughly 5 GB, which fits on one server, so every replica can hold the whole thing. If it didn't, compress (radix tree, FST), keep lists only near the top of the trie, or shard by prefix range.
3. How do suggestions stay fresh for breaking news?
Answer
Two paths. The hourly batch with decayed scores handles normal trends. For minutes-level freshness, a stream job counts the last 15 minutes, detects queries rising far above their usual rate, and pushes a small boost list that servers merge into answers until the next batch. Keeping it separate means the main build stays simple and safe.
4. How do you update the trie without stopping traffic?
Answer
Never modify it in place. Build a complete new trie offline, ship it as a versioned file, load it next to the old one, then swap one atomic pointer. Requests that started on the old version finish on it. Cache keys contain the version, so no stale answer is served after the swap, and rollback is swapping back.
5. How would you handle typos like "dhkaa"?
Answer
Exact-prefix tries don't. Options: add common misspellings as aliases pointing to the right query (learned from logs where users corrected themselves), or use fuzzy matching with a bounded edit distance (Levenshtein automata over an FST, as in Lucene). Fuzzy lookups are more expensive, so they're typically used only when the exact prefix returns few results.
6. How would you personalise suggestions?
Answer
Keep the global trie for popularity and fetch a small per-user list (recent searches, stored per user) in parallel; merge them on the server with a simple scoring rule. The merged answer can't be cached at the CDN anymore, so personalise only after a few characters or only for logged-in users, and cache the global part separately.
7. Our users type in Bangla and Banglish. What changes?
Answer
Normalise Unicode (NFC) so the same word typed on different keyboards matches, build a trie per language or script, and consider transliteration so "dhaka" can also suggest "ঢাকা" queries. The prefix length limit should count characters, not bytes. The rest of the design is unchanged.
8. How does the client help?
Answer
Debounce (wait ~50–100 ms after the last key), cancel in-flight requests when a new key arrives, cache answers for the session, and ignore responses that arrive out of order. Together these often cut requests by more than half.
Common mistakes: querying the database on every keystroke; counting keystrokes instead of submitted searches; updating the trie in place under a lock; forgetting the CDN and Cache-Control; no plan for offensive or personal suggestions; using raw all-time counts so trends never appear; adding Kafka and stream processing before estimating.
What interviewers look for: a latency budget with numbers, the insight that short prefixes make query-time ranking impossible, a clear split between offline building and online serving, a safe way to roll out new data, caching at the right layers with a staleness argument, and honest attention to safety and privacy.
Designed it yourself and understood the solution? Mark it to track your progress.