Real-time: WebSocket

Live order status, chat

রোডম্যাপের মাস ৬পড়তে প্রায় ১৫ মিনিট

Foodpanda তে order দেওয়ার পর status "রান্না হচ্ছে" থেকে "rider পথে" তে নিজে থেকে বদলে যায়, তোমাকে refresh করতে হয় না। Chat এ message সাথে সাথে আসে। এগুলো real-time। API অধ্যায়ে দেখেছো এর কয়েকটা উপায় (polling, SSE, WebSocket)। এই অধ্যায়ে FastAPI দিয়ে WebSocket আর SSE দুটোই বানাবো, আর দেখবো একাধিক server এ এটা কীভাবে চালাতে হয়।

#WebSocket এর মূল কথা

সাধারণ HTTP: client জিজ্ঞেস করে, server উত্তর দেয়, শেষ। WebSocket: একবার connection তৈরি হয়, তারপর খোলা থাকে, আর দুই দিক থেকেই যেকোনো সময় message পাঠানো যায়।

Python
# realtime/ws.py
from fastapi import APIRouter, WebSocket, WebSocketDisconnect

router = APIRouter()


@router.websocket("/ws/echo")
async def echo(ws: WebSocket):
    await ws.accept()
    try:
        while True:
            text = await ws.receive_text()
            await ws.send_text(f"তুমি বললে: {text}")
    except WebSocketDisconnect:
        pass                                    # client চলে গেছে

Browser console বা websocat দিয়ে test:

Terminal
brew install websocat
websocat ws://127.0.0.1:8000/ws/echo

#Order tracking: একটা বাস্তব উদাহরণ

Customer তার order এর status live দেখবে। Restaurant বা rider status বদলালে customer এর app এ সাথে সাথে যাবে।

Python
# realtime/manager.py
from collections import defaultdict
from fastapi import WebSocket


class ConnectionManager:
    """এই server এ কে কোন order দেখছে তার হিসাব।"""

    def __init__(self) -> None:
        self.rooms: dict[str, set[WebSocket]] = defaultdict(set)

    async def join(self, room: str, ws: WebSocket) -> None:
        await ws.accept()
        self.rooms[room].add(ws)

    def leave(self, room: str, ws: WebSocket) -> None:
        self.rooms[room].discard(ws)
        if not self.rooms[room]:
            del self.rooms[room]

    async def broadcast_local(self, room: str, message: dict) -> None:
        dead = []
        for ws in self.rooms.get(room, ()):
            try:
                await ws.send_json(message)
            except Exception:
                dead.append(ws)                 # বন্ধ connection সরাও
        for ws in dead:
            self.leave(room, ws)


manager = ConnectionManager()
Python
@router.websocket("/ws/orders/{order_id}")
async def order_updates(ws: WebSocket, order_id: int, token: str):
    user = await authenticate_ws(token)                       # login যাচাই
    if user is None or not await can_view_order(user, order_id):
        await ws.close(code=4403)                             # অনুমতি নেই
        return
    room = f"order:{order_id}"
    await manager.join(room, ws)
    try:
        while True:
            await ws.receive_text()                           # client এর ping, বা কিছু না
    except WebSocketDisconnect:
        manager.leave(room, ws)

#সমস্যা: একাধিক server

এখানেই আসল system design এর চ্যালেঞ্জ। ধরো তোমার ৩টা API server:

  • Customer এর WebSocket connection আছে server ১ এ
  • Restaurant "রান্না শেষ" বললো, request গেল server ৩ এ
  • Server ৩ এর manager এ এই order এর কোনো connection নেই। Customer কিছু পেলো না।
Customer app Restaurant API server ১ API server ২ API server ৩ WS PATCH Redis Pub/Sub channel: order:42 ১. publish ২. সবাই পায় ৩. server ১ নিজের customer কে পাঠায়
যে server এ ঘটনা ঘটলো সে Redis এ publish করে। সব server subscribe করে আছে, যার কাছে সংশ্লিষ্ট connection আছে সে পাঠিয়ে দেয়।
Python
# realtime/pubsub.py
import asyncio
import json
from app.core.redis import redis


async def publish(room: str, message: dict) -> None:
    await redis.publish(room, json.dumps(message, ensure_ascii=False))


async def listen_forever() -> None:
    """প্রতিটা server চালু হলে এটা background এ চলে।"""
    pubsub = redis.pubsub()
    await pubsub.psubscribe("order:*")
    async for msg in pubsub.listen():
        if msg["type"] == "pmessage":
            room = msg["channel"]
            await manager.broadcast_local(room, json.loads(msg["data"]))
Python
# main.py: app চালু হলে listener শুরু
from contextlib import asynccontextmanager

@asynccontextmanager
async def lifespan(app):
    task = asyncio.create_task(listen_forever())
    yield
    task.cancel()

