Project 3: Mini Foodpanda
Order, real-time status, queue, cache একসাথে
এটা তোমার প্রথম "system" project: একটা app না, কয়েকটা অংশ মিলে একটা system। Database, cache, queue, background worker, real-time update, push notification, rate limit, সব একসাথে। ছয় মাসে যা শিখেছো তার পরীক্ষা। শেষে এটা interview এ বলার মতো একটা গল্প হবে: "আমি একটা food delivery এর backend বানিয়েছি, load test করেছি, আর এই এই bottleneck ঠিক করেছি।"
#তিন ধরনের user
- Customer (Flutter app): restaurant খোঁজে, menu দেখে, order দেয়, live status দেখে
- Restaurant (Flutter app বা web): নতুন order পায়, accept/reject করে, "রান্না শেষ" বলে
- Rider (Flutter app): order নেয়, location পাঠায়, "পৌঁছে দিয়েছি" বলে
#Functional requirement
- Customer: এলাকা অনুযায়ী খোলা restaurant এর list, menu, cart, order (cash on delivery যথেষ্ট), order এর ইতিহাস, live status
- Restaurant: নতুন order এর real-time খবর, accept/reject, status বদল, menu item চালু/বন্ধ
- Rider: কাছের অপেক্ষমান order দেখা, নেওয়া, প্রতি ৫ সেকেন্ডে location পাঠানো, delivered
- Push notification: order accepted, rider পথে, delivered
- Admin: দিনের মোট order আর বিক্রির report (রাতে তৈরি)
#Non-functional requirement
| বিষয় | লক্ষ্য |
|---|---|
| Restaurant list | p95 < 200 ms (cache সহ) |
| Order | Duplicate না (idempotency), stock/দামের সঠিকতা |
| Status update | ২ সেকেন্ডের মধ্যে customer এর screen এ |
| Rider location | প্রতি ৫ সেকেন্ডে, database কে না ডুবিয়ে |
| Login/OTP | Rate limited |
| Load test | ২০০ concurrent customer এ p95 < 500 ms |
#Architecture
#Data model (মূল অংশ)
users (id, phone UNIQUE, name, role: customer|restaurant|rider|admin, ...)
restaurants (id, owner_id, name, area, lat, lng, is_open, rating_avg, ...)
menu_items (id, restaurant_id, name, price, is_available)
orders (id, public_id UUID, customer_id, restaurant_id, rider_id NULL,
status, subtotal, delivery_fee, total, address, lat, lng,
idempotency_key, created_at, updated_at)
UNIQUE (customer_id, idempotency_key)
order_items (order_id, menu_item_id, qty, unit_price)
order_events (id, order_id, from_status, to_status, actor_id, created_at) ← ইতিহাস
devices (id, user_id, fcm_token UNIQUE, platform, updated_at)#Order এর অবস্থা: state machine
Order এর status এলোমেলো বদলাতে দেবে না। অনুমোদিত পথ এক জায়গায় লেখো:
ALLOWED = {
"pending": {"accepted", "rejected", "cancelled"},
"accepted": {"preparing", "cancelled"},
"preparing": {"ready"},
"ready": {"picked_up"},
"picked_up": {"delivered"},
"delivered": set(),
"rejected": set(),
"cancelled": set(),
}
WHO_CAN = {
"accepted": {"restaurant"}, "rejected": {"restaurant"}, "preparing": {"restaurant"},
"ready": {"restaurant"}, "picked_up": {"rider"}, "delivered": {"rider"},
"cancelled": {"customer", "restaurant"},
}
async def change_status(self, order_id: int, to: str, actor: User) -> Order:
async with self.db.begin():
order = await self.repo.get_for_update(order_id) # row lock
if to not in ALLOWED[order.status]:
raise InvalidTransitionError(order.status, to)
if actor.role not in WHO_CAN[to] or not self.is_party(order, actor):
raise PermissionDeniedError()
self.db.add(OrderEvent(order_id=order.id, from_status=order.status, to_status=to, actor_id=actor.id))
order.status = to
self.db.add(OutboxEvent(type="OrderStatusChanged", payload={"order_id": order.id, "status": to}))
return orderএকসাথে state machine, row lock (lost update ঠেকাতে), permission, ইতিহাস, আর outbox। তারপর outbox relay থেকে: Redis pub/sub এ publish (live update) আর push notification এর Celery job।
#Rider এর location: সবচেয়ে বেশি চাপ
২০০ rider × প্রতি ৫ সেকেন্ডে = প্রতি সেকেন্ডে ৪০টা write। বড় শহরে ১০,০০০ rider হলে ২,০০০/সেকেন্ড। প্রতিটা PostgreSQL এ লিখলে অপ্রয়োজনীয় চাপ।
@router.post("/rider/location")
async def update_location(data: LocationIn, rider: RiderUser):
await redis.geoadd("riders:online", (data.lng, data.lat, f"rider:{rider.id}"))
await redis.set(f"rider:last_seen:{rider.id}", now_ts(), ex=60)
if data.order_id: # চলমান delivery থাকলে customer কে live
await publish(f"order:{data.order_id}", {"type": "rider_location", "lat": data.lat, "lng": data.lng})
return {"ok": True}- শুধু সর্বশেষ অবস্থান Redis এ (GEO), database এ না
- কাছের rider খোঁজা:
GEOSEARCH riders:online ... BYRADIUS 3 km - ইতিহাস লাগলে (পরে বিরোধ মেটাতে) batch করে প্রতি মিনিটে database বা আলাদা store এ
#Background job
| Job | কখন |
|---|---|
| Push notification | প্রতিটা status বদলে (outbox থেকে) |
| Restaurant ৫ মিনিটে accept না করলে auto-cancel + customer কে জানানো | প্রতি মিনিটে beat |
| দৈনিক report (মোট order, বিক্রি, restaurant অনুযায়ী) | রাত ২টা |
| পুরনো rider location ইতিহাস মোছা | দৈনিক |
#কাজের ধাপ (প্রায় ৪ সপ্তাহ)
- সপ্তাহ ১: Model, migration, OTP login (Redis + rate limit), restaurant list (cache সহ), menu
- সপ্তাহ ২: Cart আর order (idempotency, দাম server এ হিসাব, client এর পাঠানো দাম বিশ্বাস না), state machine, order_events, outbox relay
- সপ্তাহ ৩: SSE/WebSocket এ live status, rider location (Redis GEO), Celery + FCM push, auto-cancel job
- সপ্তাহ ৪: Customer Flutter app (restaurant এর অংশ ছোট web বা একই app এ role ভিত্তিক), Locust load test, Sentry, README আর architecture diagram
#Load test এর গল্প লেখো
Locust দিয়ে চালিয়ে README তে লেখো:
"১০০ user এ restaurant list এর p95 ছিল ৯৫০ ms। EXPLAIN দেখে
restaurants(area) WHERE is_openpartial index দিলাম, আর ৬০ সেকেন্ডের Redis cache। এখন ২০০ user এ p95 ৮০ ms, database CPU ৭০% থেকে ১৫%।"
এই একটা অনুচ্ছেদ interview এ ৫ মিনিটের আলোচনা তৈরি করে, আর দেখায় তুমি মেপে কাজ করো।
#শেষ করার checklist
#নিজেকে যাচাই করো
Order এর status বদলাতে state machine কেন রাখবে?
যাতে অসম্ভব পরিবর্তন (delivered থেকে pending, বা রান্না শুরুর আগে picked up) না হয়, আর কে কোন পরিবর্তন করতে পারে সেটা এক জায়গায় নিয়ন্ত্রিত থাকে। Bug আর অপব্যবহার দুটোই কমে।
Rider এর প্রতিটা location PostgreSQL এ লেখা কেন ভালো না?
খুব বেশি write (হাজার/সেকেন্ড), অথচ বেশিরভাগ সময় শুধু সর্বশেষ অবস্থান লাগে। Redis GEO তে সর্বশেষটা রাখলে দ্রুত আর সস্তা, আর কাছের rider খোঁজাও সহজ। ইতিহাস লাগলে batch করে আলাদা জায়গায়।
Client এর পাঠানো দাম কেন বিশ্বাস করবে না?
App বা request বদলে যে কেউ নিজের ইচ্ছামতো দাম পাঠাতে পারে। দাম, fee, discount সবসময় server নিজের database থেকে হিসাব করবে।
চিহ্ন দিলে sidebar আর রোডম্যাপে এই অধ্যায়ের station ভরে যাবে।