Practice · High Level Design · Work in the Background
Design a Notification System
লক্ষ লক্ষ push, SMS আর email notification পাঠানো: প্রতিটা channel এর আলাদা queue, spam না করে retry, user এর পছন্দ, আর template।
Learn mode: পুরো solution নিচে দেখানো আছে।
Problem এর পরিচয়
| Difficulty | Intermediate |
| সময় | design আর core বানাতে 2–3 ঘণ্টা; interview তে মোটামুটি 45 মিনিট |
| আগে যা জানা দরকার | Async messaging, designing for failure, idempotency মানে কী |
| Concept | তাড়াতাড়ি নিয়ে নাও আর পরে পাঠাও, প্রতিটা channel আর priority র আলাদা queue, idempotency key, exponential backoff আর jitter সহ retry, dead-letter queue, user এর পছন্দ আর quiet hours, rate limit, campaign fan-out, provider failover, delivery tracking |
| বাস্তবে | দোকান আর courier এর order আর delivery update, bank এর OTP SMS, ride-hailing এর "আপনার driver চলে এসেছে", app এর push notification, marketing email, স্কুল আর হাসপাতালের SMS alert |
যেকোনো product শেষমেশ notification পাঠায়, আর প্রায় সব team প্রথমে সহজ পথে করে: order service সরাসরি SMS gateway কে call করে। এটা চলে যতক্ষণ না gateway ধীর হয়, একটা campaign এসে ওটাকে ডুবিয়ে দেয়, একটা retry একই OTP তিনবার পাঠায়, বা কেউ রাত 3 টায় sale এর SMS পায়। এই problem হলো সেই shared service নিয়ে যেটা এসব ঠিক করে, আর এর মূল শিক্ষা যেকোনো background কাজে খাটে: request তাড়াতাড়ি নিয়ে নাও, ধীর আর অনির্ভরযোগ্য অংশটা পরে করো, আর প্রতিটা ধাপ এমন বানাও যাতে বারবার করলেও ক্ষতি না হয়।
Problem statement
নুসরাত ঢাকার একটা দোকান থেকে একটা phone order করেছে। পরের দুই দিনে কয়েকটা system তাকে কিছু জানাবে: order service ("আপনার order পাঠানো হয়েছে"), courier ("আজকে পৌঁছাবে"), payment service ("refund হয়ে গেছে"), আর marketing team ("Eid sale, 30% ছাড়")। এর কিছু তাকে পেতেই হবে (login এর OTP), কিছু সে বন্ধ করে রেখেছে (marketing email), আর জরুরি না হলে কোনোটাই যেন রাতে তার ঘুম না ভাঙায়।
কী বানাচ্ছি: একটা notification service, যেটাকে ভেতরের যেকোনো service বলতে পারে "user Y কে template X পাঠাও", আর সেটা user এর পছন্দমতো push, SMS বা email এ পৌঁছে দেয়, নির্ভরযোগ্যভাবে, duplicate ছাড়া, আর যে call করেছে তাকে আটকে না রেখে।
কে ব্যবহার করে: এক দিকে ভেতরের service গুলো (order, payment, auth, chat, courier) আর marketing team এর campaign tool; অন্য দিকে কয়েক কোটি user; আর support team, যাদের জানতে হয় "customer কি SMS টা পেয়েছিল?"।
কী সমস্যা মেটায়: প্রতিটা team কে আর নিজের retry, template, opt-out এর নিয়ম আর provider integration লিখতে হয় না; user রা একই রকম অভিজ্ঞতা পায়; আর কী পাঠানো হলো সেটা দেখার একটাই জায়গা থাকে।
Technically কোথায় মজা:
- অনির্ভরযোগ্য, ধীর provider: SMS gateway timeout দেয়, push service throttle করে, email provider address ফিরিয়ে দেয়। Call এ কয়েকশো millisecond লাগে, আর আলাদা আলাদা ভাবে fail করে।
- Duplicate: caller retry করে, queue আবার দেয়, worker মাঝপথে পড়ে যায়। দুইটা OTP এলে মানুষ বিভ্রান্ত হয়; দুইটা "টাকা পেয়েছি" এলে ভয় পায়।
- হঠাৎ চাপ: আড়াই কোটি user এর campaign যেন login এর OTP দেরি না করায়।
- প্রতি user এর নিয়ম: opt-out, quiet hours, দিনের সীমা, ভাষা, কার কোন channel আছেই।
- খরচ: প্রতিটা SMS এ টাকা লাগে; push আর email প্রায় ফ্রি। কোন কাজে কোন channel, সেটা design ই ঠিক করে।
Requirement
Functional requirement
- Send: ভেতরের একটা service পাঠায়
{user, template, params, idempotency key}; service টা template এর channel গুলোতে (push, SMS, email) পৌঁছে দেয়। - পছন্দ: user প্রতিটা channel এ marketing বন্ধ করতে পারে আর quiet hours ঠিক করতে পারে; transactional message (OTP, order আর payment update) সবসময় যাবে।
- Template: message আসে version করা template থেকে, parameter আর অনুবাদ সহ (বাংলা আর English)।
- নির্ভরযোগ্যতা: সাময়িক failure হলে retry; যেগুলো পৌঁছানো যায় না সেগুলো dead-letter queue তে যায়, দেখা আর আবার পাঠানোর জন্য।
- Retry থেকে duplicate না: একই idempotency key কখনো দ্বিতীয় notification বানাবে না।
- Campaign: marketing লাখো মানুষের একটা segment এ পাঠাতে পারবে, provider যতটা নিতে পারে সেই গতিতে।
- Tracking: প্রতিটা message এর অবস্থা (queued, sent, delivered, failed) দেখা যাবে।
- Scope এর বাইরে: app এর ভেতরের inbox UI, message এর লেখা নিয়ে A/B test, আর নিজের SMS বা push infrastructure বানানো (আমরা provider ব্যবহার করি)।
Non-functional requirement
| Requirement | লক্ষ্য | এখানে কেন গুরুত্বপূর্ণ |
|---|---|---|
| Accept latency | API call এর p99 50 ms এর নিচে | Caller (checkout, login) যেন SMS gateway এর জন্য বসে না থাকে |
| Delivery latency | OTP ~2 s এর মধ্যে পাঠানো; order update ~1 মিনিটে; campaign কয়েক ঘণ্টায় | একেক message এর তাড়া একেক রকম |
| Throughput | দিনে ~10 কোটি message, campaign এর সময় সেকেন্ডে ~10,000 | নিচে হিসাব |
| Availability | নেওয়ার জন্য 99.99%; পাঠানো পিছিয়ে থাকতে পারে | একটা request হারানো এক মিনিট দেরিতে পাঠানোর চেয়ে খারাপ |
| Durability | নেওয়া notification কখনো চুপচাপ হারাবে না | হয় পৌঁছাবে, নয়তো dead-letter queue তে দেখা যাবে |
| Duplicate না | স্বাভাবিক অবস্থায় প্রতি idempotency key তে সর্বোচ্চ একবার পাঠানো | বারবার OTP আর payment message এ বিশ্বাস কমে |
| আলাদা রাখা | একটা ধীর বা ভাঙা channel বাকিগুলোকে ছোঁবে না; campaign transactional message দেরি করাবে না | Failure ছোট রাখতে হবে |
| Privacy আর খরচ | Opt-out আর সীমা মানা; push এ চললে SMS না | আইন আর টাকা |
Accept latency, durability, duplicate না হওয়া আর আলাদা রাখা, এগুলোই design ঠিক করে। Delivery latency ঢিলা, শুধু OTP বাদে, আর সেজন্যই OTP এর নিজের আলাদা পথ।
Hint 1: caller কীসের জন্য অপেক্ষা করে?
Checkout service তোমাকে call করল আর SMS gateway উত্তর দিতে 800 ms নিল, কখনো 30 s। Checkout কি এর জন্য বসে থাকবে? উত্তর দেওয়ার আগে API কে সবচেয়ে কম কতটুকু করতে হবে?
Hint 2: duplicate
Caller এর request timeout হলো, সে আবার পাঠাল। Gateway SMS নিয়ে নেওয়ার পর কিন্তু message টা done দাগানোর আগে তোমার worker পড়ে গেল। Duplicate কোথা থেকে কোথা থেকে আসে, আর প্রতিটা কে কী থামায়?
Hint 3: একটা ধীর channel
Push, SMS আর email যদি একটা queue আর একটা worker pool ভাগাভাগি করে, তাহলে SMS gateway এর খারাপ দিনে push message গুলোর কী হয়?
Hint 4: যে retry অবস্থা আরো খারাপ করে
একটা gateway চাপে আছে, আর প্রতিটা worker সাথে সাথে retry করছে। এতে gateway এর কী হয়? কতক্ষণ অপেক্ষা করা উচিত, আর কখন হাল ছাড়বে?
Capacity আন্দাজ
ধরে নেওয়া (মুখে বলা, গোল সংখ্যা):
- 5 কোটি user; marketing এর জন্য পৌঁছানো যায় মোটামুটি আড়াই কোটি (opt in করেছে, channel আছে)।
- দিনে: 1 কোটি order আর delivery update, 50 লাখ OTP, 3.5 কোটি app push (chat, ride এর অবস্থা), আর আড়াই কোটি করে দুইটা marketing campaign: মোট প্রায় দিনে 10 কোটি message।
- Channel এর ভাগ: 6.5 কোটি push, 2.5 কোটি email, 1 কোটি SMS (SMS এ টাকা লাগে, তাই শুধু OTP আর জরুরি update এ)।
- একটা campaign মোটামুটি এক ঘণ্টায় যায়; স্বাভাবিক traffic এর peak গড়ের 3 গুণ।
Traffic
| Formula | ফল | |
|---|---|---|
| সেকেন্ডে message (গড়) | 10 কোটি ÷ 86,400 | ≈ 1,200 / s |
| স্বাভাবিক peak | 1,200 × 3 | ≈ 3,500 / s |
| Campaign | আড়াই কোটি ÷ 3,600 | ≈ 7,000 / s |
| সবচেয়ে খারাপ (স্বাভাবিক peak এর মধ্যে campaign) | 3,500 + 7,000 | ≈ 10,000 / s |
| সেকেন্ডে SMS (গড়) | 1 কোটি ÷ 86,400 | ≈ 115 / s |
| সেকেন্ডে API request (গড়) | ~7 কোটি request ÷ 86,400 (একটা request 2 channel এ যেতে পারে) | ≈ 800 / s |
Storage
| Formula | ফল | |
|---|---|---|
| দিনে status record | 10 কোটি × 300 B | দিনে 30 GB |
| 90 দিন রাখা, 3 copy | 30 GB × 90 × 3 | ≈ 8 TB |
| Dedup key (24 ঘণ্টার TTL) | 7 কোটি × ~60 B | Redis এ ≈ 4 GB |
| Queue তে campaign এর জমা | আড়াই কোটি × 1 KB | ≈ 25 GB (Kafka সহজেই রাখে) |
| পছন্দ আর contact | 5 কোটি × ~200 B | ≈ 10 GB |
Worker (Little's law): একসাথে চলতে থাকা call = rate × প্রতি call এর সময়। সেকেন্ডে 10,000 পাঠানো আর প্রতি provider call এ ~200 ms হলে যেকোনো মুহূর্তে মোটামুটি 2,000 টা call চলছে। Asynchronous I/O দিয়ে 20 টা worker instance, প্রতিটা একসাথে 100 টা call, peak সামলে নেয়। আসল সীমা সাধারণত আমাদের CPU না, provider এর rate limit: একটা SMS account হয়তো সেকেন্ডে কয়েকশোর বেশি নেয় না, তাই worker কে সেই গতিতে throttle করতে হয়।
সংখ্যাগুলো কী বলছে: একটা queue এর জন্য পরিমাণটা মাঝারি, কিন্তু API call এর ভেতরে করার জন্য অনেক ধীর আর অনির্ভরযোগ্য। Storage এর বড় অংশ status log। কঠিন অংশ size না, আচরণ: duplicate, হঠাৎ চাপ, ধীর provider আর প্রতি user এর নিয়ম।
API design
| Method আর path | Request | সফল হলে | Error |
|---|---|---|---|
POST /v1/notifications | header Idempotency-Key: evt-2; {"user_id": "nusrat", "template": "order_shipped", "params": {"order": "BD-7790"}, "priority": "normal"} | 202 {"id": "evt-2", "queued": ["sms"], "skipped": ["push: no device"]}; একই key আবার এলে 200 আর প্রথমবারের ফল | 400 অচেনা template বা param নেই, 404 অচেনা user, 429 caller এর quota বা marketing এর সীমা শেষ |
POST /v1/campaigns | {"template": "eid_sale", "segment": "active-30d", "send_at": "...", "params": {...}} | 202 {"campaign_id": "camp-7"} | 400, 403 (শুধু marketing tool) |
GET /v1/notifications/{id} | কিছু না | 200 {"sms": {"status": "delivered", "attempts": 3, "provider": "A"}} | 404 |
PUT /v1/users/{id}/preferences | {"marketing": {"email": false}, "quiet_hours": {"from": "22:00", "to": "08:00", "tz": "Asia/Dhaka"}} | 200 | 400 |
POST /v1/devices | {"user_id": "...", "token": "...", "platform": "android"} | 201 | 400 |
POST /webhooks/{provider} | provider এর delivery receipt | 200 | 401 signature ভুল |
200 OKনা,202 Accepted: API কথা দেয় যে notification টা durable ভাবে queue তে আছে, পৌঁছে গেছে সেটা না। এজন্যই সে millisecond এ উত্তর দিতে পারে।- Idempotency key: caller বেছে নেয়, আর এটা business event এর সাথে বাঁধা (
order-BD-7790-shipped), প্রতি চেষ্টায় random না। একই key দিয়ে retry হলে প্রথম ফলটাই ফেরত যায়, নতুন কিছু যায় না। - Authentication: service থেকে service (mTLS বা signed token); প্রতিটা caller এর quota আছে, আর শুধু marketing tool campaign বানাতে বা marketing template পাঠাতে পারে।
- Caller template এর নাম পাঠায়, লেখা না: কথা, অনুবাদ আর আইনি footer এক জায়গায় থাকে, আর একটা service hack হলেও সে সব user কে যা ইচ্ছা লিখে পাঠাতে পারে না।
- Webhook provider sign করে পাঠায় আর আমরা যাচাই করি; এগুলো status কে "sent" থেকে "delivered" বা "failed" করে।
Database design
Access pattern: প্রতিটা request এ পছন্দ আর contact পড়া হয় (বেশি পড়া, ছোট, user ধরে)। Template সারাক্ষণ পড়া হয়, কদাচিৎ বদলায়। প্রতিটা message এ status record লেখা হয় আর কয়েকবার update হয় (বেশি লেখা, notification বা user ধরে খোঁজা)। Dedup key প্রতি request এ একবার দেখা হয় আর একদিন পর মুছে যায়।
-- Preferences and contacts (PostgreSQL, cached in memory with a short TTL).
CREATE TABLE user_prefs (
user_id BIGINT PRIMARY KEY,
locale TEXT NOT NULL DEFAULT 'bn',
tz TEXT NOT NULL DEFAULT 'Asia/Dhaka',
quiet_from TIME, -- marketing only
quiet_to TIME,
optout_push BOOLEAN NOT NULL DEFAULT false, -- marketing opt-outs per channel
optout_sms BOOLEAN NOT NULL DEFAULT false,
optout_email BOOLEAN NOT NULL DEFAULT false
);
CREATE TABLE contacts (
user_id BIGINT NOT NULL,
channel TEXT NOT NULL, -- push, sms, email
address TEXT NOT NULL, -- device token, phone, email
verified BOOLEAN NOT NULL,
active BOOLEAN NOT NULL DEFAULT true, -- false after the provider says "invalid"
PRIMARY KEY (user_id, channel, address)
);
-- Templates: versioned, one row per language and channel.
CREATE TABLE templates (
name TEXT, version INT, locale TEXT, channel TEXT,
kind TEXT NOT NULL, -- transactional | marketing
body TEXT NOT NULL,
PRIMARY KEY (name, version, locale, channel)
);
-- Status of every message (Cassandra / DynamoDB style: partition by user, newest first).
-- notifications_by_user: PRIMARY KEY ((user_id), created_at DESC, message_key)
-- columns: channel, template, status, attempts, provider, provider_msg_id, updated_at
-- by_provider_msg: PRIMARY KEY (provider, provider_msg_id) -> user_id, message_key (for webhooks)
-- Dedup: Redis SET dedup:<idempotency key> <result> NX EX 86400
- পছন্দের জন্য PostgreSQL কেন: ছোট (10 GB), relational, user রা settings page থেকে বদলায়, আর constraint দরকার। Read আসে cache থেকে, তাই database এর উপর চাপ কম।
- Status এর জন্য wide-column store কেন: দিনে প্রায় 10 কোটি insert আর তার update, সহজ key lookup, পুরনো record এর জন্য স্বাভাবিক TTL, আর সহজে horizontal scaling। ছোট scale এ partition করা PostgreSQL table পুরোপুরি ঠিক আছে; লেখার পরিমাণ বা কতদিন রাখতে হবে সেটা যখন যন্ত্রণা দেয় তখন বদলাও।
- Dedup এর জন্য Redis কেন: millisecond এর কমে atomic একটা "না থাকলে set করো, expiry সহ", ঠিক এই কাজটাই দরকার, আর key গুলোর একদিন বাঁচলেই চলে। Redis স্থায়ী কোনো কিছুর source of truth না, তাই এটা হারালে অসুবিধা, data হারানো না (Failure দেখো)।
- Partitioning: status কে
user_idধরে (তাতে "এই user এর notification" একটাই partition); প্রতিটা topic এর ভেতরে queue কে user ID ধরে ভাগ, যাতে একজন user এর message গুলো ক্রমে থাকে।
Architecture এর ছবি
| Component | কেন আছে | কার সাথে যুক্ত |
|---|---|---|
| Producer (order, auth, courier, campaign) | ঠিক করে যে একজন user কে কিছু জানাতে হবে | → Notify API |
| Notify API | যাচাই, dedup, পছন্দ আর সীমা মানা, render, queue তে রাখা; দ্রুত 202 দেয় | → Redis, prefs, queue |
| Redis | Dedup key (24 ঘণ্টার expiry সহ SET NX) আর প্রতি user এর rate-limit counter | API পড়ে আর লেখে |
| Prefs DB + cache | Opt-out, quiet hours, ভাষা, contact, template | API পড়ে |
| প্রতি channel এর queue | Durable buffer; প্রতিটা channel কে নিজের গতিতে চলতে দেয় | API → queue → worker |
| প্রতি channel এর worker | Provider কে call করে, status লেখে, retry বা dead-letter করে | → provider, delivery log, DLQ |
| Provider | Push এর জন্য APNs/FCM, SMS gateway, একটা email service | বাইরের |
| Delivery log | প্রতিটা message এর অবস্থা, support আর analytics এর জন্য | worker আর webhook লেখে |
| Dead letter | যেগুলো একেবারে fail করেছে; alert, ঠিক করার পর আবার পাঠানো | worker লেখে |
যেগুলো ইচ্ছা করে রাখিনি: API পথে প্রতি message এ database write (durability র জন্য শুধু queue তে লেখাই দরকার), প্রথম দিন থেকে প্রতিটা channel এর আলাদা microservice (এক codebase, আলাদা queue আর worker pool), বা নিজের push বা SMS infrastructure।
একটা request এর জীবন
একটা request নেওয়া
- Order service → LB → Notify API:
Idempotency-Key: evt-2সহPOST /v1/notifications। - API caller এর quota, template আর parameter দেখে (parameter না থাকলে
400, কখনো ফাঁকা জায়গা সহ message না)। - Redis এ
SET dedup:evt-2 NX EX 86400। Key আগে থেকে থাকলে রাখা ফলটা200সহ ফেরত দাও: এটা retry। - পছন্দ আর contact পড়ো (cache থেকে)। Channel ঠিক করো: template বলছে push + SMS; নুসরাতের device token নেই, তাই push বাদ।
- শুধু marketing: opt-out, quiet hours (window শেষ হওয়া পর্যন্ত পিছিয়ে দাও) আর দিনের সীমা দেখো।
- User এর ভাষায় template render করো আর প্রতি channel এ একটা message রাখো (
evt-2:sms), user ধরে key করা, যাতে একজনের message গুলো ক্রমে থাকে। - কী queue তে গেল আর কী বাদ গেল সেটা সহ
202দাও। মোট: কয়েক millisecond, কোনো provider call নেই।
একটা message পাঠানো (worker এর দিক)
- একটা SMS worker queue থেকে
evt-2:smsনেয় (lease এ নেয়, worker acknowledge না করা পর্যন্ত মোছা হয় না)। - Status store দেখে: এই message key আগেই
sentহলে acknowledge করে থেমে যায়। Crash এর পর আবার আসা message এভাবে ধরা পড়ে। - Message টাকে
sendingদাগায় আর timeout (যেমন 5 s) সহ gateway কে call করে; provider idempotency key নিলে message key টা পাঠায়। - সফল: provider এর message ID সহ
sentলেখো, queue এর message acknowledge করো। - সাময়িক failure (timeout, 5xx, throttled):
base × 2^(attempt-1)আর একটু random jitter পরে retry ঠিক করো, আসলটা acknowledge করো। সর্বোচ্চ চেষ্টা শেষ হলে dead-letter queue তে সরাও। - স্থায়ী failure (ভুল number, অচেনা address): retry না। Dead-letter করো আর contact টা inactive দাগাও, যাতে ওই address এ আর চেষ্টা না হয়।
- পরে provider এর webhook জানায়
delivered(বাfailed);by_provider_msgদিয়ে status update হয়।
Component গুলো গভীরে
Duplicate: কোথা থেকে আসে আর প্রতিটা কে কী থামায়
| উৎস | উদাহরণ | প্রতিরক্ষা |
|---|---|---|
| Caller এর retry | Checkout এর request timeout, সে আবার call করে | API তে idempotency key + SET NX |
| একসাথে দুই copy | Retry এর ঝড়ে একই request 50 টা একসাথে | যাচাই আর নেওয়া একটাই atomic ধাপ (Go এর TestDuplicateRequestsQueueOnce -race সহ 50 টা একসাথে চালায়) |
| Queue আবার দেয় | Broker at least once দেয় | পাঠানোর আগে worker status store দেখে |
| পাঠানোর পর worker crash | Gateway SMS নিয়ে নিয়েছে, লেখার আগে worker মারা গেছে | Provider সমর্থন করলে তার idempotency key; নাহলে এটাই সেই ছোট ফাঁক যেটা মেনে নিতে হয় |
ফোন পর্যন্ত exactly-once delivery কোনো broker setting দিয়ে পাওয়া যায় না: শেষ ধাপটা বাইরের একটা call, যেটা সফল হতে পারে অথচ আমাদের acknowledgement fail করতে পারে। আমরা যা পারি: প্রতিটা ধাপ idempotent বানানো, আর ঝুঁকির ফাঁকটা ছোট করে আনা "provider এর উত্তর আর আমাদের status লেখার মাঝের কয়েক millisecond এ crash" পর্যন্ত। এই সৎ উত্তরটা interviewer পছন্দ করে।
প্রতি channel এ একটা queue, আর জরুরি message এর আলাদা lane
আলাদা queue মানে প্রতিটা channel এর নিজের গতি, provider এর rate limit মেপে নিজের worker সংখ্যা, আর নিজের failure এর গণ্ডি। Priority তেও একই কথা: OTP আর security alert যায় একটা high-priority topic এ, নিজের worker সহ, তাই normal queue তে বসে থাকা আড়াই কোটি message এর campaign কখনো login code দেরি করায় না। Kafka র ভাষায়: প্রতি channel (আর প্রতি priority) এ একটা topic, user ID ধরে partition।
Retry, backoff আর dead-letter queue
সাথে সাথে retry করলে চাপে থাকা provider আরো খারাপ হয়: সব worker একসাথে ওকে পেটাতে থাকে। Exponential backoff চেষ্টাগুলোর মাঝে ফাঁক বাড়ায় (1, 2, 4, 8..., আমাদের demo তে সেকেন্ড; production এ মিনিট), আর jitter (অপেক্ষার একটা random অংশ) একসাথে fail করা হাজারো message কে একসাথে retry করতে দেয় না। সর্বোচ্চ সংখ্যক চেষ্টার পর, বা স্থায়ী error হলে, message যায় dead-letter queue তে: হারায় না, DLQ বাড়লে alert বাজে, আর কারণ ঠিক হলে কেউ আবার পাঠাতে পারে। উপরে থাকে একটা circuit breaker: কিছুক্ষণ একটা provider বেশিরভাগ call এ fail করলে অল্প সময়ের জন্য ওকে call করা বন্ধ (বা দ্বিতীয় provider এ চলে যাও), retry জমাতে থাকার বদলে।
পিছিয়ে দেওয়া retry কে কোথাও অপেক্ষা করতে হয়। উপায়: প্রতিটা delay এর জন্য একটা "retry" topic (sms-retry-1m, sms-retry-10m) যেটা worker শুধু সময় হলে পড়ে, বা due time ধরে সাজানো একটা scheduled-job table/sorted set। আমাদের core প্রতিটা message এ একটা Due সময় রাখে, এটাই sorted-set এর ছোট রূপ।
পছন্দ, quiet hours আর সীমা
দুই ধরনের message, আলাদা নিয়ম:
| Transactional (OTP, order, payment) | Marketing | |
|---|---|---|
| Opt-out | হয় না (OS এ push permission ছাড়া) | প্রতি channel এ |
| Quiet hours | মানা হয় না: রাত 2 টায় OTP আসবে এটাই প্রত্যাশিত | user এর time zone এ window শেষ হওয়া পর্যন্ত পিছিয়ে যায় |
| দিনের সীমা | শুধু অপব্যবহার ঠেকানোর সীমা | যেমন user প্রতি দিনে 1 টা |
নিয়মগুলো নেওয়ার সময় চলে (opt-out, সীমা), আর যে message গুলো অপেক্ষা করেছে সেগুলোর জন্য পাঠানোর সময় আবার দেখা হয় (campaign queue তে থাকার সময় user opt out করতে পারে)। পছন্দ অল্প সময় cache থাকে (কয়েক সেকেন্ড থেকে এক মিনিট); আবার দেখার নিয়মটা সেই পুরনো থাকাকে নিরীহ করে দেয়।
Campaign: বন্যা না বানিয়ে fan-out
আড়াই কোটি user এর campaign মানে আড়াই কোটি প্রাপক সহ একটা API call না। একটা scheduler segment টা page ধরে পড়ে, ধরো 10,000 করে batch এ request বানায়, camp-7:<user_id> এর মতো idempotency key সহ (যাতে scheduler restart হলে দুইবার না যায়), আর নিয়ন্ত্রিত গতিতে normal-priority queue তে দেয়। মোট গতি provider এর সীমার নিচে, আর এমন যাতে বাকি traffic দেরি না হয়। Campaign থামানো আর আবার চালু করা যায়, কারণ অগ্রগতি মানে শুধু "segment এর কতদূর এলাম"।
Provider failover
প্রতিটা channel এ দুইটা provider থাকতে পারে। Worker প্রতিটা provider এর error rate আর latency দেখে; primary র circuit breaker খুলে গেলে traffic secondary তে যায়। SMS এ এতে খরচ আর নির্দিষ্ট operator এর delivery সমস্যাতেও সুবিধা হয়। Message key message এর সাথেই যায়, তাই যে provider ই পাঠাক, status আর webhook মিলে যায়।
চালিয়ে দেখার মতো core
এই program টা design এর মূল অংশ, এক বসায় পড়ে ফেলার মতো ছোট: parameter সহ template, contact, opt-out আর quiet hours সহ user, API এর দিকের Send যেটা dedup আর নিয়ম একসাথে atomic ভাবে মানে, প্রতি channel এ একটা queue, আর worker যারা provider কে call করে, সাময়িক failure এ exponential backoff সহ retry করে আর বাকিগুলো dead-letter করে। একটা simulated clock সময়টা একদম ঠিক রাখে, আর নকল provider গুলো ঠিক সময়মতো fail করে।
notify/
├── go.mod
├── model.go Channel, Kind, Template, User, Request, Message, Render, Clock
├── provider.go Outcome, Provider, FakeProvider (scripted failures)
├── service.go Service: Send (dedup, prefs, quiet hours, limits), Work/Step (retry, DLQ)
├── notify_test.go duplicates under -race, backoff timing, DLQ, quiet hours, opt-outs, 4 workers
└── cmd/demo/main.go order updates, a duplicate, a flaky gateway, a campaign at night
// model.go
package notify
import (
"errors"
"fmt"
"strings"
)
// Channel is one way to reach a person. Each channel has its own queue and its own provider,
// so a slow SMS gateway never holds up push or email.
type Channel string
const (
Push Channel = "push"
SMS Channel = "sms"
Email Channel = "email"
)
// Kind decides which rules apply. Users can't opt out of transactional messages (an order
// update, a one-time code), but marketing respects opt-outs, quiet hours and a daily limit.
type Kind int
const (
Transactional Kind = iota
Marketing
)
// Template is a message shape: which channels it goes to and its text with {placeholders}.
type Template struct {
Kind Kind
Channels []Channel
Text string
}
// User is what the preference service knows about a person.
type User struct {
Contacts map[Channel]string // device token, phone number, email address
OptOut map[Channel]bool // marketing opt-outs per channel
QuietFrom int // quiet hours for marketing, e.g. 22 to 8 (0, 0 = none)
QuietTo int
}
// Request is what another service sends us: "tell this user about this event".
// ID is the idempotency key: the same ID twice means the same notification.
type Request struct {
ID, User, Template string
Params map[string]string
}
// Message is one notification on one channel, waiting in that channel's queue.
type Message struct {
Key string // request ID + channel, e.g. "evt-1:sms"
Channel Channel
To string
Body string
Kind Kind
Attempt int // attempts made so far
Due int64 // simulated seconds; not sent before this
seq int // enqueue order, breaks ties between equal Due times
}
var (
ErrDuplicate = errors.New("duplicate request, already accepted")
ErrUnknownUser = errors.New("unknown user")
ErrUnknownTemplate = errors.New("unknown template")
ErrRateLimited = errors.New("marketing limit reached for today")
)
// Render fills {name} placeholders; a missing value is an error, never a blank in a message.
func Render(text string, params map[string]string) (string, error) {
var b strings.Builder
for {
i := strings.IndexByte(text, '{')
if i < 0 {
b.WriteString(text)
return b.String(), nil
}
j := strings.IndexByte(text[i:], '}')
if j < 0 {
return "", fmt.Errorf("unclosed placeholder in %q", text)
}
name := text[i+1 : i+j]
v, ok := params[name]
if !ok {
return "", fmt.Errorf("missing parameter %q", name)
}
b.WriteString(text[:i])
b.WriteString(v)
text = text[i+j+1:]
}
}
// Clock formats simulated seconds as "D1 21:59:50".
func Clock(t int64) string {
s := t % 86400
return fmt.Sprintf("D%d %02d:%02d:%02d", t/86400+1, s/3600, s/60%60, s%60)
}
// provider.go
package notify
import "sync"
// Outcome is what a provider (APNs/FCM, an SMS gateway, an email service) said.
type Outcome int
const (
OK Outcome = iota
Transient // timeout, 5xx, throttled: worth retrying
Permanent // invalid number, unknown address: retrying won't help
)
func (o Outcome) String() string {
switch o {
case OK:
return "delivered"
case Transient:
return "failed (timeout)"
default:
return "rejected (bad address)"
}
}
// Provider sends one message on one channel.
type Provider interface {
Send(m Message) Outcome
}
// FakeProvider plays back scripted outcomes per address (attempt 1, 2, ...); after the
// script runs out, sends succeed. It also counts sends per message key.
type FakeProvider struct {
mu sync.Mutex
Script map[string][]Outcome
Sent map[string]int
}
func NewFakeProvider(script map[string][]Outcome) *FakeProvider {
if script == nil {
script = map[string][]Outcome{}
}
return &FakeProvider{Script: script, Sent: map[string]int{}}
}
func (p *FakeProvider) Send(m Message) Outcome {
p.mu.Lock()
defer p.mu.Unlock()
p.Sent[m.Key]++
if s := p.Script[m.To]; m.Attempt < len(s) {
return s[m.Attempt]
}
return OK
}
// service.go
// Package notify is the core of a notification system: requests are deduplicated, checked
// against preferences, rendered from templates and put on one queue per channel; workers send
// them through providers, retry transient failures with exponential backoff, and move
// messages that can't be delivered to a dead-letter queue.
package notify
import (
"strconv"
"sync"
)
// MaxAttempts is how many times a message is tried before it goes to the dead-letter queue.
const MaxAttempts = 4
// Backoff is the wait before retry number n (1, 2, 4, ... seconds). Production adds jitter
// and uses minutes, not seconds; the demo keeps it exact so every language prints the same.
func Backoff(attempt int) int64 { return int64(1) << (attempt - 1) }
// Event is one delivery attempt, for logs and the demo.
type Event struct {
Time int64
Msg Message
Outcome Outcome
RetryAt int64 // set when the message was put back on the queue
Dead bool // set when it went to the dead-letter queue
}
// Service accepts requests and runs the per-channel queues.
type Service struct {
mu sync.Mutex
users map[string]User
templates map[string]Template
providers map[Channel]Provider
seen map[string]bool // accepted request IDs (production: a store with a TTL)
marketing map[string]int // "user/day" -> marketing messages accepted
MaxMarketingPerDay int
queues map[Channel][]Message
seq int
Dead []Message
Delivered int
}
func NewService(users map[string]User, templates map[string]Template, providers map[Channel]Provider) *Service {
return &Service{
users: users, templates: templates, providers: providers,
seen: map[string]bool{}, marketing: map[string]int{}, MaxMarketingPerDay: 1,
queues: map[Channel][]Message{},
}
}
func inQuiet(u User, now int64) bool {
if u.QuietFrom == u.QuietTo {
return false
}
h := int(now % 86400 / 3600)
if u.QuietFrom < u.QuietTo {
return h >= u.QuietFrom && h < u.QuietTo
}
return h >= u.QuietFrom || h < u.QuietTo
}
// quietEnd is the next time the clock reads QuietTo:00.
func quietEnd(u User, now int64) int64 {
day := now / 86400 * 86400
end := day + int64(u.QuietTo)*3600
if end <= now {
end += 86400
}
return end
}
// Accepted says what happened to a request: channels queued, channels skipped (with the
// reason), and when sending starts (later than now during quiet hours).
type Accepted struct {
Queued []Channel
Skipped []string
Due int64
}
// Send accepts one request. The whole check-and-accept runs under one lock, so two copies of
// a request racing each other can't both get through.
func (s *Service) Send(r Request, now int64) (Accepted, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.seen[r.ID] {
return Accepted{}, ErrDuplicate
}
u, ok := s.users[r.User]
if !ok {
return Accepted{}, ErrUnknownUser
}
t, ok := s.templates[r.Template]
if !ok {
return Accepted{}, ErrUnknownTemplate
}
body, err := Render(t.Text, r.Params)
if err != nil {
return Accepted{}, err
}
var a Accepted
dayKey := r.User + "/" + strconv.FormatInt(now/86400, 10)
if t.Kind == Marketing && s.marketing[dayKey] >= s.MaxMarketingPerDay {
return Accepted{}, ErrRateLimited
}
a.Due = now
if t.Kind == Marketing && inQuiet(u, now) {
a.Due = quietEnd(u, now)
}
for _, ch := range t.Channels {
to, ok := u.Contacts[ch]
switch {
case !ok:
a.Skipped = append(a.Skipped, string(ch)+": no address")
case t.Kind == Marketing && u.OptOut[ch]:
a.Skipped = append(a.Skipped, string(ch)+": opted out")
default:
s.seq++
s.queues[ch] = append(s.queues[ch], Message{Key: r.ID + ":" + string(ch), Channel: ch, To: to, Body: body, Kind: t.Kind, Due: a.Due, seq: s.seq})
a.Queued = append(a.Queued, ch)
}
}
s.seen[r.ID] = true
if t.Kind == Marketing && len(a.Queued) > 0 {
s.marketing[dayKey]++
}
return a, nil
}
// take removes the earliest due message (on one channel, or on any if ch is ""), if one is due.
func (s *Service) take(ch Channel, now int64) (Message, bool) {
s.mu.Lock()
defer s.mu.Unlock()
bestCh, best := Channel(""), -1
for _, c := range []Channel{Push, SMS, Email} {
if ch != "" && c != ch {
continue
}
for i, m := range s.queues[c] {
if m.Due > now {
continue
}
if best < 0 || m.Due < s.queues[bestCh][best].Due ||
(m.Due == s.queues[bestCh][best].Due && m.seq < s.queues[bestCh][best].seq) {
bestCh, best = c, i
}
}
}
if best < 0 {
return Message{}, false
}
q := s.queues[bestCh]
m := q[best]
s.queues[bestCh] = append(q[:best:best], q[best+1:]...)
return m, true
}
// deliver calls the provider outside the lock (it's network I/O), then records the result.
func (s *Service) deliver(m Message, now int64) Event {
out := s.providers[m.Channel].Send(m)
m.Attempt++
ev := Event{Time: now, Msg: m, Outcome: out}
s.mu.Lock()
defer s.mu.Unlock()
switch {
case out == OK:
s.Delivered++
case out == Transient && m.Attempt < MaxAttempts:
m.Due = now + Backoff(m.Attempt)
s.queues[m.Channel] = append(s.queues[m.Channel], m)
ev.RetryAt = m.Due
default:
s.Dead = append(s.Dead, m)
ev.Dead = true
}
return ev
}
// Work is one worker step on one channel's queue: send the earliest due message, if any.
func (s *Service) Work(ch Channel, now int64) (Event, bool) {
m, ok := s.take(ch, now)
if !ok {
return Event{}, false
}
return s.deliver(m, now), true
}
// Step sends the earliest due message on any channel (the demo's single worker).
func (s *Service) Step(now int64) (Event, bool) { return s.Work("", now) }
// NextDue is the earliest time any queued message becomes due.
func (s *Service) NextDue() (int64, bool) {
s.mu.Lock()
defer s.mu.Unlock()
var next int64
found := false
for _, q := range s.queues {
for _, m := range q {
if !found || m.Due < next {
next, found = m.Due, true
}
}
}
return next, found
}
// Pending is how many messages are still queued.
func (s *Service) Pending() int {
s.mu.Lock()
defer s.mu.Unlock()
n := 0
for _, q := range s.queues {
n += len(q)
}
return n
}
// cmd/demo/main.go
// Command demo sends a few order updates and a marketing campaign through the notification
// core, with a flaky SMS gateway, a bad email address, quiet hours and duplicate requests.
package main
import (
"fmt"
"strings"
"notify"
)
func main() {
users := map[string]notify.User{
"rafi": {Contacts: map[notify.Channel]string{notify.Push: "tok-rafi", notify.SMS: "+8801711000001", notify.Email: "rafi@example.com"},
QuietFrom: 22, QuietTo: 8},
"nusrat": {Contacts: map[notify.Channel]string{notify.SMS: "+8801811000002", notify.Email: "nusrat@example.com"},
OptOut: map[notify.Channel]bool{notify.Email: true}},
"tanvir": {Contacts: map[notify.Channel]string{notify.Email: "tanvir@bad-domain"}},
}
templates := map[string]notify.Template{
"order_shipped": {Kind: notify.Transactional, Channels: []notify.Channel{notify.Push, notify.SMS}, Text: "Order {order} is on the way, arriving {day}"},
"eid_sale": {Kind: notify.Marketing, Channels: []notify.Channel{notify.Push, notify.Email}, Text: "Eid sale: {pct}% off until Friday"},
}
providers := map[notify.Channel]notify.Provider{
notify.Push: notify.NewFakeProvider(nil),
notify.SMS: notify.NewFakeProvider(map[string][]notify.Outcome{"+8801811000002": {notify.Transient, notify.Transient}}),
notify.Email: notify.NewFakeProvider(map[string][]notify.Outcome{"tanvir@bad-domain": {notify.Permanent}}),
}
s := notify.NewService(users, templates, providers)
send := func(now int64, r notify.Request) {
a, err := s.Send(r, now)
head := fmt.Sprintf(" %-16s %-13s %-6s ->", r.ID, r.Template, r.User)
if err != nil {
fmt.Printf("%s error: %v\n", head, err)
return
}
parts := []string{}
for _, ch := range a.Queued {
parts = append(parts, string(ch))
}
line := "queued " + strings.Join(parts, ", ")
if a.Due > now {
line += " for " + notify.Clock(a.Due) + " (quiet hours)"
}
if len(a.Queued) == 0 {
line = "nothing to send"
}
if len(a.Skipped) > 0 {
line += " (skipped " + strings.Join(a.Skipped, "; ") + ")"
}
fmt.Printf("%s %s\n", head, line)
}
run := func(until int64) {
for {
next, ok := s.NextDue()
if !ok || next > until {
return
}
ev, _ := s.Step(next)
line := ev.Outcome.String()
switch {
case ev.RetryAt > 0:
line += ", retry at " + notify.Clock(ev.RetryAt)
case ev.Dead:
line += ", moved to dead letters"
default:
line += ": " + ev.Msg.Body
}
fmt.Printf(" %s %-5s %-19s try %d: %s\n", notify.Clock(ev.Time), ev.Msg.Channel, ev.Msg.Key, ev.Msg.Attempt, line)
}
}
t1 := int64(21*3600 + 59*60 + 50)
fmt.Println("D1 21:59:50, the order service sends three requests:")
send(t1, notify.Request{ID: "evt-1", User: "rafi", Template: "order_shipped", Params: map[string]string{"order": "BD-7781", "day": "Sunday"}})
send(t1, notify.Request{ID: "evt-1", User: "rafi", Template: "order_shipped", Params: map[string]string{"order": "BD-7781", "day": "Sunday"}})
send(t1, notify.Request{ID: "evt-2", User: "nusrat", Template: "order_shipped", Params: map[string]string{"order": "BD-7790", "day": "Monday"}})
fmt.Println("workers:")
run(t1 + 10)
t2 := int64(22*3600 + 5)
fmt.Println("D1 22:00:05, the marketing team starts a campaign:")
send(t2, notify.Request{ID: "camp-7:rafi", User: "rafi", Template: "eid_sale", Params: map[string]string{"pct": "30"}})
send(t2, notify.Request{ID: "camp-7:nusrat", User: "nusrat", Template: "eid_sale", Params: map[string]string{"pct": "30"}})
send(t2, notify.Request{ID: "camp-7:tanvir", User: "tanvir", Template: "eid_sale", Params: map[string]string{"pct": "30"}})
send(t2, notify.Request{ID: "camp-8:rafi", User: "rafi", Template: "eid_sale", Params: map[string]string{"pct": "40"}})
send(t2, notify.Request{ID: "camp-9:tanvir", User: "tanvir", Template: "eid_sale", Params: map[string]string{}})
fmt.Println("workers:")
run(2 * 86400)
fmt.Printf("delivered %d, dead letters %d, still queued %d\n", s.Delivered, len(s.Dead), s.Pending())
}
Demo চালালে:
$ go run ./cmd/demo
D1 21:59:50, the order service sends three requests:
evt-1 order_shipped rafi -> queued push, sms
evt-1 order_shipped rafi -> error: duplicate request, already accepted
evt-2 order_shipped nusrat -> queued sms (skipped push: no address)
workers:
D1 21:59:50 push evt-1:push try 1: delivered: Order BD-7781 is on the way, arriving Sunday
D1 21:59:50 sms evt-1:sms try 1: delivered: Order BD-7781 is on the way, arriving Sunday
D1 21:59:50 sms evt-2:sms try 1: failed (timeout), retry at D1 21:59:51
D1 21:59:51 sms evt-2:sms try 2: failed (timeout), retry at D1 21:59:53
D1 21:59:53 sms evt-2:sms try 3: delivered: Order BD-7790 is on the way, arriving Monday
D1 22:00:05, the marketing team starts a campaign:
camp-7:rafi eid_sale rafi -> queued push, email for D2 08:00:00 (quiet hours)
camp-7:nusrat eid_sale nusrat -> nothing to send (skipped push: no address; email: opted out)
camp-7:tanvir eid_sale tanvir -> queued email (skipped push: no address)
camp-8:rafi eid_sale rafi -> error: marketing limit reached for today
camp-9:tanvir eid_sale tanvir -> error: missing parameter "pct"
workers:
D1 22:00:05 email camp-7:tanvir:email try 1: rejected (bad address), moved to dead letters
D2 08:00:00 push camp-7:rafi:push try 1: delivered: Eid sale: 30% off until Friday
D2 08:00:00 email camp-7:rafi:email try 1: delivered: Eid sale: 30% off until Friday
delivered 5, dead letters 1, still queued 0
Output টা পড়া:
- Duplicate: দ্বিতীয়
evt-1আগেই নেওয়া হয়েছে বলে ফেরত যায়। রাফি একটা push আর একটা SMS পায়, প্রতিটার দুইটা না। - বাদ পড়া channel: নুসরাতের device token নেই, তাই
evt-2শুধু SMS এ যায়, আর response বলে push কেন বাদ গেল। কী হলো caller সেটা ঠিকঠাক জানে। - মাঝে মাঝে fail করা gateway: নুসরাতের SMS 21:59:50 এ timeout, 1 s পর আবার চেষ্টা, আবার timeout, 2 s অপেক্ষা, আর 21:59:53 এ try 3 এ পৌঁছায়। একই মুহূর্তে queue তে যাওয়া রাফির message গুলো আগে চলে গেছে, আটকে থাকেনি।
- Quiet hours: 22:00:05 এ রাফি quiet hours এ (22 থেকে 8), তাই campaign টা D2 08:00:00 এর জন্য queue তে যায় আর তখনই পৌঁছায়। এক ঘণ্টা আগের order update দেরি হয়নি, কারণ quiet hours শুধু marketing এ খাটে।
- Opt-out: নুসরাত marketing email বন্ধ করেছে আর তার push token নেই, তাই campaign এর তাকে পাঠানোর কিছু নেই। এটা শূন্য message সহ একটা সফল request, error না।
- স্থায়ী failure: তানভীরের email address ভুল। Provider try 1 এই ফিরিয়ে দেয়, সোজা dead letter এ যায়; retry করলে শুধু call নষ্ট হতো।
- সীমা আর যাচাই: একই দিনে রাফিকে দ্বিতীয় campaign দিনের marketing সীমায় আটকায়;
pctছাড়া একটা request ফিরিয়ে দেওয়া হয়, "Eid sale: % off" পাঠানোর বদলে। - মোট: 5 টা পৌঁছেছে, 1 টা dead letter, queue তে কিছু বাকি নেই।
Test গুলো সবচেয়ে জরুরি জিনিসগুলো যাচাই করে: একসাথে 50 টা একই request থেকে ঠিক একটা message queue তে যায় (-race), retry হয় 0, 1, 3 আর 7 s এ, তারপর DLQ, স্থায়ী failure এ retry হয় না, quiet hours শুধু marketing পিছায় আর মাঝরাত পেরিয়েও ঠিক কাজ করে, opt-out transactional message আটকায় না, fail করা request তার idempotency key নষ্ট করে না, আর একটা queue থেকে চারটা worker একসাথে নিলেও প্রতিটা message ঠিক একবার যায়। Duplicate যাচাই আর নেওয়ার মাঝে lock ছেড়ে দিলে duplicate test fail করে (এক run এ 4 টা copy ঢুকে গেছে), তাই test টা সত্যিই ওই ধাপটা পাহারা দেয়।
$ go test -race -v ./...
=== RUN TestDuplicateRequestsQueueOnce
--- PASS: TestDuplicateRequestsQueueOnce (0.00s)
=== RUN TestRetriesBackOffThenDeadLetter
--- PASS: TestRetriesBackOffThenDeadLetter (0.00s)
=== RUN TestPermanentFailureIsNotRetried
--- PASS: TestPermanentFailureIsNotRetried (0.00s)
=== RUN TestQuietHoursDelayMarketingOnly
--- PASS: TestQuietHoursDelayMarketingOnly (0.00s)
=== RUN TestOptOutAppliesToMarketingOnly
--- PASS: TestOptOutAppliesToMarketingOnly (0.00s)
=== RUN TestMarketingLimitAndValidation
--- PASS: TestMarketingLimitAndValidation (0.00s)
=== RUN TestConcurrentWorkersSendEachMessageOnce
--- PASS: TestConcurrentWorkersSendEachMessageOnce (0.02s)
PASS
ok notify 1.034s
? notify/cmd/demo [no test files]
একই core আরো পাঁচটা language এ
Single-threaded version, একই class, একই output। এখানে worker loop একটার পর একটা চলে; worker একসাথে চলে Go version এ। যা আলাদা:
- Python: message গুলো frozen dataclass, আর retry
replace()দিয়ে বদলানো একটা copy বানায়, তাই queue তে থাকা message কখনো জায়গায় বদলায় না। - TypeScript:
Outcomeএকটাenumনা, const object, কারণ Node.tsfile চালায় type মুছে দিয়ে, আর enum শুধু type না।SendRequestআরAttemptনাম যাতে DOM এর globalRequestআরEventএর সাথে না লাগে। - Rust:
Channel,KindআরOutcomeহলো enum, আরmatchএ সব case ধরতে হয়, তাই নতুন channel যোগ করলে প্রতিটাmatchকে সেটা সামলাতে হয়।renderআগেভাগে ফেরার জন্যlet ... elseব্যবহার করে। - Java: অপরিবর্তনীয় message এর জন্য record;
sendআরtakeহলোsynchronized, আর provider call lock এর বাইরে, Go এর মতোই। - C++: একই critical section এর চারপাশে
std::lock_guardসহ একটাstd::mutex; "কোনো message due না" বোঝাতেstd::optional।
"""A notification system's core: requests are deduplicated, checked against preferences,
rendered from templates and put on one queue per channel; workers send them through
providers, retry transient failures with exponential backoff, and move messages that can't be
delivered to a dead-letter queue."""
from dataclasses import dataclass, field, replace
from enum import Enum
PUSH, SMS, EMAIL = "push", "sms", "email"
CHANNELS = [PUSH, SMS, EMAIL]
TRANSACTIONAL, MARKETING = 0, 1
MAX_ATTEMPTS = 4
class Outcome(Enum):
OK = "delivered"
TRANSIENT = "failed (timeout)"
PERMANENT = "rejected (bad address)"
@dataclass
class Template:
kind: int
channels: list[str]
text: str
@dataclass
class User:
contacts: dict[str, str]
opt_out: set[str] = field(default_factory=set) # marketing opt-outs per channel
quiet_from: int = 0 # quiet hours for marketing, e.g. 22 to 8 (0, 0 = none)
quiet_to: int = 0
@dataclass
class Request:
id: str # the idempotency key
user: str
template: str
params: dict[str, str]
@dataclass(frozen=True)
class Message:
key: str # request ID + channel, e.g. "evt-1:sms"
channel: str
to: str
body: str
kind: int
attempt: int # attempts made so far
due: int # simulated seconds; not sent before this
seq: int # enqueue order, breaks ties
@dataclass
class Accepted:
queued: list[str] = field(default_factory=list)
skipped: list[str] = field(default_factory=list)
due: int = 0
@dataclass
class Event:
time: int
msg: Message
outcome: Outcome
retry_at: int = 0
dead: bool = False
class Rejected(Exception):
pass
def render(text: str, params: dict[str, str]) -> str:
"""Fills {name} placeholders; a missing value is an error, never a blank in a message."""
out = []
while True:
i = text.find("{")
if i < 0:
return "".join(out) + text
j = text.find("}", i)
if j < 0:
raise Rejected(f"unclosed placeholder in {text!r}")
name = text[i + 1 : j]
if name not in params:
raise Rejected(f'missing parameter "{name}"')
out += [text[:i], params[name]]
text = text[j + 1 :]
def clock(t: int) -> str:
s = t % 86400
return f"D{t // 86400 + 1} {s // 3600:02d}:{s // 60 % 60:02d}:{s % 60:02d}"
def backoff(attempt: int) -> int:
"""Wait before retry n: 1, 2, 4... seconds (production: minutes, with jitter)."""
return 1 << (attempt - 1)
class FakeProvider:
"""Plays back scripted outcomes per address; after the script runs out, sends succeed."""
def __init__(self, script: dict[str, list[Outcome]] | None = None):
self.script = script or {}
self.sent: dict[str, int] = {}
def send(self, m: Message) -> Outcome:
self.sent[m.key] = self.sent.get(m.key, 0) + 1
s = self.script.get(m.to, [])
return s[m.attempt] if m.attempt < len(s) else Outcome.OK
def in_quiet(u: User, now: int) -> bool:
if u.quiet_from == u.quiet_to:
return False
h = now % 86400 // 3600
if u.quiet_from < u.quiet_to:
return u.quiet_from <= h < u.quiet_to
return h >= u.quiet_from or h < u.quiet_to
def quiet_end(u: User, now: int) -> int:
end = now // 86400 * 86400 + u.quiet_to * 3600
return end + 86400 if end <= now else end
class Service:
def __init__(self, users: dict[str, User], templates: dict[str, Template], providers: dict[str, FakeProvider]):
self.users, self.templates, self.providers = users, templates, providers
self.seen: set[str] = set() # accepted request IDs (production: a store with a TTL)
self.marketing: dict[str, int] = {} # "user/day" -> marketing messages accepted
self.max_marketing_per_day = 1
self.queues: dict[str, list[Message]] = {c: [] for c in CHANNELS}
self.seq = 0
self.dead: list[Message] = []
self.delivered = 0
def send(self, r: Request, now: int) -> Accepted:
if r.id in self.seen:
raise Rejected("duplicate request, already accepted")
u = self.users.get(r.user)
if u is None:
raise Rejected("unknown user")
t = self.templates.get(r.template)
if t is None:
raise Rejected("unknown template")
body = render(t.text, r.params)
day_key = f"{r.user}/{now // 86400}"
if t.kind == MARKETING and self.marketing.get(day_key, 0) >= self.max_marketing_per_day:
raise Rejected("marketing limit reached for today")
a = Accepted(due=now)
if t.kind == MARKETING and in_quiet(u, now):
a.due = quiet_end(u, now)
for ch in t.channels:
if ch not in u.contacts:
a.skipped.append(f"{ch}: no address")
elif t.kind == MARKETING and ch in u.opt_out:
a.skipped.append(f"{ch}: opted out")
else:
self.seq += 1
self.queues[ch].append(Message(f"{r.id}:{ch}", ch, u.contacts[ch], body, t.kind, 0, a.due, self.seq))
a.queued.append(ch)
self.seen.add(r.id)
if t.kind == MARKETING and a.queued:
self.marketing[day_key] = self.marketing.get(day_key, 0) + 1
return a
def _take(self, now: int) -> Message | None:
due = [m for c in CHANNELS for m in self.queues[c] if m.due <= now]
if not due:
return None
m = min(due, key=lambda m: (m.due, m.seq))
self.queues[m.channel].remove(m)
return m
def step(self, now: int) -> Event | None:
m = self._take(now)
if m is None:
return None
out = self.providers[m.channel].send(m)
m = replace(m, attempt=m.attempt + 1)
ev = Event(now, m, out)
if out == Outcome.OK:
self.delivered += 1
elif out == Outcome.TRANSIENT and m.attempt < MAX_ATTEMPTS:
m = replace(m, due=now + backoff(m.attempt))
self.queues[m.channel].append(m)
ev.retry_at = m.due
else:
self.dead.append(m)
ev.dead = True
return ev
def next_due(self) -> int | None:
dues = [m.due for q in self.queues.values() for m in q]
return min(dues) if dues else None
def pending(self) -> int:
return sum(len(q) for q in self.queues.values())
def main() -> None:
users = {
"rafi": User({PUSH: "tok-rafi", SMS: "+8801711000001", EMAIL: "rafi@example.com"}, quiet_from=22, quiet_to=8),
"nusrat": User({SMS: "+8801811000002", EMAIL: "nusrat@example.com"}, opt_out={EMAIL}),
"tanvir": User({EMAIL: "tanvir@bad-domain"}),
}
templates = {
"order_shipped": Template(TRANSACTIONAL, [PUSH, SMS], "Order {order} is on the way, arriving {day}"),
"eid_sale": Template(MARKETING, [PUSH, EMAIL], "Eid sale: {pct}% off until Friday"),
}
providers = {
PUSH: FakeProvider(),
SMS: FakeProvider({"+8801811000002": [Outcome.TRANSIENT, Outcome.TRANSIENT]}),
EMAIL: FakeProvider({"tanvir@bad-domain": [Outcome.PERMANENT]}),
}
s = Service(users, templates, providers)
def send(now: int, r: Request) -> None:
head = f" {r.id:<16} {r.template:<13} {r.user:<6} ->"
try:
a = s.send(r, now)
except Rejected as e:
print(f"{head} error: {e}")
return
line = "queued " + ", ".join(a.queued)
if a.due > now:
line += f" for {clock(a.due)} (quiet hours)"
if not a.queued:
line = "nothing to send"
if a.skipped:
line += " (skipped " + "; ".join(a.skipped) + ")"
print(f"{head} {line}")
def run(until: int) -> None:
while (nxt := s.next_due()) is not None and nxt <= until:
ev = s.step(nxt)
assert ev is not None
line = ev.outcome.value
if ev.retry_at:
line += f", retry at {clock(ev.retry_at)}"
elif ev.dead:
line += ", moved to dead letters"
else:
line += f": {ev.msg.body}"
print(f" {clock(ev.time)} {ev.msg.channel:<5} {ev.msg.key:<19} try {ev.msg.attempt}: {line}")
t1 = 21 * 3600 + 59 * 60 + 50
print("D1 21:59:50, the order service sends three requests:")
send(t1, Request("evt-1", "rafi", "order_shipped", {"order": "BD-7781", "day": "Sunday"}))
send(t1, Request("evt-1", "rafi", "order_shipped", {"order": "BD-7781", "day": "Sunday"}))
send(t1, Request("evt-2", "nusrat", "order_shipped", {"order": "BD-7790", "day": "Monday"}))
print("workers:")
run(t1 + 10)
t2 = 22 * 3600 + 5
print("D1 22:00:05, the marketing team starts a campaign:")
send(t2, Request("camp-7:rafi", "rafi", "eid_sale", {"pct": "30"}))
send(t2, Request("camp-7:nusrat", "nusrat", "eid_sale", {"pct": "30"}))
send(t2, Request("camp-7:tanvir", "tanvir", "eid_sale", {"pct": "30"}))
send(t2, Request("camp-8:rafi", "rafi", "eid_sale", {"pct": "40"}))
send(t2, Request("camp-9:tanvir", "tanvir", "eid_sale", {}))
print("workers:")
run(2 * 86400)
print(f"delivered {s.delivered}, dead letters {len(s.dead)}, still queued {s.pending()}")
if __name__ == "__main__":
main()// A notification system's core: requests are deduplicated, checked against preferences,
// rendered from templates and put on one queue per channel; workers send them through
// providers, retry transient failures with exponential backoff, and move messages that can't
// be delivered to a dead-letter queue.
type Channel = "push" | "sms" | "email";
const CHANNELS: Channel[] = ["push", "sms", "email"];
type Kind = "transactional" | "marketing";
const MAX_ATTEMPTS = 4;
// A const object instead of an enum: Node runs this file by stripping types, and enums aren't types.
const Outcome = { OK: "delivered", Transient: "failed (timeout)", Permanent: "rejected (bad address)" } as const;
type Outcome = (typeof Outcome)[keyof typeof Outcome];
type Template = { kind: Kind; channels: Channel[]; text: string };
type User = {
contacts: Partial<Record<Channel, string>>;
optOut?: Set<Channel>; // marketing opt-outs per channel
quietFrom?: number; // quiet hours for marketing, e.g. 22 to 8
quietTo?: number;
};
// SendRequest and Attempt, not Request and Event: those names are DOM globals.
type SendRequest = { id: string; user: string; template: string; params: Record<string, string> };
type Message = {
readonly key: string; // request ID + channel, e.g. "evt-1:sms"
readonly channel: Channel;
readonly to: string;
readonly body: string;
readonly kind: Kind;
readonly attempt: number; // attempts made so far
readonly due: number; // simulated seconds; not sent before this
readonly seq: number; // enqueue order, breaks ties
};
type Accepted = { queued: Channel[]; skipped: string[]; due: number };
type Attempt = { time: number; msg: Message; outcome: Outcome; retryAt: number; dead: boolean };
class Rejected extends Error {}
/** Fills {name} placeholders; a missing value is an error, never a blank in a message. */
function render(text: string, params: Record<string, string>): string {
let out = "";
for (;;) {
const i = text.indexOf("{");
if (i < 0) return out + text;
const j = text.indexOf("}", i);
if (j < 0) throw new Rejected(`unclosed placeholder in ${JSON.stringify(text)}`);
const name = text.slice(i + 1, j);
if (!(name in params)) throw new Rejected(`missing parameter "${name}"`);
out += text.slice(0, i) + params[name];
text = text.slice(j + 1);
}
}
const pad2 = (n: number) => String(n).padStart(2, "0");
function clock(t: number): string {
const s = t % 86400;
return `D${Math.floor(t / 86400) + 1} ${pad2(Math.floor(s / 3600))}:${pad2(Math.floor(s / 60) % 60)}:${pad2(s % 60)}`;
}
/** Wait before retry n: 1, 2, 4... seconds (production: minutes, with jitter). */
const backoff = (attempt: number) => 2 ** (attempt - 1);
/** Plays back scripted outcomes per address; after the script runs out, sends succeed. */
class FakeProvider {
sent = new Map<string, number>();
readonly script: Map<string, Outcome[]>;
constructor(script: Record<string, Outcome[]> = {}) {
this.script = new Map(Object.entries(script));
}
send(m: Message): Outcome {
this.sent.set(m.key, (this.sent.get(m.key) ?? 0) + 1);
const s = this.script.get(m.to) ?? [];
return m.attempt < s.length ? s[m.attempt]! : Outcome.OK;
}
}
function inQuiet(u: User, now: number): boolean {
const from = u.quietFrom ?? 0;
const to = u.quietTo ?? 0;
if (from === to) return false;
const h = Math.floor((now % 86400) / 3600);
return from < to ? h >= from && h < to : h >= from || h < to;
}
function quietEnd(u: User, now: number): number {
const end = Math.floor(now / 86400) * 86400 + (u.quietTo ?? 0) * 3600;
return end <= now ? end + 86400 : end;
}
class Service {
seen = new Set<string>(); // accepted request IDs (production: a store with a TTL)
marketing = new Map<string, number>(); // "user/day" -> marketing messages accepted
maxMarketingPerDay = 1;
queues = new Map<Channel, Message[]>(CHANNELS.map((c) => [c, []]));
seq = 0;
dead: Message[] = [];
delivered = 0;
readonly users: Map<string, User>;
readonly templates: Map<string, Template>;
readonly providers: Map<Channel, FakeProvider>;
constructor(users: Record<string, User>, templates: Record<string, Template>, providers: Record<Channel, FakeProvider>) {
this.users = new Map(Object.entries(users));
this.templates = new Map(Object.entries(templates));
this.providers = new Map(Object.entries(providers) as [Channel, FakeProvider][]);
}
send(r: SendRequest, now: number): Accepted {
if (this.seen.has(r.id)) throw new Rejected("duplicate request, already accepted");
const u = this.users.get(r.user);
if (!u) throw new Rejected("unknown user");
const t = this.templates.get(r.template);
if (!t) throw new Rejected("unknown template");
const body = render(t.text, r.params);
const dayKey = `${r.user}/${Math.floor(now / 86400)}`;
if (t.kind === "marketing" && (this.marketing.get(dayKey) ?? 0) >= this.maxMarketingPerDay)
throw new Rejected("marketing limit reached for today");
const a: Accepted = { queued: [], skipped: [], due: now };
if (t.kind === "marketing" && inQuiet(u, now)) a.due = quietEnd(u, now);
for (const ch of t.channels) {
const to = u.contacts[ch];
if (to === undefined) a.skipped.push(`${ch}: no address`);
else if (t.kind === "marketing" && u.optOut?.has(ch)) a.skipped.push(`${ch}: opted out`);
else {
this.queues.get(ch)!.push({ key: `${r.id}:${ch}`, channel: ch, to, body, kind: t.kind, attempt: 0, due: a.due, seq: ++this.seq });
a.queued.push(ch);
}
}
this.seen.add(r.id);
if (t.kind === "marketing" && a.queued.length > 0) this.marketing.set(dayKey, (this.marketing.get(dayKey) ?? 0) + 1);
return a;
}
private take(now: number): Message | undefined {
let best: Message | undefined;
for (const c of CHANNELS)
for (const m of this.queues.get(c)!)
if (m.due <= now && (!best || m.due < best.due || (m.due === best.due && m.seq < best.seq))) best = m;
if (best) {
const q = this.queues.get(best.channel)!;
q.splice(q.indexOf(best), 1);
}
return best;
}
step(now: number): Attempt | undefined {
const taken = this.take(now);
if (!taken) return undefined;
const outcome = this.providers.get(taken.channel)!.send(taken);
const m: Message = { ...taken, attempt: taken.attempt + 1 };
const ev: Attempt = { time: now, msg: m, outcome, retryAt: 0, dead: false };
if (outcome === Outcome.OK) this.delivered++;
else if (outcome === Outcome.Transient && m.attempt < MAX_ATTEMPTS) {
ev.retryAt = now + backoff(m.attempt);
this.queues.get(m.channel)!.push({ ...m, due: ev.retryAt });
} else {
this.dead.push(m);
ev.dead = true;
}
return ev;
}
nextDue(): number | undefined {
let next: number | undefined;
for (const q of this.queues.values()) for (const m of q) if (next === undefined || m.due < next) next = m.due;
return next;
}
pending(): number {
let n = 0;
for (const q of this.queues.values()) n += q.length;
return n;
}
}
const s = new Service(
{
rafi: { contacts: { push: "tok-rafi", sms: "+8801711000001", email: "rafi@example.com" }, quietFrom: 22, quietTo: 8 },
nusrat: { contacts: { sms: "+8801811000002", email: "nusrat@example.com" }, optOut: new Set<Channel>(["email"]) },
tanvir: { contacts: { email: "tanvir@bad-domain" } },
},
{
order_shipped: { kind: "transactional", channels: ["push", "sms"], text: "Order {order} is on the way, arriving {day}" },
eid_sale: { kind: "marketing", channels: ["push", "email"], text: "Eid sale: {pct}% off until Friday" },
},
{
push: new FakeProvider(),
sms: new FakeProvider({ "+8801811000002": [Outcome.Transient, Outcome.Transient] }),
email: new FakeProvider({ "tanvir@bad-domain": [Outcome.Permanent] }),
},
);
function send(now: number, r: SendRequest): void {
const head = ` ${r.id.padEnd(16)} ${r.template.padEnd(13)} ${r.user.padEnd(6)} ->`;
let a: Accepted;
try {
a = s.send(r, now);
} catch (e) {
if (!(e instanceof Rejected)) throw e;
console.log(`${head} error: ${e.message}`);
return;
}
let line = "queued " + a.queued.join(", ");
if (a.due > now) line += ` for ${clock(a.due)} (quiet hours)`;
if (a.queued.length === 0) line = "nothing to send";
if (a.skipped.length > 0) line += ` (skipped ${a.skipped.join("; ")})`;
console.log(`${head} ${line}`);
}
function run(until: number): void {
for (let next = s.nextDue(); next !== undefined && next <= until; next = s.nextDue()) {
const ev = s.step(next)!;
let line: string = ev.outcome;
if (ev.retryAt) line += `, retry at ${clock(ev.retryAt)}`;
else if (ev.dead) line += ", moved to dead letters";
else line += `: ${ev.msg.body}`;
console.log(` ${clock(ev.time)} ${ev.msg.channel.padEnd(5)} ${ev.msg.key.padEnd(19)} try ${ev.msg.attempt}: ${line}`);
}
}
const t1 = 21 * 3600 + 59 * 60 + 50;
console.log("D1 21:59:50, the order service sends three requests:");
send(t1, { id: "evt-1", user: "rafi", template: "order_shipped", params: { order: "BD-7781", day: "Sunday" } });
send(t1, { id: "evt-1", user: "rafi", template: "order_shipped", params: { order: "BD-7781", day: "Sunday" } });
send(t1, { id: "evt-2", user: "nusrat", template: "order_shipped", params: { order: "BD-7790", day: "Monday" } });
console.log("workers:");
run(t1 + 10);
const t2 = 22 * 3600 + 5;
console.log("D1 22:00:05, the marketing team starts a campaign:");
send(t2, { id: "camp-7:rafi", user: "rafi", template: "eid_sale", params: { pct: "30" } });
send(t2, { id: "camp-7:nusrat", user: "nusrat", template: "eid_sale", params: { pct: "30" } });
send(t2, { id: "camp-7:tanvir", user: "tanvir", template: "eid_sale", params: { pct: "30" } });
send(t2, { id: "camp-8:rafi", user: "rafi", template: "eid_sale", params: { pct: "40" } });
send(t2, { id: "camp-9:tanvir", user: "tanvir", template: "eid_sale", params: {} });
console.log("workers:");
run(2 * 86400);
console.log(`delivered ${s.delivered}, dead letters ${s.dead.length}, still queued ${s.pending()}`);// A notification system's core: requests are deduplicated, checked against preferences,
// rendered from templates and put on one queue per channel; workers send them through
// providers, retry transient failures with exponential backoff, and move messages that can't
// be delivered to a dead-letter queue.
use std::collections::{HashMap, HashSet};
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
enum Channel {
Push,
Sms,
Email,
}
const CHANNELS: [Channel; 3] = [Channel::Push, Channel::Sms, Channel::Email];
impl Channel {
fn name(self) -> &'static str {
match self {
Channel::Push => "push",
Channel::Sms => "sms",
Channel::Email => "email",
}
}
}
#[derive(Clone, Copy, PartialEq)]
enum Kind {
Transactional,
Marketing,
}
#[derive(Clone, Copy, PartialEq)]
enum Outcome {
Ok,
Transient, // timeout, 5xx, throttled: worth retrying
Permanent, // invalid number, unknown address: retrying won't help
}
impl Outcome {
fn text(self) -> &'static str {
match self {
Outcome::Ok => "delivered",
Outcome::Transient => "failed (timeout)",
Outcome::Permanent => "rejected (bad address)",
}
}
}
const MAX_ATTEMPTS: u32 = 4;
struct Template {
kind: Kind,
channels: Vec<Channel>,
text: &'static str,
}
#[derive(Default)]
struct User {
contacts: HashMap<Channel, &'static str>,
opt_out: HashSet<Channel>, // marketing opt-outs per channel
quiet_from: i64, // quiet hours for marketing, e.g. 22 to 8 (0, 0 = none)
quiet_to: i64,
}
struct Request {
id: &'static str, // the idempotency key
user: &'static str,
template: &'static str,
params: Vec<(&'static str, &'static str)>,
}
#[derive(Clone)]
struct Message {
key: String, // request ID + channel, e.g. "evt-1:sms"
channel: Channel,
to: String,
body: String,
attempt: u32, // attempts made so far
due: i64, // simulated seconds; not sent before this
seq: u64, // enqueue order, breaks ties
}
#[derive(Default)]
struct Accepted {
queued: Vec<Channel>,
skipped: Vec<String>,
due: i64,
}
struct Event {
time: i64,
msg: Message,
outcome: Outcome,
retry_at: Option<i64>,
dead: bool,
}
/// Fills {name} placeholders; a missing value is an error, never a blank in a message.
fn render(text: &str, params: &[(&str, &str)]) -> Result<String, String> {
let (mut out, mut rest) = (String::new(), text);
loop {
let Some(i) = rest.find('{') else {
out.push_str(rest);
return Ok(out);
};
let Some(j) = rest[i..].find('}') else {
return Err(format!("unclosed placeholder in {:?}", text));
};
let name = &rest[i + 1..i + j];
let Some((_, v)) = params.iter().find(|(k, _)| *k == name) else {
return Err(format!("missing parameter \"{}\"", name));
};
out.push_str(&rest[..i]);
out.push_str(v);
rest = &rest[i + j + 1..];
}
}
fn clock(t: i64) -> String {
let s = t % 86400;
format!("D{} {:02}:{:02}:{:02}", t / 86400 + 1, s / 3600, s / 60 % 60, s % 60)
}
/// Wait before retry n: 1, 2, 4... seconds (production: minutes, with jitter).
fn backoff(attempt: u32) -> i64 {
1 << (attempt - 1)
}
/// Plays back scripted outcomes per address; after the script runs out, sends succeed.
#[derive(Default)]
struct FakeProvider {
script: HashMap<&'static str, Vec<Outcome>>,
sent: HashMap<String, u32>,
}
impl FakeProvider {
fn send(&mut self, m: &Message) -> Outcome {
*self.sent.entry(m.key.clone()).or_insert(0) += 1;
match self.script.get(m.to.as_str()) {
Some(s) if (m.attempt as usize) < s.len() => s[m.attempt as usize],
_ => Outcome::Ok,
}
}
}
fn in_quiet(u: &User, now: i64) -> bool {
if u.quiet_from == u.quiet_to {
return false;
}
let h = now % 86400 / 3600;
if u.quiet_from < u.quiet_to {
h >= u.quiet_from && h < u.quiet_to
} else {
h >= u.quiet_from || h < u.quiet_to
}
}
fn quiet_end(u: &User, now: i64) -> i64 {
let end = now / 86400 * 86400 + u.quiet_to * 3600;
if end <= now {
end + 86400
} else {
end
}
}
struct Service {
users: HashMap<&'static str, User>,
templates: HashMap<&'static str, Template>,
providers: HashMap<Channel, FakeProvider>,
seen: HashSet<String>, // accepted request IDs (production: a store with a TTL)
marketing: HashMap<String, u32>, // "user/day" -> marketing messages accepted
max_marketing_per_day: u32,
queues: HashMap<Channel, Vec<Message>>,
seq: u64,
dead: Vec<Message>,
delivered: u32,
}
impl Service {
fn send(&mut self, r: &Request, now: i64) -> Result<Accepted, String> {
if self.seen.contains(r.id) {
return Err("duplicate request, already accepted".into());
}
let u = self.users.get(r.user).ok_or("unknown user")?;
let t = self.templates.get(r.template).ok_or("unknown template")?;
let body = render(t.text, &r.params)?;
let day_key = format!("{}/{}", r.user, now / 86400);
if t.kind == Kind::Marketing && self.marketing.get(&day_key).copied().unwrap_or(0) >= self.max_marketing_per_day {
return Err("marketing limit reached for today".into());
}
let mut a = Accepted { due: now, ..Default::default() };
if t.kind == Kind::Marketing && in_quiet(u, now) {
a.due = quiet_end(u, now);
}
for &ch in &t.channels {
match u.contacts.get(&ch) {
None => a.skipped.push(format!("{}: no address", ch.name())),
Some(_) if t.kind == Kind::Marketing && u.opt_out.contains(&ch) => a.skipped.push(format!("{}: opted out", ch.name())),
Some(to) => {
self.seq += 1;
let m = Message { key: format!("{}:{}", r.id, ch.name()), channel: ch, to: to.to_string(), body: body.clone(), attempt: 0, due: a.due, seq: self.seq };
self.queues.entry(ch).or_default().push(m);
a.queued.push(ch);
}
}
}
self.seen.insert(r.id.to_string());
if t.kind == Kind::Marketing && !a.queued.is_empty() {
*self.marketing.entry(day_key).or_insert(0) += 1;
}
Ok(a)
}
fn take(&mut self, now: i64) -> Option<Message> {
let mut best: Option<(Channel, usize, i64, u64)> = None;
for ch in CHANNELS {
for (i, m) in self.queues.get(&ch).into_iter().flatten().enumerate() {
if m.due <= now && best.map_or(true, |(_, _, d, s)| (m.due, m.seq) < (d, s)) {
best = Some((ch, i, m.due, m.seq));
}
}
}
let (ch, i, _, _) = best?;
Some(self.queues.get_mut(&ch).unwrap().remove(i))
}
fn step(&mut self, now: i64) -> Option<Event> {
let mut m = self.take(now)?;
let outcome = self.providers.get_mut(&m.channel).unwrap().send(&m);
m.attempt += 1;
let mut ev = Event { time: now, msg: m.clone(), outcome, retry_at: None, dead: false };
if outcome == Outcome::Ok {
self.delivered += 1;
} else if outcome == Outcome::Transient && m.attempt < MAX_ATTEMPTS {
m.due = now + backoff(m.attempt);
ev.retry_at = Some(m.due);
self.queues.entry(m.channel).or_default().push(m);
} else {
self.dead.push(m);
ev.dead = true;
}
Some(ev)
}
fn next_due(&self) -> Option<i64> {
self.queues.values().flatten().map(|m| m.due).min()
}
fn pending(&self) -> usize {
self.queues.values().map(Vec::len).sum()
}
}
fn main() {
use Channel::*;
let mut users = HashMap::new();
users.insert("rafi", User { contacts: HashMap::from([(Push, "tok-rafi"), (Sms, "+8801711000001"), (Email, "rafi@example.com")]), quiet_from: 22, quiet_to: 8, ..Default::default() });
users.insert("nusrat", User { contacts: HashMap::from([(Sms, "+8801811000002"), (Email, "nusrat@example.com")]), opt_out: HashSet::from([Email]), ..Default::default() });
users.insert("tanvir", User { contacts: HashMap::from([(Email, "tanvir@bad-domain")]), ..Default::default() });
let mut templates = HashMap::new();
templates.insert("order_shipped", Template { kind: Kind::Transactional, channels: vec![Push, Sms], text: "Order {order} is on the way, arriving {day}" });
templates.insert("eid_sale", Template { kind: Kind::Marketing, channels: vec![Push, Email], text: "Eid sale: {pct}% off until Friday" });
let providers = HashMap::from([
(Push, FakeProvider::default()),
(Sms, FakeProvider { script: HashMap::from([("+8801811000002", vec![Outcome::Transient, Outcome::Transient])]), ..Default::default() }),
(Email, FakeProvider { script: HashMap::from([("tanvir@bad-domain", vec![Outcome::Permanent])]), ..Default::default() }),
]);
let mut s = Service { users, templates, providers, seen: HashSet::new(), marketing: HashMap::new(), max_marketing_per_day: 1, queues: HashMap::new(), seq: 0, dead: Vec::new(), delivered: 0 };
fn send(s: &mut Service, now: i64, r: Request) {
let head = format!(" {:<16} {:<13} {:<6} ->", r.id, r.template, r.user);
match s.send(&r, now) {
Err(e) => println!("{} error: {}", head, e),
Ok(a) => {
let names: Vec<&str> = a.queued.iter().map(|c| c.name()).collect();
let mut line = format!("queued {}", names.join(", "));
if a.due > now {
line += &format!(" for {} (quiet hours)", clock(a.due));
}
if a.queued.is_empty() {
line = "nothing to send".to_string();
}
if !a.skipped.is_empty() {
line += &format!(" (skipped {})", a.skipped.join("; "));
}
println!("{} {}", head, line);
}
}
}
fn run(s: &mut Service, until: i64) {
while let Some(next) = s.next_due().filter(|&n| n <= until) {
let ev = s.step(next).unwrap();
let mut line = ev.outcome.text().to_string();
if let Some(at) = ev.retry_at {
line += &format!(", retry at {}", clock(at));
} else if ev.dead {
line += ", moved to dead letters";
} else {
line += &format!(": {}", ev.msg.body);
}
println!(" {} {:<5} {:<19} try {}: {}", clock(ev.time), ev.msg.channel.name(), ev.msg.key, ev.msg.attempt, line);
}
}
let t1 = 21 * 3600 + 59 * 60 + 50;
println!("D1 21:59:50, the order service sends three requests:");
send(&mut s, t1, Request { id: "evt-1", user: "rafi", template: "order_shipped", params: vec![("order", "BD-7781"), ("day", "Sunday")] });
send(&mut s, t1, Request { id: "evt-1", user: "rafi", template: "order_shipped", params: vec![("order", "BD-7781"), ("day", "Sunday")] });
send(&mut s, t1, Request { id: "evt-2", user: "nusrat", template: "order_shipped", params: vec![("order", "BD-7790"), ("day", "Monday")] });
println!("workers:");
run(&mut s, t1 + 10);
let t2 = 22 * 3600 + 5;
println!("D1 22:00:05, the marketing team starts a campaign:");
send(&mut s, t2, Request { id: "camp-7:rafi", user: "rafi", template: "eid_sale", params: vec![("pct", "30")] });
send(&mut s, t2, Request { id: "camp-7:nusrat", user: "nusrat", template: "eid_sale", params: vec![("pct", "30")] });
send(&mut s, t2, Request { id: "camp-7:tanvir", user: "tanvir", template: "eid_sale", params: vec![("pct", "30")] });
send(&mut s, t2, Request { id: "camp-8:rafi", user: "rafi", template: "eid_sale", params: vec![("pct", "40")] });
send(&mut s, t2, Request { id: "camp-9:tanvir", user: "tanvir", template: "eid_sale", params: vec![] });
println!("workers:");
run(&mut s, 2 * 86400);
println!("delivered {}, dead letters {}, still queued {}", s.delivered, s.dead.len(), s.pending());
}import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
// A notification system's core: requests are deduplicated, checked against preferences,
// rendered from templates and put on one queue per channel; workers send them through
// providers, retry transient failures with exponential backoff, and move messages that can't
// be delivered to a dead-letter queue.
public class Notify {
enum Channel { PUSH, SMS, EMAIL; String label() { return name().toLowerCase(); } }
enum Kind { TRANSACTIONAL, MARKETING }
enum Outcome {
OK("delivered"), TRANSIENT("failed (timeout)"), PERMANENT("rejected (bad address)");
final String text;
Outcome(String text) { this.text = text; }
}
static final int MAX_ATTEMPTS = 4;
record Template(Kind kind, List<Channel> channels, String text) {}
/** Contacts per channel, marketing opt-outs, and quiet hours for marketing (0, 0 = none). */
record User(Map<Channel, String> contacts, Set<Channel> optOut, int quietFrom, int quietTo) {}
/** id is the idempotency key: the same ID twice means the same notification. */
record Request(String id, String user, String template, Map<String, String> params) {}
/** One notification on one channel. attempt = tries so far, due = not before, seq = tie-break. */
record Message(String key, Channel channel, String to, String body, Kind kind, int attempt, long due, long seq) {}
record Accepted(List<Channel> queued, List<String> skipped, long due) {}
record Event(long time, Message msg, Outcome outcome, long retryAt, boolean dead) {}
static final class Rejected extends Exception {
private static final long serialVersionUID = 1L;
Rejected(String msg) { super(msg); }
}
/** Fills {name} placeholders; a missing value is an error, never a blank in a message. */
static String render(String text, Map<String, String> params) throws Rejected {
StringBuilder out = new StringBuilder();
while (true) {
int i = text.indexOf('{');
if (i < 0) return out.append(text).toString();
int j = text.indexOf('}', i);
if (j < 0) throw new Rejected("unclosed placeholder in \"" + text + "\"");
String name = text.substring(i + 1, j);
if (!params.containsKey(name)) throw new Rejected("missing parameter \"" + name + "\"");
out.append(text, 0, i).append(params.get(name));
text = text.substring(j + 1);
}
}
static String clock(long t) {
long s = t % 86400;
return String.format("D%d %02d:%02d:%02d", t / 86400 + 1, s / 3600, s / 60 % 60, s % 60);
}
/** Wait before retry n: 1, 2, 4... seconds (production: minutes, with jitter). */
static long backoff(int attempt) { return 1L << (attempt - 1); }
/** Plays back scripted outcomes per address; after the script runs out, sends succeed. */
static final class FakeProvider {
final Map<String, List<Outcome>> script;
final Map<String, Integer> sent = new HashMap<>();
FakeProvider(Map<String, List<Outcome>> script) { this.script = script; }
Outcome send(Message m) {
sent.merge(m.key(), 1, Integer::sum);
List<Outcome> s = script.getOrDefault(m.to(), List.of());
return m.attempt() < s.size() ? s.get(m.attempt()) : Outcome.OK;
}
}
static boolean inQuiet(User u, long now) {
if (u.quietFrom() == u.quietTo()) return false;
long h = now % 86400 / 3600;
return u.quietFrom() < u.quietTo() ? h >= u.quietFrom() && h < u.quietTo() : h >= u.quietFrom() || h < u.quietTo();
}
static long quietEnd(User u, long now) {
long end = now / 86400 * 86400 + u.quietTo() * 3600L;
return end <= now ? end + 86400 : end;
}
static final class Service {
final Map<String, User> users;
final Map<String, Template> templates;
final Map<Channel, FakeProvider> providers;
final Set<String> seen = new HashSet<>(); // accepted request IDs (production: a store with a TTL)
final Map<String, Integer> marketing = new HashMap<>(); // "user/day" -> marketing messages accepted
int maxMarketingPerDay = 1;
final Map<Channel, List<Message>> queues = new LinkedHashMap<>();
long seq;
final List<Message> dead = new ArrayList<>();
int delivered;
Service(Map<String, User> users, Map<String, Template> templates, Map<Channel, FakeProvider> providers) {
this.users = users;
this.templates = templates;
this.providers = providers;
for (Channel c : Channel.values()) queues.put(c, new ArrayList<>());
}
/** Synchronized: the check-and-accept is one step, so duplicates racing each other can't both pass. */
synchronized Accepted send(Request r, long now) throws Rejected {
if (seen.contains(r.id())) throw new Rejected("duplicate request, already accepted");
User u = users.get(r.user());
if (u == null) throw new Rejected("unknown user");
Template t = templates.get(r.template());
if (t == null) throw new Rejected("unknown template");
String body = render(t.text(), r.params());
String dayKey = r.user() + "/" + now / 86400;
if (t.kind() == Kind.MARKETING && marketing.getOrDefault(dayKey, 0) >= maxMarketingPerDay)
throw new Rejected("marketing limit reached for today");
long due = t.kind() == Kind.MARKETING && inQuiet(u, now) ? quietEnd(u, now) : now;
List<Channel> queued = new ArrayList<>();
List<String> skipped = new ArrayList<>();
for (Channel ch : t.channels()) {
String to = u.contacts().get(ch);
if (to == null) skipped.add(ch.label() + ": no address");
else if (t.kind() == Kind.MARKETING && u.optOut().contains(ch)) skipped.add(ch.label() + ": opted out");
else {
queues.get(ch).add(new Message(r.id() + ":" + ch.label(), ch, to, body, t.kind(), 0, due, ++seq));
queued.add(ch);
}
}
seen.add(r.id());
if (t.kind() == Kind.MARKETING && !queued.isEmpty()) marketing.merge(dayKey, 1, Integer::sum);
return new Accepted(queued, skipped, due);
}
synchronized Message take(long now) {
Message best = null;
for (Channel c : Channel.values())
for (Message m : queues.get(c))
if (m.due() <= now && (best == null || m.due() < best.due() || (m.due() == best.due() && m.seq() < best.seq())))
best = m;
if (best != null) queues.get(best.channel()).remove(best);
return best;
}
Event step(long now) {
Message taken = take(now);
if (taken == null) return null;
Outcome out = providers.get(taken.channel()).send(taken); // network I/O, outside the lock
Message m = new Message(taken.key(), taken.channel(), taken.to(), taken.body(), taken.kind(), taken.attempt() + 1, taken.due(), taken.seq());
synchronized (this) {
if (out == Outcome.OK) {
delivered++;
return new Event(now, m, out, 0, false);
}
if (out == Outcome.TRANSIENT && m.attempt() < MAX_ATTEMPTS) {
long at = now + backoff(m.attempt());
queues.get(m.channel()).add(new Message(m.key(), m.channel(), m.to(), m.body(), m.kind(), m.attempt(), at, m.seq()));
return new Event(now, m, out, at, false);
}
dead.add(m);
return new Event(now, m, out, 0, true);
}
}
synchronized Long nextDue() {
Long next = null;
for (List<Message> q : queues.values())
for (Message m : q) if (next == null || m.due() < next) next = m.due();
return next;
}
synchronized int pending() {
return queues.values().stream().mapToInt(List::size).sum();
}
}
static Service s;
static void send(long now, Request r) {
String head = String.format(" %-16s %-13s %-6s ->", r.id(), r.template(), r.user());
Accepted a;
try {
a = s.send(r, now);
} catch (Rejected e) {
System.out.println(head + " error: " + e.getMessage());
return;
}
String line = "queued " + String.join(", ", a.queued().stream().map(Channel::label).toList());
if (a.due() > now) line += " for " + clock(a.due()) + " (quiet hours)";
if (a.queued().isEmpty()) line = "nothing to send";
if (!a.skipped().isEmpty()) line += " (skipped " + String.join("; ", a.skipped()) + ")";
System.out.println(head + " " + line);
}
static void run(long until) {
for (Long next = s.nextDue(); next != null && next <= until; next = s.nextDue()) {
Event ev = s.step(next);
String line = ev.outcome().text;
if (ev.retryAt() > 0) line += ", retry at " + clock(ev.retryAt());
else if (ev.dead()) line += ", moved to dead letters";
else line += ": " + ev.msg().body();
System.out.printf(" %s %-5s %-19s try %d: %s%n", clock(ev.time()), ev.msg().channel().label(), ev.msg().key(), ev.msg().attempt(), line);
}
}
public static void main(String[] args) {
Map<String, User> users = Map.of(
"rafi", new User(Map.of(Channel.PUSH, "tok-rafi", Channel.SMS, "+8801711000001", Channel.EMAIL, "rafi@example.com"), Set.of(), 22, 8),
"nusrat", new User(Map.of(Channel.SMS, "+8801811000002", Channel.EMAIL, "nusrat@example.com"), Set.of(Channel.EMAIL), 0, 0),
"tanvir", new User(Map.of(Channel.EMAIL, "tanvir@bad-domain"), Set.of(), 0, 0));
Map<String, Template> templates = Map.of(
"order_shipped", new Template(Kind.TRANSACTIONAL, List.of(Channel.PUSH, Channel.SMS), "Order {order} is on the way, arriving {day}"),
"eid_sale", new Template(Kind.MARKETING, List.of(Channel.PUSH, Channel.EMAIL), "Eid sale: {pct}% off until Friday"));
Map<Channel, FakeProvider> providers = Map.of(
Channel.PUSH, new FakeProvider(Map.of()),
Channel.SMS, new FakeProvider(Map.of("+8801811000002", List.of(Outcome.TRANSIENT, Outcome.TRANSIENT))),
Channel.EMAIL, new FakeProvider(Map.of("tanvir@bad-domain", List.of(Outcome.PERMANENT))));
s = new Service(users, templates, providers);
long t1 = 21 * 3600 + 59 * 60 + 50;
System.out.println("D1 21:59:50, the order service sends three requests:");
send(t1, new Request("evt-1", "rafi", "order_shipped", Map.of("order", "BD-7781", "day", "Sunday")));
send(t1, new Request("evt-1", "rafi", "order_shipped", Map.of("order", "BD-7781", "day", "Sunday")));
send(t1, new Request("evt-2", "nusrat", "order_shipped", Map.of("order", "BD-7790", "day", "Monday")));
System.out.println("workers:");
run(t1 + 10);
long t2 = 22 * 3600 + 5;
System.out.println("D1 22:00:05, the marketing team starts a campaign:");
send(t2, new Request("camp-7:rafi", "rafi", "eid_sale", Map.of("pct", "30")));
send(t2, new Request("camp-7:nusrat", "nusrat", "eid_sale", Map.of("pct", "30")));
send(t2, new Request("camp-7:tanvir", "tanvir", "eid_sale", Map.of("pct", "30")));
send(t2, new Request("camp-8:rafi", "rafi", "eid_sale", Map.of("pct", "40")));
send(t2, new Request("camp-9:tanvir", "tanvir", "eid_sale", Map.of()));
System.out.println("workers:");
run(2 * 86400);
System.out.printf("delivered %d, dead letters %d, still queued %d%n", s.delivered, s.dead.size(), s.pending());
}
}// A notification system's core: requests are deduplicated, checked against preferences,
// rendered from templates and put on one queue per channel; workers send them through
// providers, retry transient failures with exponential backoff, and move messages that can't
// be delivered to a dead-letter queue.
#include <array>
#include <cstdint>
#include <cstdio>
#include <map>
#include <mutex>
#include <optional>
#include <set>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
enum class Channel { Push, Sms, Email };
constexpr std::array<Channel, 3> kChannels{Channel::Push, Channel::Sms, Channel::Email};
const char* name(Channel c) { return c == Channel::Push ? "push" : c == Channel::Sms ? "sms" : "email"; }
enum class Kind { Transactional, Marketing };
enum class Outcome { Ok, Transient, Permanent };
const char* text(Outcome o) {
return o == Outcome::Ok ? "delivered" : o == Outcome::Transient ? "failed (timeout)" : "rejected (bad address)";
}
constexpr int kMaxAttempts = 4;
struct Template {
Kind kind;
std::vector<Channel> channels;
std::string text;
};
struct User {
std::map<Channel, std::string> contacts;
std::set<Channel> opt_out; // marketing opt-outs per channel
int quiet_from = 0, quiet_to = 0; // quiet hours for marketing, e.g. 22 to 8
};
struct Request {
std::string id; // the idempotency key
std::string user, tmpl;
std::map<std::string, std::string> params;
};
struct Message {
std::string key; // request ID + channel, e.g. "evt-1:sms"
Channel channel;
std::string to, body;
Kind kind;
int attempt; // attempts made so far
int64_t due; // simulated seconds; not sent before this
int64_t seq; // enqueue order, breaks ties
};
struct Accepted {
std::vector<Channel> queued;
std::vector<std::string> skipped;
int64_t due = 0;
};
struct Event {
int64_t time;
Message msg;
Outcome outcome;
int64_t retry_at = 0;
bool dead = false;
};
struct Rejected : std::runtime_error {
using std::runtime_error::runtime_error;
};
// Fills {name} placeholders; a missing value is an error, never a blank in a message.
std::string render(std::string text, const std::map<std::string, std::string>& params) {
std::string out;
for (;;) {
auto i = text.find('{');
if (i == std::string::npos) return out + text;
auto j = text.find('}', i);
if (j == std::string::npos) throw Rejected("unclosed placeholder in \"" + text + "\"");
std::string key = text.substr(i + 1, j - i - 1);
auto it = params.find(key);
if (it == params.end()) throw Rejected("missing parameter \"" + key + "\"");
out += text.substr(0, i) + it->second;
text = text.substr(j + 1);
}
}
std::string clock(int64_t t) {
int64_t s = t % 86400;
char buf[32];
std::snprintf(buf, sizeof buf, "D%lld %02lld:%02lld:%02lld", static_cast<long long>(t / 86400 + 1),
static_cast<long long>(s / 3600), static_cast<long long>(s / 60 % 60), static_cast<long long>(s % 60));
return buf;
}
// Wait before retry n: 1, 2, 4... seconds (production: minutes, with jitter).
int64_t backoff(int attempt) { return int64_t{1} << (attempt - 1); }
// Plays back scripted outcomes per address; after the script runs out, sends succeed.
struct FakeProvider {
std::map<std::string, std::vector<Outcome>> script;
std::map<std::string, int> sent;
Outcome send(const Message& m) {
sent[m.key]++;
auto it = script.find(m.to);
if (it != script.end() && m.attempt < static_cast<int>(it->second.size())) return it->second[m.attempt];
return Outcome::Ok;
}
};
bool in_quiet(const User& u, int64_t now) {
if (u.quiet_from == u.quiet_to) return false;
int64_t h = now % 86400 / 3600;
if (u.quiet_from < u.quiet_to) return h >= u.quiet_from && h < u.quiet_to;
return h >= u.quiet_from || h < u.quiet_to;
}
int64_t quiet_end(const User& u, int64_t now) {
int64_t end = now / 86400 * 86400 + int64_t{u.quiet_to} * 3600;
return end <= now ? end + 86400 : end;
}
class Service {
public:
Service(std::map<std::string, User> users, std::map<std::string, Template> templates,
std::map<Channel, FakeProvider> providers)
: users_(std::move(users)), templates_(std::move(templates)), providers_(std::move(providers)) {}
// One lock around the check-and-accept, so duplicates racing each other can't both pass.
Accepted send(const Request& r, int64_t now) {
std::lock_guard lock(mu_);
if (seen_.count(r.id)) throw Rejected("duplicate request, already accepted");
auto u = users_.find(r.user);
if (u == users_.end()) throw Rejected("unknown user");
auto t = templates_.find(r.tmpl);
if (t == templates_.end()) throw Rejected("unknown template");
std::string body = render(t->second.text, r.params);
std::string day_key = r.user + "/" + std::to_string(now / 86400);
bool mkt = t->second.kind == Kind::Marketing;
if (mkt && marketing_[day_key] >= max_marketing_per_day) throw Rejected("marketing limit reached for today");
Accepted a;
a.due = mkt && in_quiet(u->second, now) ? quiet_end(u->second, now) : now;
for (Channel ch : t->second.channels) {
auto to = u->second.contacts.find(ch);
if (to == u->second.contacts.end()) {
a.skipped.push_back(std::string(name(ch)) + ": no address");
} else if (mkt && u->second.opt_out.count(ch)) {
a.skipped.push_back(std::string(name(ch)) + ": opted out");
} else {
queues_[ch].push_back({r.id + ":" + name(ch), ch, to->second, body, t->second.kind, 0, a.due, ++seq_});
a.queued.push_back(ch);
}
}
seen_.insert(r.id);
if (mkt && !a.queued.empty()) marketing_[day_key]++;
return a;
}
std::optional<Event> step(int64_t now) {
auto taken = take(now);
if (!taken) return std::nullopt;
Message m = *taken;
Outcome out = providers_.at(m.channel).send(m); // network I/O, outside the lock
m.attempt++;
Event ev{now, m, out};
std::lock_guard lock(mu_);
if (out == Outcome::Ok) {
delivered++;
} else if (out == Outcome::Transient && m.attempt < kMaxAttempts) {
m.due = now + backoff(m.attempt);
ev.retry_at = m.due;
queues_[m.channel].push_back(m);
} else {
dead.push_back(m);
ev.dead = true;
}
return ev;
}
std::optional<int64_t> next_due() {
std::lock_guard lock(mu_);
std::optional<int64_t> next;
for (auto& [ch, q] : queues_)
for (auto& m : q)
if (!next || m.due < *next) next = m.due;
return next;
}
size_t pending() {
std::lock_guard lock(mu_);
size_t n = 0;
for (auto& [ch, q] : queues_) n += q.size();
return n;
}
int max_marketing_per_day = 1;
int delivered = 0;
std::vector<Message> dead;
private:
std::optional<Message> take(int64_t now) {
std::lock_guard lock(mu_);
std::vector<Message>* bq = nullptr;
size_t bi = 0;
for (Channel c : kChannels) {
auto& q = queues_[c];
for (size_t i = 0; i < q.size(); i++) {
const Message& m = q[i];
if (m.due > now) continue;
if (!bq || m.due < (*bq)[bi].due || (m.due == (*bq)[bi].due && m.seq < (*bq)[bi].seq)) bq = &q, bi = i;
}
}
if (!bq) return std::nullopt;
Message m = (*bq)[bi];
bq->erase(bq->begin() + static_cast<std::ptrdiff_t>(bi));
return m;
}
std::mutex mu_;
std::map<std::string, User> users_;
std::map<std::string, Template> templates_;
std::map<Channel, FakeProvider> providers_;
std::set<std::string> seen_; // accepted request IDs (production: a store with a TTL)
std::map<std::string, int> marketing_; // "user/day" -> marketing messages accepted
std::map<Channel, std::vector<Message>> queues_;
int64_t seq_ = 0;
};
std::string join(const std::vector<std::string>& v, const char* sep) {
std::string out;
for (const auto& x : v) out += (out.empty() ? "" : sep) + x;
return out;
}
void send(Service& s, int64_t now, const Request& r) {
char head[96];
std::snprintf(head, sizeof head, " %-16s %-13s %-6s ->", r.id.c_str(), r.tmpl.c_str(), r.user.c_str());
try {
Accepted a = s.send(r, now);
std::vector<std::string> names;
for (Channel c : a.queued) names.push_back(name(c));
std::string line = "queued " + join(names, ", ");
if (a.due > now) line += " for " + clock(a.due) + " (quiet hours)";
if (a.queued.empty()) line = "nothing to send";
if (!a.skipped.empty()) line += " (skipped " + join(a.skipped, "; ") + ")";
std::printf("%s %s\n", head, line.c_str());
} catch (const Rejected& e) {
std::printf("%s error: %s\n", head, e.what());
}
}
void run(Service& s, int64_t until) {
for (auto next = s.next_due(); next && *next <= until; next = s.next_due()) {
Event ev = *s.step(*next);
std::string line = text(ev.outcome);
if (ev.retry_at) line += ", retry at " + clock(ev.retry_at);
else if (ev.dead) line += ", moved to dead letters";
else line += ": " + ev.msg.body;
std::printf(" %s %-5s %-19s try %d: %s\n", clock(ev.time).c_str(), name(ev.msg.channel), ev.msg.key.c_str(),
ev.msg.attempt, line.c_str());
}
}
int main() {
using enum Channel;
std::map<std::string, User> users{
{"rafi", {{{Push, "tok-rafi"}, {Sms, "+8801711000001"}, {Email, "rafi@example.com"}}, {}, 22, 8}},
{"nusrat", {{{Sms, "+8801811000002"}, {Email, "nusrat@example.com"}}, {Email}, 0, 0}},
{"tanvir", {{{Email, "tanvir@bad-domain"}}, {}, 0, 0}},
};
std::map<std::string, Template> templates{
{"order_shipped", {Kind::Transactional, {Push, Sms}, "Order {order} is on the way, arriving {day}"}},
{"eid_sale", {Kind::Marketing, {Push, Email}, "Eid sale: {pct}% off until Friday"}},
};
std::map<Channel, FakeProvider> providers{
{Push, {}},
{Sms, {{{"+8801811000002", {Outcome::Transient, Outcome::Transient}}}, {}}},
{Email, {{{"tanvir@bad-domain", {Outcome::Permanent}}}, {}}},
};
Service s(users, templates, providers);
const int64_t t1 = 21 * 3600 + 59 * 60 + 50;
std::printf("D1 21:59:50, the order service sends three requests:\n");
send(s, t1, {"evt-1", "rafi", "order_shipped", {{"order", "BD-7781"}, {"day", "Sunday"}}});
send(s, t1, {"evt-1", "rafi", "order_shipped", {{"order", "BD-7781"}, {"day", "Sunday"}}});
send(s, t1, {"evt-2", "nusrat", "order_shipped", {{"order", "BD-7790"}, {"day", "Monday"}}});
std::printf("workers:\n");
run(s, t1 + 10);
const int64_t t2 = 22 * 3600 + 5;
std::printf("D1 22:00:05, the marketing team starts a campaign:\n");
send(s, t2, {"camp-7:rafi", "rafi", "eid_sale", {{"pct", "30"}}});
send(s, t2, {"camp-7:nusrat", "nusrat", "eid_sale", {{"pct", "30"}}});
send(s, t2, {"camp-7:tanvir", "tanvir", "eid_sale", {{"pct", "30"}}});
send(s, t2, {"camp-8:rafi", "rafi", "eid_sale", {{"pct", "40"}}});
send(s, t2, {"camp-9:tanvir", "tanvir", "eid_sale", {}});
std::printf("workers:\n");
run(s, 2 * 86400);
std::printf("delivered %d, dead letters %zu, still queued %zu\n", s.delivered, s.dead.size(), s.pending());
}Scaling এর ধাপ
এই ধাপগুলো বোঝানোর জন্য; আসল সীমা hardware আর traffic এর ধরনের উপর নির্ভর করে।
| ধাপ | Traffic | আগে কী ভাঙে | কী বদলায় |
|---|---|---|---|
| 1. 100 user | ঘণ্টায় কয়েকটা message | কিছু না | App এর ভেতরেই একটা background job থেকে পাঠাও (request এর ভেতরে না), provider এর SDK দিয়ে। পাঠানো message এর একটা table। |
| 2. 10K user | মিনিটে কয়েকটা | Caller ধীর provider এর জন্য বসে থাকে; job crash করলে message হারায় | ছোট একটা notification module: database ভিত্তিক job queue, backoff সহ retry, এক জায়গায় template, একটা opt-out flag। |
| 3. 100K user | সেকেন্ডে কয়েকটা | একটা ধীর channel বাকিগুলো আটকায়; retry র পর duplicate | প্রতি channel এ আলাদা queue আর worker, dedup store সহ idempotency key, dead-letter queue, status table। |
| 4. 1M user | সেকেন্ডে কয়েকশো, campaign এর ধাক্কা | Campaign OTP দেরি করায়; provider এর rate limit; প্রতি message এ পছন্দ পড়া | Priority lane, throttle সহ campaign scheduler, পছন্দের cache, quiet hours, Kafka বা managed queue এর মতো broker। |
| 5. 10M+ user | সেকেন্ডে কয়েক হাজার | একটা provider এর outage; status লেখা; কয়েকটা region আর ভাষা | প্রতি channel এ circuit breaker সহ দুইটা provider, wide-column status store, provider এর কাছাকাছি region ভিত্তিক worker, delivery webhook, বড় scale এ localisation। |
প্রতিটা ধাপে যেটা ভাঙে সেটা আচরণ (অপেক্ষা, duplicate, আটকে যাওয়া), কাঁচা size না। 10,000 user এর startup এর Kafka লাগে না; লাগে একটা job queue, retry আর একটা idempotency key।
Failure এর পরিস্থিতি
| Failure | কী হয় | কীভাবে ধরা পড়ে | কীভাবে সারে | Data নিরাপদ? | User এর উপর প্রভাব |
|---|---|---|---|---|---|
| Provider ধীর বা বন্ধ | Call timeout; retry জমে | প্রতি provider এর error rate আর latency | Backoff, circuit breaker, secondary provider এ যাওয়া | Message queue তে অপেক্ষা করে, কিছু হারায় না | শুধু ওই channel এ দেরি |
| পাঠানোর মাঝখানে worker crash | Lease এ নেওয়া message আবার আসে | Queue আবার দেয়, worker health | আরেকটা worker নেয়; আগেই sent হওয়া message status দেখে বাদ যায় | At-least-once; পাঠানোর পরের ছোট ফাঁকে duplicate হতে পারে | কদাচিৎ, একটা duplicate |
| Queue broker নষ্ট | API queue তে রাখতে পারে না | Enqueue error | Broker এর replica দায়িত্ব নেয়; API 503 দেয় যাতে caller একই key দিয়ে আবার চেষ্টা করে | Caller retry করে; dedup দুইবার হওয়া আটকায় | কয়েক সেকেন্ড থেকে কয়েক মিনিট দেরি |
| Redis (dedup) বন্ধ | API duplicate দেখতে পারে না | Redis error | Status store এ idempotency key এর unique insert এ ফিরে যাও (ধীর), বা কম জরুরি template এ অল্প সময় duplicate এর ঝুঁকি মেনে নাও | অল্প সময় dedup দুর্বল, message হারায় না | হয়তো একটা duplicate |
| Preference DB বন্ধ | Opt-out পড়া যায় না | Error, cache miss | Cache থেকে চালাও; marketing এ পছন্দ পড়া না গেলে পাঠিও না (fail closed) | Opt-out মানা হয় | Marketing দেরি; transactional চলে |
| Duplicate request | Caller retry করে | Dedup hit এর metric | একই key প্রথম ফলটা দেয় | দুইবার পাঠানো হয় না | কিছু না |
| Campaign এর বন্যা | একসাথে আড়াই কোটি message queue তে | প্রতি priority তে queue এর গভীরতা আর বয়স | Throttle করা scheduler; আলাদা priority lane; pause button | Queue তে, হারায় না | Campaign ধীর; OTP তে কিছু হয় না |
| খারাপ template deploy | প্রতিটা message ভুল render হয় | Render error, user এর অভিযোগ হঠাৎ বেড়ে যাওয়া | Template version করা: আগের version এ ফিরে যাও | যেগুলো এখনো যায়নি সেগুলো ঠিক version নেয় | বড়জোর কয়েকটা ভুল message |
| Dead letter বাড়ছে | অনেক message স্থায়ীভাবে fail | DLQ size এর alert | খুঁজে দেখো (মেয়াদ শেষ device token, ভুল number, provider বদল), ঠিক করো, আবার পাঠাও | DLQ রেখে দেয় | আবার পাঠানো পর্যন্ত দেরি |
মূল নিয়ম: নেওয়া request কখনো হারাবে না (হয় queue তে, নয় DLQ তে), marketing এ fail closed (সন্দেহ হলে পাঠিও না), আর OTP তে fail open (হাল না ছেড়ে অন্য provider চেষ্টা করো)।
Reliability আর observability
- SLI: নেওয়ার success rate আর latency; priority ধরে (OTP, transactional, marketing) নেওয়া থেকে provider এর "sent" পর্যন্ত সময়; channel আর provider ধরে delivery success rate; DLQ এর size।
- SLO: 99.99% নেওয়া সফল; 99% OTP 5 s এর মধ্যে পাঠানো; 99% transactional message 1 মিনিটের মধ্যে; DLQ দিনের পরিমাণের 0.1% এর নিচে।
- SLA: ভেতরের caller দের জন্য SLO টাই প্রকাশ করো; বাইরের customer এর জন্য (service হিসেবে বিক্রি করলে) মাসিক availability এর একটা ঢিলা সংখ্যা।
- Metric: প্রতি topic এ queue এর গভীরতা আর সবচেয়ে পুরনো message এর বয়স (আটকে থাকা consumer ধরতে গভীরতার চেয়ে বয়স ভালো), প্রতি provider এ retry, circuit breaker এর অবস্থা, dedup hit rate, পছন্দের কারণে বাদ পড়ার সংখ্যা, প্রতি channel এ দিনের খরচ।
- Alert: OTP latency এর SLO পুড়লে আর কোনো provider failover হলে page; DLQ বাড়লে আর স্থায়ী failure এর হার বাড়লে (প্রায়ই মেয়াদ শেষ device token) ticket।
- Log: প্রতি চেষ্টায় message key, channel, provider, ফল আর latency সহ একটা structured লাইন; OTP code বা পুরো message কখনো log করবে না।
- Tracing: producer এর request থেকে API, queue আর worker হয়ে provider call পর্যন্ত একটা trace; message key হলো correlation ID।
- Health check: worker শুধু broker এ পৌঁছাতে পারলেই healthy বলে; provider বন্ধ থাকা একটা metric, worker restart করার কারণ না।
- Disaster recovery: broker zone জুড়ে replicate করা; status store একাধিক zone এ; DLQ আর campaign এর অগ্রগতি দিয়ে outage এর পর আবার পাঠানো যায়।
Security
- শুধু template, কখনো নিজের লেখা না: caller যা ইচ্ছা পাঠাতে পারে না, এতে একটা service hack হলে ক্ষতি কম হয়, আর আমাদের নিজের sender ID দিয়ে phishing বন্ধ থাকে।
- Authentication আর authorization: mTLS বা signed service token; প্রতিটা caller কোন template পাঠাতে পারবে সেটা ঠিক করা (auth service OTP পাঠাতে পারে, marketing tool marketing, আর কেউ না)।
- Rate limiting আর অপব্যবহার: প্রতি caller এর quota; প্রতি প্রাপকের OTP সীমা (যেমন ঘণ্টায় 5 টা), যাতে SMS bombing আর toll fraud (attacker premium number এ SMS পাঠাতে বাধ্য করে) ঠেকানো যায়।
- Secret: provider এর API key secret manager এ, নিয়মিত বদলানো; webhook signature যাচাই।
- Privacy: OTP আর message এর লেখা log হয় না; phone number আর email at rest encrypted; status record এর মেয়াদ থাকে; data মুছে ফেলার অনুরোধ এলে contact আর history মুছে ফেলো।
- সম্মতি: marketing শুধু যারা opt in করেছে তাদের, প্রতিটা marketing email এ unsubscribe link, আর স্থানীয় নিয়মে লাগলে SMS এ STOP keyword।
- Input validation: email HTML এ render করার সময় parameter escape, আর SMS এর জন্য দৈর্ঘ্যের সীমা।
Trade-off analysis
| সিদ্ধান্ত | বেছে নিয়েছি | বিকল্প | কেন এটা | কখন বিকল্পে যাবে |
|---|---|---|---|---|
| নেওয়ার ধরন | Async: queue তে রেখে 202 | API call এর ভেতরেই পাঠানো | Caller কখনো provider এর জন্য বসে থাকে না; failure নিরাপদে retry হয় | খুব ছোট system যেখানে queue এর চেয়ে একটা background job সহজ |
| Queue | প্রতি channel এ একটা, সাথে একটা high-priority lane | একটা shared queue | আলাদা রাখা: ধীর SMS gateway বা campaign OTP আটকাতে পারে না | খুব কম পরিমাণ যেখানে একটা queue চালানো সহজ |
| Duplicate | Idempotency key + SET NX + status দেখা | ভরসা করা যে broker এর "exactly once" সামলে নেবে | শেষ ধাপটা বাইরের call, কোনো broker সেটা নিয়ন্ত্রণ করে না | OTP আর payment এ কখনো বাদ দিও না; কম গুরুত্বের push এ হয়তো |
| Retry | Jitter সহ exponential backoff, সর্বোচ্চ চেষ্টা, DLQ | সাথে সাথে retry, বা চিরকাল retry | Provider কে সামলে ওঠার সময় দেয়, আর failure চোখে পড়ে | চিরকাল retry কখনো না; প্রতি channel এ সংখ্যা ঠিক করো |
| Status store | User ধরে partition করা wide-column store | PostgreSQL table | দিনে 10 কোটির বেশি write, TTL আর সহজ lookup সহ | দিনে কয়েক লাখের নিচে partition সহ PostgreSQL সহজ |
| Broker | Kafka (বা managed queue) | Database ভিত্তিক job table | বেশি throughput, replay, user ধরে partition | সেকেন্ডে কয়েকশো message পর্যন্ত job table এ চলে |
| Provider | প্রতি channel এ circuit breaker সহ দুইটা | একটা provider | Outage আর এলাকা ভিত্তিক delivery সমস্যা হরহামেশা হয় | কম পরিমাণ, বা দ্বিতীয় integration এর বাজেট নেই |
ডানের প্রতিটা বিকল্প চালানো সহজ; বামের প্রতিটা সিদ্ধান্তের দাম দিচ্ছে একটা নির্দিষ্ট requirement (আলাদা রাখা, duplicate না, durability)। কোন requirement কোন অংশটাকে যুক্তিসঙ্গত করে, interviewer সেটাই শুনতে চায়।
শেষ architecture
এই design যুক্তিসঙ্গত কারণ প্রতিটা অংশ একটা requirement এর উত্তর: দ্রুত নেওয়া আর durability আসে queue তে রেখে ফেরত দেওয়া থেকে; duplicate না হওয়া আসে API তে idempotency key আর worker এ status দেখা থেকে; আলাদা রাখা আসে প্রতি channel এর queue আর priority lane থেকে; provider এর অনির্ভরযোগ্যতা সামলায় backoff, DLQ আর দ্বিতীয় provider; user এর নিয়ম নেওয়ার সময় চলে আর পাঠানোর সময় আবার দেখা হয়; আর status store আর webhook উত্তর দেয় "সে কি পেয়েছে?"। 1,000 user এ এর কিছুই লাগে না, আর কোনটা কখন যোগ করতে হয় সেটা scaling এর table দেখায়।
Interview এর আলোচনা
1. Order service থেকে সোজা SMS provider কে call করলে সমস্যা কী?
উত্তর
Provider ধীর আর প্রায়ই fail করে; order service ওর জন্য বসে থাকবে, নিজের retry লাগবে, আর retry তে duplicate যেতে পারে। প্রতিটা team আবার template, opt-out আর সীমা বানাবে। একটা notification service millisecond এ durable ভাবে request নিয়ে নেয় আর এসব এক জায়গায় সামলায়। খুব ছোট product এর জন্য একই app এ retry সহ একটা background job ভালো প্রথম ধাপ।
2. একজন user যেন একই OTP দুইবার না পায়, কীভাবে নিশ্চিত করবে?
উত্তর
Caller event এর সাথে বাঁধা একটা idempotency key পাঠায়; API atomic SET NX করে আর বারবার এলে প্রথম ফলটা দেয়। Worker পাঠানোর আগে status store দেখে, তাই queue আবার দিলে বাদ যায়। Provider idempotency key নিলে message key টা পাঠাও। বাকি থাকে provider নেওয়া আর আমাদের status লেখার মাঝের crash এর ফাঁক; ছোট করা যায়, শূন্য না।
3. আড়াই কোটি user এর campaign শুরু হলো ঠিক যখন login হঠাৎ বেড়ে গেছে। OTP এর কী হবে?
উত্তর
খারাপ কিছু না, যদি OTP এর নিজের high-priority topic আর worker থাকে, আর campaign scheduler throttle করা গতিতে normal queue তে দেয়। একটা shared queue হলে OTP লাখো marketing message এর পেছনে বসে থাকত, আর ঠিক এজন্যই lane গুলো আছে।
4. আলাদা time zone এর user দের quiet hours কীভাবে সামলাবে?
উত্তর
পছন্দের সাথে প্রতিটা user এর time zone রাখো, আর quiet hours স্থানীয় সময়ে হিসাব করো। Window এর মধ্যে marketing এলে message এর due time window এর শেষে বসাও, আর একটা delayed queue বা scheduler সেটা ছাড়বে। Transactional message quiet hours মানে না। পিছানো message শেষমেশ পাঠানোর সময় পছন্দ আবার দেখো।
5. SMS gateway 20 মিনিট বন্ধ। ধাপে ধাপে বলো কী হয়।
উত্তর
Call timeout হয়; error rate একটা সীমা পেরোলে circuit breaker খুলে যায় আর gateway কে পেটানো বন্ধ করে। দ্বিতীয় provider থাকলে SMS সেখানে যায়। না থাকলে message backoff সহ queue তে অপেক্ষা করে; product অনুমতি দিলে OTP push বা email এ যেতে পারে। Latency আর error rate এ alert বাজে। Gateway ফিরলে breaker আধা খোলে, তারপর বন্ধ হয়, আর জমা message provider এর rate limit মেনে বের হয়।
6. Message টা আসলেই পৌঁছেছে কিনা কীভাবে জানবে?
উত্তর
"Sent" মানে provider নিয়ে নিয়েছে। "Delivered" আসে পরে provider এর webhook থেকে (SMS delivery report, email event, কিছু push receipt), provider message ID দিয়ে মিলিয়ে। সব channel ঠিকমতো delivery জানায় না, তাই dashboard এ দুইটাই দেখাও, আর "sent" কে "delivered" বলে চালিয়ো না।
7. App এর ভেতরে একটা inbox লাগলে কী বদলাবে?
উত্তর
"In-app" কে একটা channel বানাও যার worker user ধরে partition করা একটা inbox store এ লেখে (নতুনটা আগে), read/unread flag সহ, আর user online থাকলে app এর চালু WebSocket দিয়ে সাথে সাথে update পাঠায়। Status store এর user ভিত্তিক partition দেখতে inbox এর মতোই, কিন্তু আলাদা রাখো: inbox হলো product এর data, তার নিজের মেয়াদ।
সাধারণ ভুল: API request এর ভেতরে পাঠানো; সবকিছুর জন্য একটা queue; সাথে সাথে আর চিরকাল retry; broker এর "exactly once" এর উপর ভরসা; caller কে নিজের লেখা পাঠাতে দেওয়া; opt-out আর quiet hours ভুলে যাওয়া; dead-letter queue না থাকায় failure হাওয়া হয়ে যাওয়া; "sent" কে "delivered" ধরা।
Interviewer যা দেখে: 202 সহ পরিষ্কার async সীমানা, duplicate এর প্রতিটা উৎস ঢাকে এমন একটা idempotency এর গল্প, channel আর priority ধরে আলাদা রাখা, যুক্তিসঙ্গত retry আর DLQ এর আচরণ, ঠিক জায়গায় পছন্দ মানা, campaign এর ধাক্কা আর provider এর সীমার সংখ্যা, আর exactly-once এর মানে কী হতে পারে আর কী পারে না সেটা নিয়ে সততা।
নিজে design করে solution টা বুঝেছো? Mark করো, তাহলে progress ট্র্যাক হবে।