app = FastAPI(lifespan=lifespan)
Python
# order status বদলানোর service এ
async def update_status(self, order_id: int, status: OrderStatus) -> Order:
    order = ...                                             # database এ বদলাও, commit
    await publish(f"order:{order_id}", {"type": "status", "status": status, "at": now_iso()})
    return order

#Connection টিকিয়ে রাখা

  • Heartbeat: দুর্বল network এ connection "মরে" যায় কিন্তু কেউ টের পায় না। প্রতি ২০-৩০ সেকেন্ডে ping/pong পাঠাও, উত্তর না এলে বন্ধ করো।
  • Reconnect (client এ): বন্ধ হলে আবার যুক্ত হও, কিন্তু সাথে সাথে বারবার না, ক্রমশ বাড়তি অপেক্ষা (১, ২, ৪, ৮ সেকেন্ড... সাথে random)। না হলে server restart এর পর লাখো app একসাথে ঝাঁপিয়ে পড়বে।
  • Load balancer এর timeout: অনেক load balancer অলস connection ৬০ সেকেন্ডে কেটে দেয়। Heartbeat সেটাও ঠেকায়।
  • Mobile background: app background এ গেলে OS connection বন্ধ করে দেয়। তখন খবর দিতে push notification।

#SSE: একমুখী হলে আরও সহজ

Order tracking এ আসলে data শুধু server থেকে client এ যায়। তাহলে SSE (Server-Sent Events) যথেষ্ট, আর সেটা সাধারণ HTTP:

Python
import asyncio
import json
from fastapi.responses import StreamingResponse


@router.get("/orders/{order_id}/events")
async def order_events(order_id: int, user: CurrentUser):
    await ensure_can_view(user, order_id)
    pubsub = redis.pubsub()
    await pubsub.subscribe(f"order:{order_id}")

    async def stream():
        try:
            yield f"data: {json.dumps(await current_status(order_id))}\n\n"     # প্রথমে বর্তমান অবস্থা
            while True:
                msg = await pubsub.get_message(ignore_subscribe_messages=True, timeout=15)
                if msg:
                    yield f"data: {msg['data']}\n\n"
                else:
                    yield ": keep-alive\n\n"                                    # heartbeat
        finally:
            await pubsub.unsubscribe()

    return StreamingResponse(stream(), media_type="text/event-stream",
                             headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})

SSE এর সুবিধা: সাধারণ HTTP header দিয়ে auth, browser নিজে থেকে reconnect করে, proxy আর load balancer এর সাথে কম ঝামেলা। AI এর উত্তর অক্ষরে অক্ষরে পাঠানোর জন্যও এটাই ব্যবহার হবে (LLM অধ্যায়)।

#Flutter দিকে

Dart (Flutter)
// web_socket_channel package
final channel = WebSocketChannel.connect(
  Uri.parse('wss://api.example.com/ws/orders/42?token=$ticket'),
);
channel.stream.listen(
  (data) => onStatus(jsonDecode(data)),
  onDone: scheduleReconnect,           // বন্ধ হলে backoff সহ আবার
  onError: (_) => scheduleReconnect(),
);

#অনুশীলন

#নিজেকে যাচাই করো

একাধিক server এ WebSocket চালাতে Redis pub/sub কেন লাগে?

Client এর connection একটা নির্দিষ্ট server এ, কিন্তু ঘটনা অন্য server এ ঘটতে পারে। Pub/sub এর মাধ্যমে ঘটনাটা সব server এ পৌঁছায়, যার কাছে connection আছে সে client কে পাঠায়।

Redis Pub/Sub এর message হারালে কী হবে, আর app এর দিক থেকে কী করা উচিত?

Pub/Sub message রাখে না, সেই মুহূর্তে কেউ না শুনলে হারায়। তাই database কে সত্যের উৎস রাখতে হয়: reconnect এর পর app প্রথমে REST দিয়ে সর্বশেষ অবস্থা আনবে, তারপর live update শুনবে।

Reconnect এ সাথে সাথে বারবার চেষ্টা না করে backoff কেন?

Server restart বা সমস্যার সময় লাখো client একসাথে বারবার যুক্ত হতে চাইলে server আবার ডুবে যাবে। ক্রমশ বাড়তি অপেক্ষা আর random যোগ করলে চাপ ছড়িয়ে পড়ে।

Order tracking এর জন্য WebSocket এর বদলে SSE কেন যথেষ্ট হতে পারে?

এখানে data শুধু server থেকে client এ যায়। SSE সাধারণ HTTP এর উপর চলে, auth header ব্যবহার করা যায়, browser নিজে reconnect করে, আর proxy এর সাথে কম ঝামেলা। দুই দিকে ঘন ঘন কথা লাগলে তবেই WebSocket।

চিহ্ন দিলে sidebar আর রোডম্যাপে এই অধ্যায়ের station ভরে যাবে।