কেস স্টাডি: স্কেলে Chat System
message delivery, read receipt, typing indicator এবং fan-out সহ একটি production chat system ডিজাইন করে বানান।
গল্পে বুঝি
ইবনে সিনার পাড়ায় একটা ছোট ডাকঘর আছে, আর আল-খোয়ারিজমি হলো সেখানকার একমাত্র পিয়ন। কেউ চিঠি পাঠাতে চাইলে চিঠিটা প্রথমে ডাকঘরে আসে, আল-খোয়ারিজমি দেখে ঠিকানাটা কার — তারপর সেই লোকের কাছে পৌঁছে দেয়। ফাতিমা আল-ফিহরি যদি বাড়িতেই থাকে, আল-খোয়ারিজমি সঙ্গে সঙ্গে দরজায় গিয়ে চিঠিটা হাতে তুলে দেয়, ফাতিমা আল-ফিহরি একটা সই করে দেয় “পেয়েছি” বলে। কিন্তু ফাতিমা আল-ফিহরি যদি বাইরে থাকে, আল-খোয়ারিজমি চিঠিটা ফেলে না দিয়ে ডাকঘরেই জমা রাখে — ফাতিমা আল-ফিহরি যেই মুহূর্তে পাড়ায় ফেরে, আল-খোয়ারিজমি জমানো সব চিঠি একসাথে তার হাতে দিয়ে দেয়।
পাড়ার একটা সমিতির নোটিশ এলে ব্যাপারটা আরেকটু বড় — আল-খোয়ারিজমি সেই একটা নোটিশের অনেকগুলো কপি বানায়, সমিতির প্রতিটা সদস্যের নামে আলাদা করে বিলি করে দেয়, যে বাড়িতে নেই তার কপি জমা থাকে। আর সই একবারে শেষ হয় না — চিঠি হাতে পাওয়ার সময় একটা সই, আর ফাতিমা আল-ফিহরি যখন খামটা খুলে সত্যিই পড়ে ফেলে তখন আল-খোয়ারিজমি আরেকটা সই নিয়ে যায়, যাতে পাঠানো লোকটা জানতে পারে চিঠি শুধু পৌঁছায়নি, পড়াও হয়েছে।
এই ডাকঘরটাই আসলে একটা chat system-এর message routing server। ফাতিমা আল-ফিহরি বাড়িতে থাকলে সঙ্গে সঙ্গে চিঠি দেওয়াটা হলো persistent connection-এর উপর real-time delivery, আর বাইরে থাকলে চিঠি জমিয়ে রাখাটা হলো offline store-and-forward queue। সমিতির নোটিশের অনেক কপি বিলি করাটাই group message-এর fan-out, আর দুই দফা সই নেওয়াটা হলো delivery receipt আর read receipt। WhatsApp বা Messenger-এ ঠিক এভাবেই কাজ হয় — আপনি online থাকলে message সঙ্গে সঙ্গে পান, offline থাকলে server-এ জমা থেকে reconnect করার মুহূর্তে আসে, আর single/double/blue tick দিয়ে sender বুঝতে পারে message পৌঁছেছে কি পড়া হয়েছে।
স্কেলে একটি Chat System দেখতে কেমন?
একটি production chat system দুজন user-এর মধ্যে message পাঠানোর চেয়ে অনেক বেশি কিছু। এতে জড়িত distributed server-জুড়ে message ordering, সব participant-এর কাছে message পৌঁছাতে fan-out, কে অনলাইন তা জানতে presence tracking, sender যেন জানে তার message দেখা হয়েছে তার জন্য read receipt, এবং যেসব user এখন connected নয় তাদের জন্য message রাখতে offline queue। এগুলোই ঠিক সেই সমস্যা যা WhatsApp, Telegram আর Slack দিনে কোটি কোটি message-এর স্কেলে সমাধান করে।
এটাকে একটা বড় অফিস ভবনের জন্য postal service-এর মতো ভাবুন। mailroom (message router) প্রতিটি চিঠি নেয়, directory (presence service) দেখে recipient তার ডেস্কে আছে কিনা যাচাই করে, আর হয় সরাসরি delivery করে অথবা তার mailbox-এ (offline queue) রেখে দেয়। চিঠি পৌঁছালে sender একটা delivery receipt পায়, আর recipient খুললে একটা read receipt পায়। প্রতিটি চিঠির একটা timestamp আর sequence number থাকে যাতে ভুল ক্রমে এলেও সেগুলো সঠিকভাবে sort করা যায়।
বাস্তব জীবনের উদাহরণ
বাস্তব জীবনের উদাহরণ
একটি messaging app-এর delivery সিস্টেমের মতো — single tick মানে server-এ পৌঁছেছে, double tick মানে recipient-এর কাছে delivered, blue tick মানে read। Group message সব member-এর কাছে fan out হয়।
WhatsApp দিনে 100 বিলিয়নেরও বেশি message সামলায়। আপনি একটা message পাঠালে, সেটা একটা message router-এ যায় যা recipient-এর connection server খুঁজে বের করে, message fan out করে, store করে, আর একটা single checkmark ফেরত দেয় (server-এ delivered)। Recipient-এর device সেটা পেলে, আপনি একটা double checkmark পান। তারা chat খুললে, আপনি blue checkmark পান (read receipt)। Recipient offline থাকলে, message একটা offline queue-তে বসে থাকে আর তার ফোন reconnect করার মুহূর্তেই delivered হয়। Telegram Lamport timestamp ব্যবহার করে নিশ্চিত করে যে একাধিক device থেকে একসাথে পাঠানো message-ও সঠিক ক্রমে দেখা যায়।
একটি Chat System বানানো
এখানে একটি সম্পূর্ণ chat system আছে – message storage, write-এর সময় fan-out, read receipt, typing indicator, ordering-এর জন্য Lamport timestamp, এবং offline message queue সহ। এটা production chat system-এর ব্যবহৃত মূল architecture implement করে।
import crypto from 'node:crypto';
// --- Lamport Clock for message ordering ---
class LamportClock {
private counter: number = 0;
tick(): number {
return ++this.counter;
}
update(received: number): number {
this.counter = Math.max(this.counter, received) + 1;
return this.counter;
}
current(): number {
return this.counter;
}
}
// --- Types ---
interface Message {
id: string;
conversationId: string;
senderId: string;
content: string;
timestamp: number;
lamportTs: number;
status: 'sent' | 'delivered' | 'read';
}
interface Conversation {
id: string;
participants: string[];
lastMessageAt: number;
createdAt: number;
}
interface ReadReceipt {
userId: string;
conversationId: string;
lastReadMessageId: string;
lastReadTimestamp: number;
}
interface TypingEvent {
userId: string;
conversationId: string;
isTyping: boolean;
timestamp: number;
}
interface UserConnection {
userId: string;
online: boolean;
lastSeen: number;
send: (event: ChatEvent) => void;
}
type ChatEvent =
| { type: 'message'; data: Message }
| { type: 'delivered'; data: { messageId: string; conversationId: string } }
| { type: 'read_receipt'; data: ReadReceipt }
| { type: 'typing'; data: TypingEvent }
| { type: 'presence'; data: { userId: string; online: boolean } }
| { type: 'offline_messages'; data: Message[] };
// --- Message Store ---
class MessageStore {
private messages = new Map<string, Message[]>(); // conversationId -> messages
private conversations = new Map<string, Conversation>();
private readReceipts = new Map<string, ReadReceipt>(); // `userId:convId` -> receipt
createConversation(participants: string[]): Conversation {
const id = `conv-${crypto.randomUUID().slice(0, 8)}`;
const conv: Conversation = {
id,
participants: [...participants],
lastMessageAt: 0,
createdAt: Date.now()
};
this.conversations.set(id, conv);
this.messages.set(id, []);
console.log(`[STORE] Created conversation ${id} with participants: ${participants.join(', ')}`);
return conv;
}
storeMessage(msg: Message): void {
const msgs = this.messages.get(msg.conversationId);
if (!msgs) throw new Error(`Conversation ${msg.conversationId} not found`);
msgs.push(msg);
const conv = this.conversations.get(msg.conversationId);
if (conv) conv.lastMessageAt = msg.timestamp;
console.log(
`[STORE] Stored message ${msg.id} in ${msg.conversationId} (lamport: ${msg.lamportTs})`
);
}
getMessages(conversationId: string, limit: number = 50): Message[] {
const msgs = this.messages.get(conversationId) || [];
return msgs.slice(-limit).sort((a, b) => a.lamportTs - b.lamportTs);
}
getConversation(id: string): Conversation | undefined {
return this.conversations.get(id);
}
getConversationsForUser(userId: string): Conversation[] {
const result: Conversation[] = [];
for (const conv of this.conversations.values()) {
if (conv.participants.includes(userId)) {
result.push(conv);
}
}
return result.sort((a, b) => b.lastMessageAt - a.lastMessageAt);
}
setReadReceipt(userId: string, conversationId: string, messageId: string): ReadReceipt {
const key = `${userId}:${conversationId}`;
const receipt: ReadReceipt = {
userId,
conversationId,
lastReadMessageId: messageId,
lastReadTimestamp: Date.now()
};
this.readReceipts.set(key, receipt);
// Mark messages as read
const msgs = this.messages.get(conversationId) || [];
let found = false;
for (const msg of msgs) {
if (msg.id === messageId) found = true;
if (!found && msg.senderId !== userId && msg.status !== 'read') {
msg.status = 'read';
}
if (msg.id === messageId) {
msg.status = 'read';
break;
}
}
return receipt;
}
getReadReceipt(userId: string, conversationId: string): ReadReceipt | undefined {
return this.readReceipts.get(`${userId}:${conversationId}`);
}
markDelivered(conversationId: string, messageId: string): void {
const msgs = this.messages.get(conversationId) || [];
const msg = msgs.find((m) => m.id === messageId);
if (msg && msg.status === 'sent') {
msg.status = 'delivered';
}
}
}
// --- Offline Queue ---
class OfflineQueue {
private queues = new Map<string, Message[]>(); // userId -> pending messages
enqueue(userId: string, message: Message): void {
if (!this.queues.has(userId)) {
this.queues.set(userId, []);
}
this.queues.get(userId)!.push(message);
console.log(`[OFFLINE] Queued message ${message.id} for offline user ${userId}`);
}
drain(userId: string): Message[] {
const messages = this.queues.get(userId) || [];
this.queues.delete(userId);
if (messages.length > 0) {
console.log(`[OFFLINE] Draining ${messages.length} messages for user ${userId}`);
}
return messages;
}
getQueueSize(userId: string): number {
return (this.queues.get(userId) || []).length;
}
}
// --- Connection Manager (Presence) ---
class ConnectionManager {
private connections = new Map<string, UserConnection>();
connect(userId: string, sendFn: (event: ChatEvent) => void): UserConnection {
const conn: UserConnection = {
userId,
online: true,
lastSeen: Date.now(),
send: sendFn
};
this.connections.set(userId, conn);
console.log(`[PRESENCE] User ${userId} is now ONLINE`);
return conn;
}
disconnect(userId: string): void {
const conn = this.connections.get(userId);
if (conn) {
conn.online = false;
conn.lastSeen = Date.now();
console.log(
`[PRESENCE] User ${userId} is now OFFLINE (last seen: ${new Date(conn.lastSeen).toISOString()})`
);
}
}
isOnline(userId: string): boolean {
const conn = this.connections.get(userId);
return conn?.online ?? false;
}
getConnection(userId: string): UserConnection | undefined {
const conn = this.connections.get(userId);
return conn?.online ? conn : undefined;
}
getOnlineUsers(): string[] {
const online: string[] = [];
for (const [userId, conn] of this.connections) {
if (conn.online) online.push(userId);
}
return online;
}
getLastSeen(userId: string): number | undefined {
return this.connections.get(userId)?.lastSeen;
}
}
// --- Fan-out Service (Message Router) ---
class FanOutService {
constructor(
private store: MessageStore,
private connections: ConnectionManager,
private offlineQueue: OfflineQueue,
private clock: LamportClock
) {}
sendMessage(senderId: string, conversationId: string, content: string): Message {
const conv = this.store.getConversation(conversationId);
if (!conv) throw new Error(`Conversation ${conversationId} not found`);
if (!conv.participants.includes(senderId)) {
throw new Error(`User ${senderId} is not in conversation ${conversationId}`);
}
const message: Message = {
id: `msg-${crypto.randomUUID().slice(0, 8)}`,
conversationId,
senderId,
content: content.slice(0, 4096),
timestamp: Date.now(),
lamportTs: this.clock.tick(),
status: 'sent'
};
// Store the message
this.store.storeMessage(message);
// Fan-out to all participants
console.log(
`[FANOUT] Distributing message ${message.id} to ${conv.participants.length} participants`
);
for (const participantId of conv.participants) {
if (participantId === senderId) continue; // Don't send to self
const conn = this.connections.getConnection(participantId);
if (conn) {
// User is online -- deliver immediately
conn.send({ type: 'message', data: message });
this.store.markDelivered(conversationId, message.id);
console.log(`[FANOUT] Delivered ${message.id} to ${participantId} (online)`);
// Send delivery receipt to sender
const senderConn = this.connections.getConnection(senderId);
if (senderConn) {
senderConn.send({
type: 'delivered',
data: { messageId: message.id, conversationId }
});
}
} else {
// User is offline -- queue for later delivery
this.offlineQueue.enqueue(participantId, message);
}
}
return message;
}
sendReadReceipt(userId: string, conversationId: string, messageId: string): void {
const receipt = this.store.setReadReceipt(userId, conversationId, messageId);
const conv = this.store.getConversation(conversationId);
if (!conv) return;
// Notify other participants about the read receipt
for (const participantId of conv.participants) {
if (participantId === userId) continue;
const conn = this.connections.getConnection(participantId);
if (conn) {
conn.send({ type: 'read_receipt', data: receipt });
console.log(`[RECEIPT] Sent read receipt to ${participantId} (read by ${userId})`);
}
}
}
sendTypingIndicator(userId: string, conversationId: string, isTyping: boolean): void {
const conv = this.store.getConversation(conversationId);
if (!conv) return;
const event: TypingEvent = {
userId,
conversationId,
isTyping,
timestamp: Date.now()
};
for (const participantId of conv.participants) {
if (participantId === userId) continue;
const conn = this.connections.getConnection(participantId);
if (conn) {
conn.send({ type: 'typing', data: event });
}
}
}
handleUserReconnect(userId: string): void {
const pending = this.offlineQueue.drain(userId);
if (pending.length === 0) return;
const conn = this.connections.getConnection(userId);
if (!conn) return;
// Deliver all pending messages
conn.send({ type: 'offline_messages', data: pending });
// Send delivery receipts to senders
for (const msg of pending) {
this.store.markDelivered(msg.conversationId, msg.id);
const senderConn = this.connections.getConnection(msg.senderId);
if (senderConn) {
senderConn.send({
type: 'delivered',
data: { messageId: msg.id, conversationId: msg.conversationId }
});
}
}
console.log(`[RECONNECT] Delivered ${pending.length} offline messages to ${userId}`);
}
broadcastPresence(userId: string, online: boolean): void {
// Notify users in shared conversations
const conversations = this.store.getConversationsForUser(userId);
const notified = new Set<string>();
for (const conv of conversations) {
for (const participantId of conv.participants) {
if (participantId === userId || notified.has(participantId)) continue;
notified.add(participantId);
const conn = this.connections.getConnection(participantId);
if (conn) {
conn.send({ type: 'presence', data: { userId, online } });
}
}
}
}
}
// --- Demo simulation ---
function main(): void {
const store = new MessageStore();
const connMgr = new ConnectionManager();
const offlineQueue = new OfflineQueue();
const clock = new LamportClock();
const fanout = new FanOutService(store, connMgr, offlineQueue, clock);
// Simulate event handlers for users
const eventLog: { user: string; event: ChatEvent }[] = [];
function createSendFn(userId: string) {
return (event: ChatEvent) => {
eventLog.push({ user: userId, event });
console.log(` >> [${userId}] received ${event.type}`);
};
}
// Create users
console.log('=== Setup ===');
connMgr.connect('fatima', createSendFn('fatima'));
connMgr.connect('omar', createSendFn('omar'));
// yusuf is offline
// Create conversations
const conv1 = store.createConversation(['fatima', 'omar', 'yusuf']);
// Fatima sends a message
console.log('\n=== Fatima sends a message ===');
const msg1 = fanout.sendMessage('fatima', conv1.id, 'Hey team, how is everyone?');
// Omar reads the message
console.log('\n=== Omar reads the message ===');
fanout.sendReadReceipt('omar', conv1.id, msg1.id);
// Omar starts typing
console.log('\n=== Omar types a reply ===');
fanout.sendTypingIndicator('omar', conv1.id, true);
// Omar sends a reply
const msg2 = fanout.sendMessage('omar', conv1.id, 'Doing great! Working on the new feature.');
fanout.sendTypingIndicator('omar', conv1.id, false);
// Yusuf comes online and gets pending messages
console.log('\n=== Yusuf comes online ===');
connMgr.connect('yusuf', createSendFn('yusuf'));
fanout.handleUserReconnect('yusuf');
fanout.broadcastPresence('yusuf', true);
// Yusuf reads all messages
console.log('\n=== Yusuf reads messages ===');
fanout.sendReadReceipt('yusuf', conv1.id, msg2.id);
// Print final state
console.log('\n=== Final message history ===');
const messages = store.getMessages(conv1.id);
for (const msg of messages) {
console.log(` [${msg.lamportTs}] ${msg.senderId}: ${msg.content} (${msg.status})`);
}
console.log('\n=== Online users ===');
console.log(` ${connMgr.getOnlineUsers().join(', ')}`);
console.log(`\n=== Event log (${eventLog.length} events) ===`);
for (const entry of eventLog) {
console.log(` ${entry.user}: ${entry.event.type}`);
}
}
main();package main
import (
"fmt"
"math"
"strings"
"sync"
"time"
)
// --- Lamport Clock ---
type LamportClock struct {
mu sync.Mutex
counter int64
}
func (lc *LamportClock) Tick() int64 {
lc.mu.Lock()
defer lc.mu.Unlock()
lc.counter++
return lc.counter
}
func (lc *LamportClock) Update(received int64) int64 {
lc.mu.Lock()
defer lc.mu.Unlock()
if received > lc.counter {
lc.counter = received
}
lc.counter++
return lc.counter
}
// --- Types ---
type MessageStatus string
const (
StatusSent MessageStatus = "sent"
StatusDelivered MessageStatus = "delivered"
StatusRead MessageStatus = "read"
)
type Message struct {
ID string `json:"id"`
ConversationID string `json:"conversationId"`
SenderID string `json:"senderId"`
Content string `json:"content"`
Timestamp int64 `json:"timestamp"`
LamportTs int64 `json:"lamportTs"`
Status MessageStatus `json:"status"`
}
type Conversation struct {
ID string `json:"id"`
Participants []string `json:"participants"`
LastMessageAt int64 `json:"lastMessageAt"`
CreatedAt int64 `json:"createdAt"`
}
type ReadReceipt struct {
UserID string `json:"userId"`
ConversationID string `json:"conversationId"`
LastReadMessageID string `json:"lastReadMessageId"`
LastReadTimestamp int64 `json:"lastReadTimestamp"`
}
type TypingEvent struct {
UserID string `json:"userId"`
ConversationID string `json:"conversationId"`
IsTyping bool `json:"isTyping"`
Timestamp int64 `json:"timestamp"`
}
type ChatEvent struct {
Type string
Data interface{}
}
// --- User Connection ---
type UserConnection struct {
UserID string
Online bool
LastSeen int64
SendFn func(ChatEvent)
}
// --- Message Store ---
type MessageStore struct {
mu sync.RWMutex
messages map[string][]Message // conversationId -> messages
conversations map[string]*Conversation
readReceipts map[string]*ReadReceipt // "userId:convId" -> receipt
nextConvID int
}
func NewMessageStore() *MessageStore {
return &MessageStore{
messages: make(map[string][]Message),
conversations: make(map[string]*Conversation),
readReceipts: make(map[string]*ReadReceipt),
}
}
func (ms *MessageStore) CreateConversation(participants []string) *Conversation {
ms.mu.Lock()
defer ms.mu.Unlock()
ms.nextConvID++
id := fmt.Sprintf("conv-%d", ms.nextConvID)
conv := &Conversation{
ID: id,
Participants: append([]string{}, participants...),
LastMessageAt: 0,
CreatedAt: time.Now().UnixMilli(),
}
ms.conversations[id] = conv
ms.messages[id] = []Message{}
fmt.Printf("[STORE] Created conversation %s with participants: %s\n", id, strings.Join(participants, ", "))
return conv
}
func (ms *MessageStore) StoreMessage(msg Message) {
ms.mu.Lock()
defer ms.mu.Unlock()
ms.messages[msg.ConversationID] = append(ms.messages[msg.ConversationID], msg)
if conv, ok := ms.conversations[msg.ConversationID]; ok {
conv.LastMessageAt = msg.Timestamp
}
fmt.Printf("[STORE] Stored message %s in %s (lamport: %d)\n", msg.ID, msg.ConversationID, msg.LamportTs)
}
func (ms *MessageStore) GetMessages(conversationID string, limit int) []Message {
ms.mu.RLock()
defer ms.mu.RUnlock()
msgs := ms.messages[conversationID]
if len(msgs) == 0 {
return nil
}
start := 0
if len(msgs) > limit {
start = len(msgs) - limit
}
result := make([]Message, len(msgs[start:]))
copy(result, msgs[start:])
// Sort by Lamport timestamp
for i := 0; i < len(result)-1; i++ {
for j := i + 1; j < len(result); j++ {
if result[i].LamportTs > result[j].LamportTs {
result[i], result[j] = result[j], result[i]
}
}
}
return result
}
func (ms *MessageStore) GetConversation(id string) *Conversation {
ms.mu.RLock()
defer ms.mu.RUnlock()
return ms.conversations[id]
}
func (ms *MessageStore) GetConversationsForUser(userID string) []*Conversation {
ms.mu.RLock()
defer ms.mu.RUnlock()
var result []*Conversation
for _, conv := range ms.conversations {
for _, p := range conv.Participants {
if p == userID {
result = append(result, conv)
break
}
}
}
return result
}
func (ms *MessageStore) SetReadReceipt(userID, conversationID, messageID string) *ReadReceipt {
ms.mu.Lock()
defer ms.mu.Unlock()
key := fmt.Sprintf("%s:%s", userID, conversationID)
receipt := &ReadReceipt{
UserID: userID,
ConversationID: conversationID,
LastReadMessageID: messageID,
LastReadTimestamp: time.Now().UnixMilli(),
}
ms.readReceipts[key] = receipt
// Mark messages as read
msgs := ms.messages[conversationID]
for i := range msgs {
if msgs[i].SenderID != userID && msgs[i].Status != StatusRead {
msgs[i].Status = StatusRead
}
if msgs[i].ID == messageID {
msgs[i].Status = StatusRead
break
}
}
return receipt
}
func (ms *MessageStore) MarkDelivered(conversationID, messageID string) {
ms.mu.Lock()
defer ms.mu.Unlock()
msgs := ms.messages[conversationID]
for i := range msgs {
if msgs[i].ID == messageID && msgs[i].Status == StatusSent {
msgs[i].Status = StatusDelivered
return
}
}
}
// --- Offline Queue ---
type OfflineQueue struct {
mu sync.Mutex
queues map[string][]Message // userId -> pending messages
}
func NewOfflineQueue() *OfflineQueue {
return &OfflineQueue{queues: make(map[string][]Message)}
}
func (oq *OfflineQueue) Enqueue(userID string, msg Message) {
oq.mu.Lock()
defer oq.mu.Unlock()
oq.queues[userID] = append(oq.queues[userID], msg)
fmt.Printf("[OFFLINE] Queued message %s for offline user %s\n", msg.ID, userID)
}
func (oq *OfflineQueue) Drain(userID string) []Message {
oq.mu.Lock()
defer oq.mu.Unlock()
msgs := oq.queues[userID]
delete(oq.queues, userID)
if len(msgs) > 0 {
fmt.Printf("[OFFLINE] Draining %d messages for user %s\n", len(msgs), userID)
}
return msgs
}
// --- Connection Manager ---
type ConnectionMgr struct {
mu sync.RWMutex
conns map[string]*UserConnection
}
func NewConnectionMgr() *ConnectionMgr {
return &ConnectionMgr{conns: make(map[string]*UserConnection)}
}
func (cm *ConnectionMgr) Connect(userID string, sendFn func(ChatEvent)) *UserConnection {
cm.mu.Lock()
defer cm.mu.Unlock()
conn := &UserConnection{
UserID: userID,
Online: true,
LastSeen: time.Now().UnixMilli(),
SendFn: sendFn,
}
cm.conns[userID] = conn
fmt.Printf("[PRESENCE] User %s is now ONLINE\n", userID)
return conn
}
func (cm *ConnectionMgr) Disconnect(userID string) {
cm.mu.Lock()
defer cm.mu.Unlock()
if conn, ok := cm.conns[userID]; ok {
conn.Online = false
conn.LastSeen = time.Now().UnixMilli()
fmt.Printf("[PRESENCE] User %s is now OFFLINE\n", userID)
}
}
func (cm *ConnectionMgr) GetConnection(userID string) *UserConnection {
cm.mu.RLock()
defer cm.mu.RUnlock()
conn := cm.conns[userID]
if conn != nil && conn.Online {
return conn
}
return nil
}
func (cm *ConnectionMgr) IsOnline(userID string) bool {
cm.mu.RLock()
defer cm.mu.RUnlock()
conn := cm.conns[userID]
return conn != nil && conn.Online
}
func (cm *ConnectionMgr) GetOnlineUsers() []string {
cm.mu.RLock()
defer cm.mu.RUnlock()
var online []string
for uid, conn := range cm.conns {
if conn.Online {
online = append(online, uid)
}
}
return online
}
// --- Fan-out Service ---
type FanOutService struct {
store *MessageStore
connMgr *ConnectionMgr
offlineQueue *OfflineQueue
clock *LamportClock
nextMsgID int
mu sync.Mutex
}
func NewFanOutService(store *MessageStore, connMgr *ConnectionMgr, oq *OfflineQueue, clock *LamportClock) *FanOutService {
return &FanOutService{
store: store,
connMgr: connMgr,
offlineQueue: oq,
clock: clock,
}
}
func (fs *FanOutService) genMsgID() string {
fs.mu.Lock()
defer fs.mu.Unlock()
fs.nextMsgID++
return fmt.Sprintf("msg-%d", fs.nextMsgID)
}
func (fs *FanOutService) SendMessage(senderID, conversationID, content string) *Message {
conv := fs.store.GetConversation(conversationID)
if conv == nil {
fmt.Printf("[ERROR] Conversation %s not found\n", conversationID)
return nil
}
// Truncate content
if len(content) > 4096 {
content = content[:4096]
}
msg := Message{
ID: fs.genMsgID(),
ConversationID: conversationID,
SenderID: senderID,
Content: content,
Timestamp: time.Now().UnixMilli(),
LamportTs: fs.clock.Tick(),
Status: StatusSent,
}
fs.store.StoreMessage(msg)
fmt.Printf("[FANOUT] Distributing message %s to %d participants\n", msg.ID, len(conv.Participants))
for _, participantID := range conv.Participants {
if participantID == senderID {
continue
}
conn := fs.connMgr.GetConnection(participantID)
if conn != nil {
conn.SendFn(ChatEvent{Type: "message", Data: msg})
fs.store.MarkDelivered(conversationID, msg.ID)
fmt.Printf("[FANOUT] Delivered %s to %s (online)\n", msg.ID, participantID)
if senderConn := fs.connMgr.GetConnection(senderID); senderConn != nil {
senderConn.SendFn(ChatEvent{
Type: "delivered",
Data: map[string]string{"messageId": msg.ID, "conversationId": conversationID},
})
}
} else {
fs.offlineQueue.Enqueue(participantID, msg)
}
}
return &msg
}
func (fs *FanOutService) SendReadReceipt(userID, conversationID, messageID string) {
receipt := fs.store.SetReadReceipt(userID, conversationID, messageID)
conv := fs.store.GetConversation(conversationID)
if conv == nil {
return
}
for _, participantID := range conv.Participants {
if participantID == userID {
continue
}
if conn := fs.connMgr.GetConnection(participantID); conn != nil {
conn.SendFn(ChatEvent{Type: "read_receipt", Data: receipt})
fmt.Printf("[RECEIPT] Sent read receipt to %s (read by %s)\n", participantID, userID)
}
}
}
func (fs *FanOutService) SendTypingIndicator(userID, conversationID string, isTyping bool) {
conv := fs.store.GetConversation(conversationID)
if conv == nil {
return
}
event := TypingEvent{
UserID: userID,
ConversationID: conversationID,
IsTyping: isTyping,
Timestamp: time.Now().UnixMilli(),
}
for _, participantID := range conv.Participants {
if participantID == userID {
continue
}
if conn := fs.connMgr.GetConnection(participantID); conn != nil {
conn.SendFn(ChatEvent{Type: "typing", Data: event})
}
}
}
func (fs *FanOutService) HandleReconnect(userID string) {
pending := fs.offlineQueue.Drain(userID)
if len(pending) == 0 {
return
}
conn := fs.connMgr.GetConnection(userID)
if conn == nil {
return
}
conn.SendFn(ChatEvent{Type: "offline_messages", Data: pending})
for _, msg := range pending {
fs.store.MarkDelivered(msg.ConversationID, msg.ID)
if senderConn := fs.connMgr.GetConnection(msg.SenderID); senderConn != nil {
senderConn.SendFn(ChatEvent{
Type: "delivered",
Data: map[string]string{"messageId": msg.ID, "conversationId": msg.ConversationID},
})
}
}
fmt.Printf("[RECONNECT] Delivered %d offline messages to %s\n", len(pending), userID)
}
func (fs *FanOutService) BroadcastPresence(userID string, online bool) {
conversations := fs.store.GetConversationsForUser(userID)
notified := make(map[string]bool)
for _, conv := range conversations {
for _, participantID := range conv.Participants {
if participantID == userID || notified[participantID] {
continue
}
notified[participantID] = true
if conn := fs.connMgr.GetConnection(participantID); conn != nil {
conn.SendFn(ChatEvent{
Type: "presence",
Data: map[string]interface{}{"userId": userID, "online": online},
})
}
}
}
}
// Suppress unused import warning
var _ = math.MaxFloat64
// --- Main ---
func main() {
store := NewMessageStore()
connMgr := NewConnectionMgr()
offlineQueue := NewOfflineQueue()
clock := &LamportClock{}
fanout := NewFanOutService(store, connMgr, offlineQueue, clock)
type eventEntry struct {
User string
Event ChatEvent
}
var eventLog []eventEntry
var logMu sync.Mutex
makeSendFn := func(userID string) func(ChatEvent) {
return func(e ChatEvent) {
logMu.Lock()
eventLog = append(eventLog, eventEntry{User: userID, Event: e})
logMu.Unlock()
fmt.Printf(" >> [%s] received %s\n", userID, e.Type)
}
}
fmt.Println("=== Setup ===")
connMgr.Connect("fatima", makeSendFn("fatima"))
connMgr.Connect("omar", makeSendFn("omar"))
// yusuf starts offline
conv1 := store.CreateConversation([]string{"fatima", "omar", "yusuf"})
fmt.Println("\n=== Fatima sends a message ===")
msg1 := fanout.SendMessage("fatima", conv1.ID, "Hey team, how is everyone?")
fmt.Println("\n=== Omar reads the message ===")
fanout.SendReadReceipt("omar", conv1.ID, msg1.ID)
fmt.Println("\n=== Omar types a reply ===")
fanout.SendTypingIndicator("omar", conv1.ID, true)
msg2 := fanout.SendMessage("omar", conv1.ID, "Doing great! Working on the new feature.")
fanout.SendTypingIndicator("omar", conv1.ID, false)
fmt.Println("\n=== Yusuf comes online ===")
connMgr.Connect("yusuf", makeSendFn("yusuf"))
fanout.HandleReconnect("yusuf")
fanout.BroadcastPresence("yusuf", true)
fmt.Println("\n=== Yusuf reads messages ===")
fanout.SendReadReceipt("yusuf", conv1.ID, msg2.ID)
fmt.Println("\n=== Final message history ===")
messages := store.GetMessages(conv1.ID, 50)
for _, msg := range messages {
fmt.Printf(" [%d] %s: %s (%s)\n", msg.LamportTs, msg.SenderID, msg.Content, msg.Status)
}
fmt.Println("\n=== Online users ===")
fmt.Printf(" %s\n", strings.Join(connMgr.GetOnlineUsers(), ", "))
fmt.Printf("\n=== Event log (%d events) ===\n", len(eventLog))
for _, entry := range eventLog {
fmt.Printf(" %s: %s\n", entry.User, entry.Event.Type)
}
}এটাকে যা Production-Ready করে
- Lamport timestamp – distributed server-জুড়ে message-এর causal ordering দেয়
- Write-এর সময় fan-out – recipient-দের poll করতে বাধ্য না করে সঙ্গে সঙ্গে তাদের কাছে message push করে
- Offline message queue – disconnected user-দের জন্য message store করে, reconnect-এ নিশ্চিত delivery সহ
- Read receipt – প্রতি user-এর read state track করে, receipt message sender-দের কাছে propagate করে
- Typing indicator – ক্ষণস্থায়ী event যা conversation participant-দের কাছে broadcast হয়, কোনো persistence ছাড়াই
- Connection manager – presence-এর জন্য last-seen timestamp সহ online/offline status track করে
মূল কথা
- Write-এর সময় fan-out (push model) সঙ্গে সঙ্গে message delivery করে, তবে সব recipient connection track করা লাগে
- Lamport timestamp server-জুড়ে synchronized clock ছাড়াই causal ordering দেয়
- Offline queue অপরিহার্য – mobile user সারাদিন ঘন ঘন disconnect আর reconnect করে
- Read receipt-এর জন্য message delivery status থেকে আলাদা একটি tracking layer লাগে
- Typing indicator হলো fire-and-forget event যা কখনো persist বা queue করা উচিত নয়
- Connection-level presence application-level presence থেকে আলাদা – একজন user-এর একাধিক device থাকতে পারে
বাস্তব ব্যবহার
- WhatsApp mobile user-দের জন্য offline queuing সহ write-এর সময় fan-out ব্যবহার করে দিনে 100+ বিলিয়ন message প্রসেস করে
- Telegram multi-device message sync-এর জন্য Lamport-ধাঁচের ordering সহ একটি custom protocol (MTProto) ব্যবহার করে
- Discord ভিন্ন scaling strategy-র জন্য ক্ষণস্থায়ী event (typing, presence) থেকে persistent data (message) আলাদা করে
- Slack real-time delivery-র জন্য WebSocket আর message history retrieval-এর জন্য REST API-র সমন্বয় ব্যবহার করে