Practice · High Level Design · Read-Heavy: Serve It Fast
Design Search Autocomplete
প্রতিটা key চাপার সাথে ১০০ ms এর মধ্যে suggestion: প্রতিটা prefix এ top-K সহ trie, search log থেকে offline এ সেটা বানানো, আর user এর কাছাকাছি cache।
Learn mode: পুরো solution নিচে দেখানো আছে।
Problem এর পরিচয়
| Difficulty | Intermediate |
| সময় | design আর core বানাতে 2–3 ঘণ্টা; interview তে মোটামুটি 45 মিনিট |
| আগে যা জানা দরকার | Storage and search, caching আর CDN, trie জিনিসটা কী |
| Concept | Latency budget, প্রতিটা node এ আগে থেকে হিসাব করা top-K সহ trie, offline batch এ বানানো বনাম online এ serve করা, immutable snapshot আর atomic swap, version সহ cache key, edge caching, prefix range ধরে sharding, time decay দিয়ে trending |
| বাস্তবে | যেকোনো বড় e-commerce site এর search box, Google আর YouTube এর suggestion, app store, map ("dhanmondi 27..."), bus বা train ticket site এর "কোথায় যাবেন" ঘর |
Autocomplete দেখতে ছোট একটা feature: search box এর নিচে একটা dropdown। কিন্তু HLD problem হিসেবে এটা দারুণ, কারণ সংখ্যাগুলো এমন একটা design এ ঠেলে দেয় যেটা "database এ query মারো" থেকে একদম আলাদা: প্রতিটা key চাপা একটা request, পরের অক্ষর টাইপ করার আগেই উত্তর আসতে হবে, আর data (মানুষ কী খোঁজে) সারাদিন বদলায়। মূল শিক্ষাটা অনেক জায়গায় কাজে লাগবে: system টাকে দুই ভাগ করো, একটা ধীর offline পথ যেটা উত্তর বানিয়ে রাখে, আর একটা দ্রুত online পথ যেটা শুধু সেই উত্তর পড়ে।
Problem statement
মিরপুরের কেউ একটা বড় shopping site খুলে "dha" টাইপ করা শুরু করল। শব্দটা শেষ করার আগেই dropdown এ চলে এসেছে "dhaka weather", "dhakaiya biryani", "dhaka traffic"। একটায় tap করল, বাকিটা আর টাইপ করতে হলো না। Phone এর keyboard এ এতে সত্যিই কষ্ট বাঁচে, আর মানুষ এমন query তে যায় যেটার আসলে result আছে।
কী বানাচ্ছি: এমন একটা service, user এ পর্যন্ত যা টাইপ করেছে (prefix) সেটা দিলে ওই prefix দিয়ে শুরু হওয়া সবচেয়ে popular 10 টা পুরো query ফেরত দেয়, এত দ্রুত যে মনে হয় সাথে সাথে চলে এলো।
কে ব্যবহার করে: search box এ যে টাইপ করে সবাই, প্রতিটা key চাপায়। পরোক্ষভাবে search team, যাদের দরকার suggestion যেন fresh থাকে (নতুন metro line, Eid sale, breaking news) আর পরিষ্কার থাকে (কোনো গালি বা কারো personal তথ্য suggest হবে না)।
কী সমস্যা মেটায়: কম টাইপ, কম বানান ভুল, আর এমন search যেটা আসলে আছে এমন জিনিসে গিয়ে পড়ে।
Technically কোথায় মজা:
- Latency: mobile network সহ পুরো আসা-যাওয়া 100 ms এর অনেক কমে শেষ হতে হবে, নাহলে dropdown টাইপিং এর পেছনে পড়ে থাকে, কোনো কাজে আসে না।
- পরিমাণ: একটা search মানে কয়েকটা request (টাইপের প্রতিটা বিরতিতে একটা), তাই suggestion এর traffic search এর traffic এর কয়েক গুণ।
- ছোট prefix বিশাল: "d" দিয়ে শুরু হওয়া query লক্ষ লক্ষ। প্রতিটা key চাপায় এগুলো sort করা অসম্ভব।
- Fresh বনাম দ্রুত: কোনটা popular সেটা দিনের মধ্যেই বদলায়, কিন্তু request এর পথে উত্তর নতুন করে বানালে latency শেষ।
- Content safety: মানুষ যা টাইপ করে সেটাই লাখো মানুষকে suggestion হিসেবে দেখানো হতে পারে, গালি, গুজব, phone number সহ।
Requirement
Functional requirement
- Suggest: একটা prefix এর জন্য ওটা দিয়ে শুরু হওয়া সর্বোচ্চ 10 টা পুরো query ফেরত দাও, সবচেয়ে popular টা আগে।
- শুধু আসল search: suggestion আসবে মানুষ আসলে যেগুলো submit করেছে (Enter চেপেছে) সেগুলো থেকে, প্রতিটা key চাপা থেকে না।
- Freshness: কোনো query popular হয়ে গেলে মোটামুটি এক ঘণ্টার মধ্যে দেখাবে; হঠাৎ ভাইরাল হওয়া trend মোটামুটি 15 মিনিটে (stretch goal)।
- Filtering: blocked শব্দ, আপত্তিকর কিছু, আর personal data র মতো দেখতে কিছু কখনো suggest হবে না; অভিযোগ এলে কয়েক মিনিটের মধ্যে একটা suggestion সরানো যাবে।
- Scope এর বাইরে: বানান ঠিক করা ("dhkaa"), personal suggestion, আর search result page নিজে। প্রথম দুইটা interview অংশে আলোচনা করব।
Non-functional requirement
| Requirement | লক্ষ্য | এখানে কেন গুরুত্বপূর্ণ |
|---|---|---|
| Latency | server এ p99 10 ms এর নিচে; user থামার ~100 ms এর মধ্যে dropdown | পরের key চাপার চেয়ে ধীর হলে user সেটা দেখেই না |
| Throughput | peak এ সেকেন্ডে ~60,000 request (নিচে হিসাব) | টাইপের প্রতিটা বিরতি একটা request |
| Availability | 99.9% | Autocomplete সাহায্য করে, কিন্তু critical না: এটা না চললেও search চলে |
| Freshness | নতুন popular query ~1 ঘণ্টায় | পুরনো suggestion Eid sale আর খবর মিস করে |
| Consistency | Eventual; একটা request একটাই version এর data দেখবে | কয়েক মিনিট পুরনো চলে, এক উত্তরে দুই version মিশে গেলে গোলমাল |
| Durability | Search log এ সামান্য data হারালে সমস্যা নেই; বানানো trie সবসময় আবার বানানো যায় | এগুলো statistics, টাকার হিসাব না |
| Safety আর privacy | কোনো blocked বা personal suggestion না; দ্রুত সরানো | একটা খারাপ suggestion মানেই খবরের শিরোনাম |
Latency আর throughput ই design ঠিক করে। খেয়াল করো কোনগুলো কড়া না: freshness মিনিটে মাপা, durability ঢিলা, আর availability 99.9% হলেই চলে কারণ feature টা ভাঙলেও search চলে। এই ছাড়টুকুর জন্যই আমরা সবকিছু আগে থেকে হিসাব করে রাখতে পারি।
Hint 1: সময়ের budget
পুরো আসা-যাওয়া যদি 100 ms এর নিচে শেষ করতে হয় আর mobile network ই তার বেশিরভাগ খেয়ে ফেলে, তাহলে server এর হাতে আসলে কত millisecond থাকে? ওই সময়ে কী ধরনের কাজ করা যায়?
Hint 2: ছোট prefix এর সমস্যা
"d" দিয়ে লক্ষ লক্ষ query মেলে। প্রতিটা key চাপায় এগুলো sort করা যাবে না। তাহলে sort টা কখন করা যায়?
Hint 3: popularity কোথা থেকে আসে
কোনটা গুনবে: key চাপা, নাকি submit করা search? হিসাবটা কতটা fresh হতে হবে, আর কাজটা কি user অপেক্ষা করার সময়ই করতে হবে?
Hint 4: কম request
"dhaka metro" টাইপ করা মানে 11 বার key চাপা। Server এর কি 11 টাই দেখা দরকার? Browser আর CDN কী করতে পারে?
Capacity আন্দাজ
ধরে নেওয়া (মুখে বলা, গোল সংখ্যা):
- দিনে 5 কোটি active user, প্রত্যেকে দিনে 6 টা search করে।
- একটা query গড়ে 18 অক্ষর, কিন্তু ছোট একটা debounce থাকায় browser প্রতি search এ মোটামুটি 6 টা suggestion request পাঠায়।
- সবচেয়ে ব্যস্ত ঘণ্টা গড়ের মোটামুটি 3 গুণ।
- 30 দিনে অন্তত 20 জন আলাদা মানুষ যেটা search করেছে শুধু সেটাই suggestion হতে পারবে (এটা privacy rule ও, Security অংশ দেখো)। তাতে মোটামুটি 1 কোটি suggest করার মতো query থাকে, গড়ে 20 byte লম্বা।
- প্রতিটা prefix এ top 10 রাখব।
Traffic
| Formula | ফল | |
|---|---|---|
| দিনে search | 5 কোটি × 6 | 30 কোটি |
| দিনে suggestion request | 30 কোটি × 6 | 180 কোটি |
| সেকেন্ডে suggestion request (গড়) | 180 কোটি ÷ 86,400 | ≈ 21,000 / s |
| সেকেন্ডে suggestion request (peak) | 21,000 × 3 | ≈ 63,000 / s |
| সেকেন্ডে search log event (গড় / peak) | 30 কোটি ÷ 86,400 | ≈ 3,500 / s, ≈ 10,500 / s |
Storage
| Formula | ফল | |
|---|---|---|
| দিনে search log | 30 কোটি × প্রতি event 200 B | দিনে 60 GB |
| Log 90 দিন রাখা, 3 copy | 60 GB × 90 × 3 | ≈ 16 TB |
| Query এর text | 1 কোটি × 20 B | 200 MB |
| Trie এর node | 1 কোটি × 20 এর কম (common prefix ভাগাভাগি করায় ~6 কোটি) | ≈ 6 কোটি node |
| Trie এর size | 6 কোটি node × ~80 B (children + 4 B এর 10 টা query ID + overhead) | ≈ 5 GB |
Server: memory তে trie lookup কয়েক microsecond এর কাজ; আসল সময় যায় HTTP এর আশেপাশের কাজে। একটা server সেকেন্ডে 10,000 request সামলায় ধরে নিলে (হিসেব করে কম ধরা), peak এ মোটামুটি 7 টা server লাগে। দুইটা zone মিলিয়ে 10 টা চালাও, যাতে একটা zone পড়ে গেলেও সমস্যা না হয়। প্রতিটা server পুরো 5 GB trie রাখে, তাই যেকোনো server যেকোনো prefix এর উত্তর দিতে পারে।
Bandwidth: 10 টা suggestion সহ একটা response মোটামুটি 400 byte JSON আর ~300 byte header। Peak এ 63,000 × 700 B ≈ 44 MB/s (~350 Mbit/s)। চোখে পড়ার মতো, তবে সমস্যা না, আর gzip আর CDN এটা আরো কমায়।
Cache size: 1 থেকে 3 অক্ষরের prefix মোট মোটামুটি 37 + 37² + 37³ ≈ 52,000 টা (অক্ষর, সংখ্যা, space)। প্রতিটা 700 B হলে 36 MB, আর এই ছোট prefix গুলোই সবচেয়ে বেশি বার আসে, তাই CDN edge cache এর জন্য একদম মাপমতো।
সংখ্যাগুলো কী বলছে: serve করার data (5 GB) একটা machine এর memory তে ধরে যায়, তাই একটা lookup কোনো database ছাড়াই শুধু pointer ধরে হাঁটা হতে পারে। খরুচে কাজটা (30 কোটি search গোনা আর প্রতিটা prefix এর ranking) offline এ, batch এ করা যায়, কারণ freshness মিনিটে মাপা। পুরো design টা এই দুই বাক্যেই।
API design
| Method আর path | Request | সফল হলে | Error |
|---|---|---|---|
GET /v1/suggest?q=dha&lang=en&limit=10 | কিছু না | 200 {"q": "dha", "version": 412, "suggestions": ["dhaka weather", "dhakaiya biryani", "dhaka traffic"]} সাথে Cache-Control: public, max-age=60 | 400 prefix বেশি লম্বা বা ভুল lang, 429 বেশি request |
POST /v1/search (search service এর, আমাদের না) | {"q": "dhaka metro rail", "event_id": "..."} | result page | গোনার জন্য search event হিসেবে log হয় |
POST /admin/v1/blocked-terms | {"term": "...", "reason": "..."} (শুধু staff) | 201 | 403 |
- GET আর URL এ prefix কেন: এতে browser আর CDN URL ধরে response cache করতে পারে। এই API এর সবচেয়ে জরুরি গুণ এটাই।
- Response এ count নেই: popularity এর সংখ্যা ভেতরের ব্যাপার; বাইরে দেখালে মানুষ সেটা নিয়ে কারসাজি করতে চায়।
- Authentication: suggestion anonymous। ইচ্ছা করেই: request এ cookie বা user ID থাকলে response personal হয়ে যায়, আর cache করা যায় না। Rate limit হয় IP আর client app ধরে।
- Idempotency:
GETএমনিতেই idempotent। Search event এevent_idথাকে, যাতে log একটা event দুইবার দিলে গোনার pipeline duplicate বাদ দিতে পারে। versionবলে কোন trie উত্তর দিয়েছে; "এই suggestion কেন দেখলাম" debug করতে কাজে লাগে, আর পুরনো version এর কোনো response দেরিতে এলে client সেটা বাদ দিতে পারে।- Client এর দায়িত্ব: browser পাঠানোর আগে একটু থামে (মোটামুটি 50–100 ms), নতুন key চাপলে আগের request cancel করে, আর session জুড়ে উত্তর cache করে। এই তিনটা নিয়মেই server এর traffic অনেক কমে যায়।
Database design
Access pattern: online পথ পড়ে "ঠিক এই prefix এর top 10", সেকেন্ডে হাজার হাজার বার, আর কখনো লেখে না। Offline পথ সারাদিন search event যোগ করে, আর ঘণ্টায় একবার সব count নতুন করে লেখে।
সোজা database এ query করলে কী সমস্যা? SELECT query FROM queries WHERE query LIKE 'd%' ORDER BY count DESC LIMIT 10 prefix এর জন্য B-tree index ব্যবহার করতে পারে, কিন্তু "d" এর জন্য তবুও লক্ষ লক্ষ মিলে যাওয়া row পড়ে count ধরে sort করতে হয়। 1,000 query এর site এ এটা ঠিক আছে, আমাদের scale এ আশা নেই। সমাধান হলো প্রতিটা prefix এর উত্তর আগেই হিসাব করে রাখা, আর সেই উত্তরগুলো ঘন করে রাখার একটা উপায় হলো trie।
তাই serve করার "database" হলো trie নিজেই, memory তে, একটা file থেকে load করা। বাকি store গুলো সাধারণ:
-- 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()
);
- Log শুধু append হয় আর সময় ধরে ভাগ করা, তাই সেকেন্ডে 10,000 event লেখা সস্তা, আর এক ঘণ্টার data মানে পড়ার জন্য একটা directory।
- Count নতুন করে হিসাব হয়, জায়গায় update হয় না: batch আগের count আর নতুন ঘণ্টা পড়ে পুরো নতুন একটা set লেখে। কোনো row নিয়ে কাড়াকাড়ি হয় না।
trie_versionsহলো control plane: server গুলো এটা poll করে (বা notification পায়), নিজের shard এর file নামায়, checksum মিলিয়ে swap করে। Rollback মানে পুরনো একটা row কে আবারliveকরে দেওয়া।- Replication আর partitioning: log replicate করে Kafka আর object storage; trie "replicate" হয় প্রতিটা server এ একই file load করে; shard হলো prefix range (Scaling দেখো)। ছোট PostgreSQL table গুলোর জন্য একটা standby ই যথেষ্ট।
Architecture এর ছবি
| Component | কেন আছে | কার সাথে যুক্ত |
|---|---|---|
| Browser | Debounce করে, পুরনো request cancel করে, session জুড়ে উত্তর cache করে | GET /v1/suggest নিয়ে → CDN / LB |
| CDN edge (শেষ design এ) | সবচেয়ে বেশি আসা ছোট prefix গুলোর উত্তর user এর কাছ থেকেই দেয় | URL ধরে 60 s cache |
| Load balancer | Suggest server গুলোর মধ্যে request ভাগ করে, অসুস্থ server বাদ দেয় | → suggest server |
| Suggest server | পুরো trie RAM এ রাখে; pointer ধরে হেঁটে prefix এর উত্তর দেয় | শুধু পড়ে; snapshot load করে |
| Prefix cache | বারবার আসা prefix এ হাঁটাটা বাঁচায়; key = version:prefix | আগে process এর ভেতরের LRU; shared Redis শুধু তখন যখন উত্তর বানানো খরুচে |
| Search API | User query submit করলে একটা search event log করে | → query log |
| Query log | Durable, শুধু append হওয়া search event এর stream | → aggregator |
| Aggregator | ঘণ্টায় একবার batch: search গোনে, পুরনো score অর্ধেক করে, blocklist আর privacy threshold লাগায় | → trie builder |
| Trie builder | Trie আর প্রতিটা node এর top 10 বানায়, যাচাই করে | → snapshot store |
| Snapshot store | Object storage এ version করা trie file | → suggest server নামিয়ে নেয় |
Prefix cache নিয়ে একটা কথা: RAM এ trie ধরে হাঁটা এমনিতেই microsecond এর ব্যাপার, তাই সামনে একটা shared Redis বসালে উল্টো একটা network hop বাড়ে। এখানে আমাদের cache হলো process এর ভেতরের একটা LRU, যেটা hot prefix এর হাঁটা আর JSON বানানো বাঁচায়। Shared Redis এর দাম ওঠে পরে, যখন একটা উত্তর কয়েকটা shard থেকে জোড়া লাগাতে হয় বা personal history র সাথে মেশাতে হয়। এটাই caching strategy: আগে browser, তারপর CDN, তারপর process এর ভেতরে, Redis শুধু খরুচে জোড়া লাগানো উত্তরের জন্য।
যেগুলো ইচ্ছা করে রাখিনি: read পথে database, read পথে কোনো write, বা প্রথম দিন থেকে stream processor (ঘণ্টায় একবারের batch দিয়েই freshness এর requirement মেটে)।
একটা request এর জীবন
একটা prefix টাইপ করা (cache miss)
- User
d,h,aটাইপ করে। শেষ key এর পর browser ~100 ms অপেক্ষা করে, তাই একটাই request যায়:GET /v1/suggest?q=dha।dhএর request তখনো রাস্তায় থাকলে সেটা cancel হয়। - আগে browser cache, তারপর CDN: আশেপাশের কেউ গত 60 সেকেন্ডে
dhaচেয়ে থাকলে edge ই উত্তর দেয়, আমাদের server টের ও পায় না। - LB → যেকোনো suggest server। Server prefix টা normalise করে (ছোট হাতের অক্ষর, একটা করে space, সর্বোচ্চ 50 অক্ষর; শেষের space রেখে দেয়, কারণ
dhakaআরdhakaআলাদা prefix)। - Current trie এর pointer একবার পড়ে (version 1), cache এ
1:dhaখোঁজে। Miss। - Trie তে
d → h → aহাঁটে: তিন ধাপ। Top-10 list ওই node এ আগে থেকেই রাখা, খোঁজা বা sort করার কিছু নেই। - List টা
1:dhaনামে রাখে আরCache-Control: public, max-age=60সহ200দেয়। Server এর মোট সময়: এক millisecond এর অনেক কম।
একটা search গোনা হচ্ছে (offline পথ)
- User "dhaka metro rail" এ Enter চাপে। Search service query log এ
{event_id, query, lang, user_hash, ts}লেখে। Search নিজে এর জন্য অপেক্ষা করে না। - প্রতি ঘণ্টায় aggregator গত ঘণ্টার event পড়ে, duplicate
event_idবাদ দেয়, query normalise করে গোনে। - আগের score এর সাথে মেলায়:
score = old_score / 2 + new_count(exponential decay, যাতে গত সপ্তাহ মিলিয়ে যায় আর নতুন trend সামনে আসতে পারে), blocked শব্দ আর 20 জন আলাদা user এর নিচের query বাদ দেয়। - Builder নতুন trie বানায়, নিচ থেকে উপরে প্রতিটা node এর top 10 হিসাব করে, তারপর যাচাই চালায় (size আগের build এর ±20% এর মধ্যে, কিছু "golden" prefix এখনো ঠিকঠাক উত্তর দেয়, কোনো blocked শব্দ নেই)।
- Snapshot file checksum সহ object storage এ যায়;
trie_versionsএর একটা row প্রথমেvalidated, তারপরliveহয়। - প্রতিটা suggest server file নামায়, পুরনো trie এর পাশে memory তে load করে, আর একটা pointer swap করে। যে request গুলো চলছিল সেগুলো পুরনো trie তেই শেষ হয়; নতুন গুলো নতুনটা পায়। পুরনো cache entry র key তে পুরনো version, তাই সেগুলো আর কখনো serve হয় না, এমনি এমনি বের হয়ে যায়।
Component গুলো গভীরে
প্রতিটা node এ top-K সহ trie
সাধারণ trie তে "কোন query গুলো d দিয়ে শুরু?" এর উত্তর দিতে d এর নিচের পুরো subtree হাঁটতে হয়: লক্ষ লক্ষ node। আমরা request এর সময় এটা করি না। বরং offline এ বানানোর সময় প্রতিটা node তার subtree র সেরা K টা query রেখে দেয়। হিসাবটা হয় নিচ থেকে উপরে: একটা node এর list হলো তার নিজের query (যদি ওখানে কোনো query শেষ হয়) আর children দের list মিলিয়ে, sort করে, K তে কাটা। Children দের list আগেই নিজের subtree র সেরা, তাই merge শুধু K × children টা জিনিস দেখে, পুরো subtree না।
| আগে থেকে list না রাখলে | প্রতিটা node এ top-K রাখলে | |
|---|---|---|
| Lookup | prefix হাঁটো + পুরো subtree ঘাঁটো + sort | prefix হাঁটো, list ফেরত দাও |
| "d" এর খরচ | লক্ষ লক্ষ node | 1 ধাপ |
| সাধারণভাবে খরচ | O(subtree এর size) | O(prefix এর দৈর্ঘ্য) |
| বাড়তি memory | নেই | প্রতিটা node এ K টা entry (string না, query ID রাখো) |
| একটা count বদলানো | সস্তা | root পর্যন্ত পথের সব list ঠিক করতে হয়, তাই আমরা batch এ পুরোটা আবার বানাই |
Production এর জন্য memory বাঁচানোর কৌশল: list এ string এর বদলে 4-byte query ID রাখো; একটাই child আছে এমন node এর শিকল জুড়ে দাও (radix tree); একটা নির্দিষ্ট গভীরতা পর্যন্তই list রাখো, তার নিচে ছোট একটা scan; বা finite-state transducer ব্যবহার করো (Lucene এর suggester যেটা ব্যবহার করে), যেটা prefix এর সাথে suffix ও ভাগাভাগি করে।
Immutable snapshot আর atomic swap
Trie কখনো জায়গায় edit হয় না। প্রতিটা build পুরো নতুন একটা trie বানায়; server সেটা current টার পাশে load করে, তারপর একটা মাত্র pointer swap করে। এতে তিনটা জিনিস ফ্রিতে পাই:
- Read পথে কোনো lock নেই: reader কখনো আধা-update হওয়া trie দেখে না, কারণ কেউ ওটা update ই করে না।
- Consistent উত্তর: একটা request শুরুতে একবারই pointer পড়ে, তাই তার পুরো উত্তর এক version থেকে আসে (Go এর
TestReadersDuringSwapstest টা-raceসহ ঠিক এটাই যাচাই করে)। - সাথে সাথে rollback: আগের file এখনো object storage এ আছে; ফিরে যাওয়াও ঠিক একই কাজ।
দাম হলো memory: swap এর সময় একটা server অল্প সময়ের জন্য দুইটা trie রাখে (2 × 5 GB)। Server এর size সেই হিসাবে ঠিক করো, নাহলে shard গুলো একটা একটা করে load করো।
Version সহ cache key
Cache key যদি শুধু prefix হতো, তাহলে swap এর পরেও প্রতিটা entry expire না হওয়া পর্যন্ত cache পুরনো list দিতে থাকত। প্রতিটা swap এ সব server জুড়ে সব key মুছে ফেলা ধীর, আর race এর ঝুঁকি আছে। তার বদলে key তে trie এর version থাকে: 412:dha। 413 এ swap হওয়ার পর প্রতিটা lookup 413:... key ব্যবহার করে, তাই পুরনো entry গুলোতে আর কেউ hit করে না, LRU এগুলো বের করে দেয়। CDN এর key তে আমাদের version বসানো যায় না (ও URL ধরে cache করে), তাই সেখানে max-age (60 s) পর্যন্ত পুরনো থাকা মেনে নিই, freshness এর requirement এটা অনুমতি দেয়।
Decay সহ গোনা, আর trend
Batch হিসাব করে score = old_score / 2 + this_hour (production এ অর্ধেক করার হারটা ঠিক করে নেওয়া হয়, যেমন কয়েক দিনের half-life)। Decay না থাকলে গত বছরের "eid ul adha 2025" এই সপ্তাহের খবরকে চিরকাল হারিয়ে দিত। Decay থাকলে হঠাৎ অনেক search হওয়া একটা query এক-দুইটা build এর মধ্যেই পুরনো প্রিয় query গুলোকে ছাড়িয়ে যায়। Demo ঠিক এটাই দেখায়: "dhaka metro rail" 1 টা search থেকে এক দিনের ভারী search এর পর d এর একদম উপরে চলে যায়।
"15 মিনিটে breaking news" এর জন্য ঘণ্টার batch যথেষ্ট না। শেষ design এ ছোট একটা stream job যোগ হয় যেটা গত 15 মিনিটের count রাখে, যে query গুলো স্বাভাবিকের চেয়ে অনেক দ্রুত বাড়ছে সেগুলো খুঁজে বের করে, আর server গুলোতে ছোট একটা "boost list" পাঠায়; পরের batch সেগুলো নিজের মধ্যে নেওয়া পর্যন্ত server গুলো এটা উত্তরে মিশিয়ে দেয়। আলাদা, সহজ একটা পথ, যাতে মূল build টা একঘেয়ে আর নির্ভরযোগ্য থাকে।
চালিয়ে দেখার মতো core
এই program টা design এর মূল অংশ, এক বসায় পড়ে ফেলার মতো ছোট: normalisation, submit করা search গোনার একটা aggregator, decay আর blocklist সহ একটা merge, প্রতিটা prefix এর top K আগে থেকে হিসাব করে রাখা একটা trie, version:prefix key এর একটা LRU cache, আর একটা service যেটা key চাপার উত্তর দেয় আর atomic ভাবে নতুন trie swap করে।
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())
}
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
Output টা পড়া:
- প্রথম দিন আর build: 25 টা search log হয়েছে, কিন্তু "Dhaka Weather" আর " dhaka weather " normalise হয়ে "dhaka weather" হয়ে গেছে, তাই এগুলো 5 টা search সহ একটাই query। "dhaka fake certificate" 4 বার search হয়েছিল,
dhakaএর নিচে দ্বিতীয় suggestion হয়ে যেত; build এর আগেই blocklist এটা বাদ দিয়েছে। 8 টা query থেকে 83 node এর trie। - "dhaka m" টাইপ করা:
dথেকেdhakaপর্যন্ত সবগুলোতে একই তিনটা suggestion: "dhaka weather" (5), "dhakaiya biryani" (4), "dhaka traffic" (3)। এটাই ঠিক: এই সব prefix এর নিচে এগুলোই সেরা query। Space টাইপ করার সাথে সাথেdhakaথেকে "dhakaiya biryani" বাদ পড়ে (এটা "dhaka " দিয়ে শুরু না), "dhaka to chittagong bus" ঢুকে যায়।dhaka mএ থাকে শুধু "dhaka metro rail"। - দ্বিতীয় user:
d,dh,dhacache hit: প্রথম user আগেই এগুলো চেয়েছিল।DHAKAnormalise হয়েdhaka(শেষের space রাখা হয়) হয়, সেটাও hit।xyzকিছু দেয় না, আর এই খালি উত্তরটাও cache হয়, তাই কোনো bot আজেবাজে prefix দিয়ে trie কে চাপ দিতে পারে না। - Cache এর সংখ্যা: এ পর্যন্ত 4 টা hit আর 9 টা miss; শুধু miss গুলোই trie তে হেঁটেছে।
- দ্বিতীয় দিন: নতুন metro line চালু হলো, "dhaka metro rail" 9 বার search হলো। Merge প্রথম দিনকে অর্ধেক করে (weather 5 → 2, সাথে নতুন 1 = 3) আর দ্বিতীয় দিন যোগ করে। "egg price" (1 → 0) হারিয়ে যায়। v2 তে "dhaka metro rail" (9)
dআরdhakaদুই জায়গাতেই সবার উপরে। - Swap এর পর:
d,dhakaআরeএক মিনিট আগে cache এ থাকলেও miss, কারণ key এখন2:d,1:dনা। কেউ পুরনো list দেখে না, আর কাউকে কিছু মুছতেও হয়নি।
Test গুলো সবচেয়ে জরুরি জিনিসগুলো যাচাই করে: random data তে আগে থেকে হিসাব করা top K আর brute-force উত্তর একই, normalisation আর শেষের space, merge এর decay আর blocklist, swap এর পর কখনো পুরনো cache করা list আসে না, 49 টা swap চলার সময় যে reader গুলো চলছে তারা সবসময় ঠিক একটা version থেকে উত্তর পায় (-race সহ চালানো), আর একসাথে গোনায় কোনো search হারায় না। Atomic pointer এর জায়গায় সাধারণ pointer বসালে swap এর test এ -race data race ধরে, তাই test টা সত্যিই 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]
একই core আরো পাঁচটা language এ
Single-threaded version, একই class, একই output। Swap এখনো একটা reference এর assignment; বদলায় শুধু thread থাকলে প্রতিটা language কীভাবে সেটা নিরাপদ রাখে:
- Python: GIL এর নিচে
self.trie = new_trieassignment atomic, আর একটা request নিজের referencetধরে রাখে, তাই ওই request শেষ হওয়া পর্যন্ত পুরনো trie বেঁচে থাকে। - TypeScript: Node সব request একটা event loop এ চালায়, তাই swap কোনো lookup এর মাঝখানে ঢুকতে পারে না। (Class টার নাম
SuggestCache, কারণCachebrowser এর একটা global।) - Rust: service একটা
Rc<Trie>রাখে, প্রতিটা request সেটা clone করে। Thread থাকলে atomic swap এর পেছনেArc<Trie>লাগবে (যেমনarc-swapcrate), আর thread-safe না এমন version compiler নিজেই মানবে না। - Java:
AtomicReference<Trie>হলো Go এরatomic.Pointerএর সরাসরি জোড়া, আর access order এLinkedHashMapএকটা তৈরি LRU। - C++: service একটা
std::shared_ptr<const Trie>রাখে; প্রতিটা request সেটা copy করে, তাতে পুরনো trie বেঁচে থাকে। Thread থাকলে swap এর জন্যstd::atomic<std::shared_ptr<const Trie>>(C++20)।
"""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 এর ধাপ
এই ধাপগুলো বোঝানোর জন্য; আসল সীমা hardware আর traffic এর ধরনের উপর নির্ভর করে।
| ধাপ | Traffic | আগে কী ভাঙে | কী বদলায় |
|---|---|---|---|
| 1. 100 user | মিনিটে কয়েকটা suggestion | কিছু না | আলাদা কোনো system লাগে না: index সহ ছোট একটা query_counts table এ LIKE 'dha%' ORDER BY count LIMIT 10, count রাতে একবার হিসাব। |
| 2. 10K user | সেকেন্ডে কয়েক ডজন | d এর মতো ছোট prefix অনেক row ঘাঁটে | রাতে একবার prefix → top 10 table বানিয়ে primary key দিয়ে পড়া; Cache-Control যোগ করা যাতে browser উত্তর cache করে। |
| 3. 100K user | সেকেন্ডে কয়েকশো | প্রতিটা key চাপায় database; রাতের freshness | Memory তে trie সহ ছোট একটা suggest service, log থেকে ঘণ্টায় একবার বানানো, file থেকে load; client এ debounce। |
| 4. 1M user | সেকেন্ডে কয়েক হাজার | একটা server এর CPU; নতুন data deploy করা ঝুঁকির | LB এর পেছনে কয়েকটা replica, ছোট prefix এর জন্য CDN, Kafka log, batch job, snapshot যাচাই, আর rollback সহ version ধরে rollout। |
| 5. 10M+ user | সেকেন্ডে কয়েক দশ হাজার, কয়েকটা language আর region | Trie একটা machine এ আর ধরে না; দূরের user ধীর উত্তর পায়; খবরের জন্য ঘণ্টা বেশি ধীর | Prefix range ধরে shard (range কাটা হয় traffic মেপে, তাই d হয়তো নিজেই একটা shard আর x-z মিলে একটা), প্রতিটা language এর আলাদা trie, প্রতিটা region এ snapshot copy, trend এর জন্য stream job। |
খেয়াল করো, ছোট product এর জন্য ধাপ 1 আর 2 পুরোপুরি ভালো design। Trie service, Kafka আর stream job হলো ধাপ 4 আর 5 এর হাতিয়ার; 100 user এর site এর জন্য এগুলো বানানো মানে খরচ আর on-call এর ঝামেলা, লাভ কিছু না।
Hash না করে prefix range ধরে shard কেন? একটা lookup সবসময় ঠিক একটা prefix এর জন্য, আর trie এর কোন অংশ লাগবে সেটা prefix ই ঠিক করে। Range shard (a-c, d-k, l-z) এ router প্রথম অক্ষর (বা কয়েকটা) দেখে request টা সেই একটা shard এ পাঠায় যেখানে ওই subtree আছে। Prefix string hash করেও চলে, যদি একটা সমতল prefix → list table রাখো (প্রতিটা prefix নিজেই একটা key), তবে storage বেশি লাগে, কারণ প্রতিটা prefix এর list আলাদা করে রাখা থাকে। Range এর ঝামেলা হলো skew: x এর চেয়ে d বা s দিয়ে অনেক বেশি query শুরু হয়, তাই range কাটতে হয় মাপা traffic আর size দেখে, বর্ণমালা দেখে না।
Failure এর পরিস্থিতি
| Failure | কী হয় | কীভাবে ধরা পড়ে | কীভাবে সারে | Data নিরাপদ? | User এর উপর প্রভাব |
|---|---|---|---|---|---|
| Suggest server crash | ওই মুহূর্তের request গুলো fail | LB health check | LB ওখানে পাঠানো বন্ধ করে; নতুন server current snapshot load করে, warm up করে, তারপর ready বলে | হারানোর কিছু নেই: trie একটা file এর copy | কয়েকটা dropdown আসে না; পরের key চাপায় ঠিক |
| খারাপ build (খালি, বিশাল, বা নষ্ট trie) | আজেবাজে বা কোনো suggestion না | Live করার আগে যাচাই: আগের build এর সাথে size, golden prefix, blocklist scan; একটা server এ canary | Live ই করো না; ফসকে গেলে আগের version আবার live করো (একটা row) | পুরনো snapshot কয়েকদিন রাখা থাকে | যাচাইয়ে ধরা পড়লে কিছু না; নাহলে কয়েক মিনিট অদ্ভুত suggestion |
| Batch pipeline বা log বন্ধ | নতুন build হয় না | Live version এর বয়স (3 ঘণ্টার বেশি হলে alert) | ঠিক করে আবার চালাও; log event গুলো রাখে (Kafka retention), তাই কিছু বাদ পড়ে না | Event রাখা আছে; count পিছিয়ে থেকে ধরে ফেলে | Suggestion পুরনো হয়, কিন্তু চলতে থাকে |
| Cache নষ্ট | প্রতিটা request trie তে হাঁটে | Hit rate কমে যায় | জরুরি কিছু না: process এর cache নিজে ভরে যায়; নষ্ট Redis পাশ কাটানো হয় | Cache এ এমন কোনো data নেই যেটা আর কোথাও নেই | CPU একটু বেশি, উত্তর একই |
| Region এর মধ্যে network partition | একটা region নতুন snapshot আনতে পারে না | Region গুলোর মধ্যে version এর ফারাক | Link ফেরা পর্যন্ত প্রতিটা region নিজের শেষ ভালো snapshot serve করে | শুধু পড়ার data, নষ্ট হওয়ার কিছু নেই | এক region এ একটু পুরনো suggestion |
| Duplicate log event | একটা search দুইবার গোনা হয় | Batch এ duplicate event_id | Batch event_id ধরে duplicate বাদ দেয় | Count ঠিক থাকে | কিছু না |
| Traffic spike (breaking news) | অনেক user একই ছোট prefix টাইপ করে | সেকেন্ডে request, CPU | বারবার আসা prefix CDN সামলায়; replica autoscale (ওদের শুধু snapshot file লাগে) | শুধু পড়া, ঝুঁকি নেই | Edge সামলালে কিছু না |
| কারসাজি (bot একটা কথা বারবার search করে) | একটা ভুয়া query suggestion এ উঠে আসে | অল্প কয়েকজন user বা IP থেকে spike; আলাদা user এর threshold | Raw search না, আলাদা user গোনো; rate limit; শব্দটা block করো | Blocklist আর threshold build এর সময়ই লাগে | সরানো পর্যন্ত একটা খারাপ suggestion |
সবগুলোর মধ্যে মিল: serve করার দিকটা একটা file এর শুধু-পড়ার copy, তাই প্রায় প্রতিটা failure শেষ হয় "শেষ ভালো version টা serve করো" দিয়ে, আর user তেমন কিছু টের পায় না। Client এর দিকেও autocomplete কে নিরাপদে ব্যর্থ হতে দিতে হবে: request ~300 ms এর বেশি নিলে browser শুধু dropdown দেখায় না, search তো চলছেই।
Reliability আর observability
- SLI: server এ আর আসল browser এ মাপা suggest latency (p50, p99); success rate; freshness (live trie version এর বয়স); কত ভাগ request edge থেকে উত্তর পেয়েছে।
- SLO: 30 দিনে 99.9% suggest request সফল আর 99% server এ 10 ms এর নিচে শেষ; live trie কখনো 3 ঘণ্টার বেশি পুরনো না।
- SLA: সাধারণত নেই; এটা ভেতরের feature। API হিসেবে বিক্রি করলে SLO এর চেয়ে ঢিলা রাখো।
- Metric: status ধরে request, cache hit rate (process এর ভেতরে আর CDN এ), প্রতিটা server এর trie version (ফারাক ধরতে), build এর সময় আর size, blocklist আর threshold কয়টা query বাদ দিল, prefix এর দৈর্ঘ্য ধরে খালি উত্তরের হার।
- Product metric: suggestion এর click-through rate, আর একটা বেছে নেওয়ার আগে মানুষ কয় অক্ষর টাইপ করে। Ranking আদৌ ভালো কিনা এগুলোই বলে।
- Alert: error budget দ্রুত পুড়লে আর "3 ঘণ্টায় একটাও সফল build নেই" হলে page; server গুলোর মধ্যে version এর ফারাক 15 মিনিটের বেশি থাকলে আর build এর size 20% এর বেশি বদলালে ticket।
- Log: suggest request এর sample রাখো (prefix personal হতে পারে, তাই অল্প দিন রাখো আর কখনো user ID এর সাথে জোড়া দিও না); build আর swap এর পুরো log।
- Tracing: CDN → LB → server জুড়ে sample trace; বেশি কাজে লাগে যখন latency আমাদের code থেকে না, network থেকে আসে।
- Health check: trie load হওয়ার আর golden prefix এর self-test পাস করার পরই readiness; liveness শুধু process দেখে।
- Disaster recovery: snapshot গুলো object storage এ file (version সহ, দ্বিতীয় region এ copy করা); নতুন region একটা নামিয়ে নিলেই serve শুরু করতে পারে। রাখা log থেকে count আবার বানানো যায়।
Security
- Content safety ই মূল ঝুঁকি। মানুষ যা টাইপ করে সেটা লাখো মানুষকে দেখানো হতে পারে। Blocklist build এর সময় লাগাও, আর জরুরি অবস্থায় সরানোর জন্য serve করার সময়ও (প্রতিটা উত্তরে দেখা হয় এমন ছোট একটা deny-set, তাতে rebuild ছাড়াই কয়েক মিনিটে সরানো যায়)।
- Privacy: অন্তত N জন আলাদা user (যেমন 20) search না করলে কোনো query suggest করবে না; এতে কেউ একবার টাইপ করা phone number, নাম, ঠিকানা বাইরে থাকে। Phone number, email বা ID number এর মতো দেখতে pattern ও বাদ দাও। Search log এ user ID hash করে রাখা হয়, অল্প দিন রাখা হয়, আর এই pipeline এর জন্য account এর সাথে জোড়া হয় না।
- কারসাজি: raw search না গুনে আলাদা user গোনো, user আর IP ধরে search submit এ rate limit, আর অল্প কয়েকটা উৎস থেকে হঠাৎ spike এর দিকে নজর রাখো।
- Input validation: prefix এর দৈর্ঘ্য সীমা (50), control character বাদ, lookup এর আগে Unicode normalise, আর শুধু চেনা
langমানা। - Rate limiting:
/v1/suggestএ IP আর client ধরে; edge এ caching ও বারবার আসা prefix এর বন্যা থেকে বাঁচায়। আজেবাজে prefix খালি উত্তর হিসেবে cache হয়। - Authorization: শুধু staff blocked শব্দ বদলাতে বা version roll করতে পারে; প্রতিটা বদল audit হয়।
- Encryption: সব জায়গায় TLS; snapshot আর log at rest encrypted।
Trade-off analysis
| সিদ্ধান্ত | বেছে নিয়েছি | বিকল্প | কেন এটা | কখন বিকল্পে যাবে |
|---|---|---|---|---|
| Serve করার structure | প্রতিটা node এ top-K সহ RAM এ trie | Elasticsearch/OpenSearch completion suggester (FST) | সবচেয়ে সহজ আর দ্রুত পথ, ranking পুরো নিজের হাতে, data memory তে ধরে | Fuzzy matching ও লাগবে, অনেক field, বা নিজের একটা service চালাতে চাও না |
| Ranking কখন | Offline এ top-K আগে থেকে হিসাব | Query এর সময় subtree থেকে ranking | ছোট prefix এ প্রতিটা key চাপায় লক্ষ লক্ষ node ঘাঁটতে হতো | খুব ছোট data, বা এত personal ranking যেটা আগে হিসাব করা যায় না |
| Freshness | ঘণ্টার batch + ছোট trend stream | Trie তে পুরোপুরি real-time update | Immutable snapshot সহজ, lock ছাড়া, rollback সহজ | যখন কয়েক মিনিটের দেরিতে সত্যিই টাকা যায় (live খেলা, stock ticker) |
| Data রাখার ধরন | Range shard ধরে trie snapshot | Key-value store এ সমতল prefix → list table | ঘন (prefix ভাগাভাগি), পাঠানোর জন্য একটা file | ঘন storage এর চেয়ে সহজ hash sharding আর প্রতি key তে update বেশি দরকার |
| Caching | Browser + CDN (60 s) + process এর LRU, version সহ key | CDN না; বা আগে shared Redis | ছোট prefix প্রচুর বার আসে; version সহ key তে মোছামুছি লাগে না | Personal উত্তর CDN এ যায় না; কয়েক shard জোড়া লাগানো উত্তরে Redis এর দাম ওঠে |
| গোনা | আলাদা user এর threshold + decay | সব সময়ের raw count | Privacy, কারসাজি ঠেকানো আর trend, এক নিয়মে | খুব ছোট site যেখানে threshold দিলে কিছুই থাকে না |
ডানে যত যাও operational খরচ তত বাড়ে: FST service, real-time trie, প্রতি key র store, প্রতিটা চালানোর জন্য একটা করে বাড়তি system। প্রতিটাই আলাদা একটা requirement এ ঠিক সিদ্ধান্ত, আর কোন requirement এ সেটা বলতে পারাই interviewer শুনতে চায়।
শেষ architecture
এই design requirement এর সাথে মেলে কারণ প্রতিটা অংশ একটা সংখ্যা বা একটা নিয়মের উত্তর: সেকেন্ডে 63,000 request আর 10 ms এর budget মেটে read পথে database ছাড়া memory র lookup দিয়ে; ছোট prefix এর বিপুল পুনরাবৃত্তি সামলায় CDN; "এক ঘণ্টার মধ্যে fresh" batch pipeline এর অনুমতি দেয়, আর immutable, version করা snapshot এর জন্য সেই pipeline প্রতি ঘণ্টায় নিশ্চিন্তে চালানো যায়, সাথে সাথে rollback সহ; content আর privacy এর নিয়ম লাগে যেখানে data বানানো হয় সেখানেই, জরুরি অবস্থার জন্য serve করার সময়ের deny-set সহ। Sharding আর trend stream আঁকা হয়েছে কারণ ধাপ 5 এ design টা সেদিকেই যায়, নতুন product এর এগুলো লাগে বলে না।
Interview এর আলোচনা
1. Database এ সোজা LIKE 'prefix%' চালালে কী সমস্যা?
উত্তর
Index মিলে যাওয়া row গুলো খুঁজে দেয়, কিন্তু "d" এর মতো ছোট prefix এ সেগুলো লক্ষ লক্ষ, আর top 10 নিতে তবুও popularity ধরে sort করতে হয়। সেকেন্ডে কয়েক দশ হাজার request এ এটা অসম্ভব। প্রতিটা prefix এর top 10 আগে থেকে হিসাব করে রাখলে প্রতিটা request একটা key lookup হয়ে যায়। ছোট site এর জন্য database query ঠিক আছে, আর সেটা বলাও উচিত।
2. Trie কত বড়, memory তে ধরবে?
উত্তর
আন্দাজ: 1 কোটি suggest করার মতো query × ~20 অক্ষর মানে সর্বোচ্চ 20 কোটি node, prefix ভাগাভাগিতে মোটামুটি 6 কোটি। প্রতিটা node এ 4 byte এর 10 টা query ID আর কিছু overhead মিলে মোটামুটি 5 GB, একটা server এ ধরে যায়, তাই প্রতিটা replica পুরোটা রাখতে পারে। না ধরলে compress করো (radix tree, FST), শুধু trie এর উপরের দিকে list রাখো, বা prefix range ধরে shard করো।
3. Breaking news এর জন্য suggestion fresh থাকে কীভাবে?
উত্তর
দুইটা পথ। Decay সহ score এর ঘণ্টার batch সাধারণ trend সামলায়। মিনিটের freshness এর জন্য একটা stream job গত 15 মিনিট গোনে, যে query গুলো স্বাভাবিকের চেয়ে অনেক দ্রুত বাড়ছে সেগুলো ধরে, আর ছোট একটা boost list পাঠায় যেটা server গুলো পরের batch পর্যন্ত উত্তরে মেশায়। আলাদা রাখলে মূল build সহজ আর নিরাপদ থাকে।
4. Traffic না থামিয়ে trie update করবে কীভাবে?
উত্তর
কখনো জায়গায় বদলাবে না। Offline এ পুরো নতুন trie বানাও, version করা file হিসেবে পাঠাও, পুরনোটার পাশে load করো, তারপর একটা atomic pointer swap করো। যে request গুলো পুরনো version এ শুরু হয়েছিল সেগুলো সেখানেই শেষ হয়। Cache key তে version থাকে, তাই swap এর পর কোনো পুরনো উত্তর আসে না, আর rollback মানে আবার swap করা।
5. "dhkaa" এর মতো typo সামলাবে কীভাবে?
উত্তর
ঠিক prefix এর trie এটা পারে না। উপায়: সাধারণ ভুল বানানগুলো ঠিক query র alias হিসেবে যোগ করো (log থেকে শেখা, যেখানে user নিজেই ঠিক করেছে), বা সীমিত edit distance দিয়ে fuzzy matching (FST এর উপর Levenshtein automata, যেমন Lucene এ)। Fuzzy lookup বেশি খরুচে, তাই সাধারণত শুধু তখনই ব্যবহার হয় যখন ঠিক prefix এ খুব কম result আসে।
6. Suggestion personal করবে কীভাবে?
উত্তর
Popularity র জন্য global trie রাখো, আর একসাথে user এর ছোট একটা list আনো (সাম্প্রতিক search, user ধরে রাখা); server এ সহজ একটা scoring নিয়মে দুইটা মেশাও। মেশানো উত্তর আর CDN এ cache করা যায় না, তাই কয়েক অক্ষরের পর থেকে বা শুধু login করা user এর জন্য personal করো, আর global অংশ আলাদা করে cache করো।
7. আমাদের user রা বাংলা আর Banglish এ টাইপ করে। কী বদলাবে?
উত্তর
Unicode normalise (NFC) করো, যাতে আলাদা keyboard এ টাইপ করা একই শব্দ মেলে; প্রতিটা language বা script এর জন্য আলাদা trie বানাও; আর transliteration ভাবো, যাতে "dhaka" টাইপ করলে "ঢাকা" র query ও আসতে পারে। Prefix এর দৈর্ঘ্যের সীমা byte না, অক্ষর গুনবে। Design এর বাকিটা একই।
8. Client কীভাবে সাহায্য করে?
উত্তর
Debounce (শেষ key এর পর ~50–100 ms অপেক্ষা), নতুন key এলে রাস্তায় থাকা request cancel, session জুড়ে উত্তর cache, আর ক্রম ভেঙে আসা response বাদ। এগুলো মিলে প্রায়ই request অর্ধেকের বেশি কমিয়ে দেয়।
সাধারণ ভুল: প্রতিটা key চাপায় database এ query; submit করা search না গুনে key চাপা গোনা; lock নিয়ে জায়গায় trie update; CDN আর Cache-Control ভুলে যাওয়া; আপত্তিকর বা personal suggestion নিয়ে কোনো plan না থাকা; সব সময়ের raw count, ফলে trend কখনো আসে না; আন্দাজ করার আগেই Kafka আর stream processing আঁকা।
Interviewer যা দেখে: সংখ্যা সহ একটা latency budget, ছোট prefix এর কারণে query এর সময় ranking যে অসম্ভব সেটা ধরতে পারা, offline এ বানানো আর online এ serve করার পরিষ্কার ভাগ, নতুন data নিরাপদে চালু করার একটা উপায়, ঠিক layer এ caching আর পুরনো থাকা নিয়ে যুক্তি, আর safety ও privacy নিয়ে সৎ মনোযোগ।
নিজে design করে solution টা বুঝেছো? Mark করো, তাহলে progress ট্র্যাক হবে।