ব্যবসার জন্য Event-Driven আর্কিটেকচার: কেন আপনার অর্ডারই স্টক আর হিসাবের খাতাকে নিজে জানিয়ে দেবে
নাইটলি sync আর জট পাকানো কলে স্টক ও হিসাব আলাদা হয়ে যায়। event, transactional outbox আর idempotent listener কীভাবে একটি অর্ডার থেকে স্টক, লেজার ও লয়্যালটি নির্ভরযোগ্যভাবে হালনাগাদ করে, উদাহরণসহ দেখুন।
Author
Anichur Rahaman
2 মাস আগে11 min read4 views
সেলের সপ্তাহান্তে ছয় outlet-এর একটি রিটেইল ব্যবসা ১,৯০০টি order পেয়েছে। সোমবার সকালে ফাইন্যান্স প্রধান report খুলতেই দেখেন, stock শিট বলছে জ্যাকেট আছে ১৪টি, অথচ পাশের শহরের outlet শনিবারেই শেষ জ্যাকেটটি বিক্রি করে ফেলেছে। হিসাবের খাতার অবস্থা আরও খারাপ: রাজস্ব পোস্ট হয় রাতের একটা batch-এ, সেই batch ১,৪১২ নম্বর order-এ এসে টাইমআউট হয়েছে, আর ৪৮৮টি order খাতা থেকে বাদ। মঙ্গলবারের রিকনসিলিয়েশনের আগে কেউ টেরও পাবে না।
এই গল্পে কেউ ভুল করেনি। system-টাই প্রতিটি বিভাগকে বলছে order-এর খবর নিজে খুঁজে নিতে: পোলিং করে, এক্সপোর্ট করে, নয়তো ভুল সময়ে কল পেয়ে। এই লেখা বলছে উল্টোটা: কিছু ঘটলে সেটা একবার তথ্য হিসেবে লিখে রাখুন, আর ব্যবসার বাকি অংশ সেই তথ্য দেখে নিজেরাই সাড়া দিক। এটাই event-driven আর্কিটেকচার।
system ডিজাইনের এই অংশেই আমার কাজের বেশিরভাগ সময় গেছে, তাই মতামত একটু জোরালো হবে। শেষে আপনি যেন আসল event-driven system আর মার্কেটিংয়ের দাবির তফাত ধরতে পারেন, আর জানেন ঠিক কী চাইতে হবে, এটাই লক্ষ্য।
নাইটলি sync আর জট পাকানো কলের সমস্যা কোথায়
বেশিরভাগ ব্যবসায়িক software শুরু হয় সরাসরি কল দিয়ে। checkout শেষ হলে checkout-এর কোড stock-এর কোডকে ডাকে, তারপর অ্যাকাউন্টিংকে, তারপর email-কে। ডেমোতে চলে, কিন্তু তিনভাবে ভাঙে, আর ভাঙার ধরনও আগে থেকে বলে দেওয়া যায়।
সবচেয়ে ধীর ধাপটাই গ্রাহককে আটকে রাখে। email সার্ভিস আট সেকেন্ড নিলে ক্রেতাও আট সেকেন্ড অপেক্ষা করে, কিংবা email ডাউন থাকায় order-টাই ব্যর্থ হয়।
প্রতিটি নতুন চাহিদা পুরোনো কোড বদলায়। লয়্যালটি পয়েন্ট যোগ করতে হলে কোম্পানির সবচেয়ে ঝুঁকিপূর্ণ ফাইল, মানে checkout, খুলে আরও একটা কল বসাতে হয়।
আধখেঁচড়া কাজের কোনো মালিক থাকে না। stock কমেছে, তারপর অ্যাকাউন্টিং ব্যর্থ হয়েছে। দ্বিতীয় ধাপটা যে এখনও বাকি, সে কথা কোথাও লেখা নেই।
চালু প্যাচ হলো নাইটলি sync: রাত ২টায় order এক্সপোর্ট করো, অন্য জায়গায় ইমপোর্ট করো। এতে ব্যর্থতা সরে যায় সেই ঘণ্টাগুলোতে যখন কেউ নজর রাখে না, আর প্রতিটি সংখ্যা ২৪ ঘণ্টা পর্যন্ত পুরোনো হয়ে যায়। তখন টিম ফাঁক খুঁজতে রিকনসিলিয়েশন স্প্রেডশিট বানায়, আর শেষে স্প্রেডশিটটাই হয়ে ওঠে আসল system।
Command আর event: যে শব্দদুটো জানা জরুরি
দুটো শব্দ বেশিরভাগ কাজ করে, আর এ দুটো গুলিয়ে ফেলাই ডিজাইনের সবচেয়ে সাধারণ প্রথম ভুল।
Command হলো অনুরোধ: "এই order-টা নাও", "এই payment ফেরত দাও"। এটি একজন নির্দিষ্ট মালিককে উদ্দেশ করে, বর্তমান কালে বলা, আর একে ফিরিয়ে দেওয়া যায়। Event হলো ঘটে-যাওয়া তথ্য: OrderPlaced, PaymentReceived, ParcelDelivered। নাম থাকে অতীত কালে, কারণ ঘটনাটা ঘটে গেছে, কেউ তা অস্বীকার করতে পারে না। যে অংশ তথ্যটার মালিক, সে producer। যে-ই এতে আগ্রহী, সে listener (consumer বা subscriber-ও বলা হয়)।
Producer জানে না কে শুনছে। পুরো সুবিধাটা এই একটা বৈশিষ্ট্যেই। লয়্যালটি পয়েন্ট যোগ করা মানে ParcelDelivered-এর জন্য একটা নতুন listener লেখা। checkout-এ হাত পড়ে না, তাই সেটা ভাঙতেও পারে না।
একটি order, তিনটি event, পাঁচটি listener
একটা উদাহরণ দিলে বিষয়টা স্পষ্ট হয়। ধরুন (কাল্পনিক উদাহরণ) একজন ক্রেতা SKU JKT-M-এর দুটো জ্যাকেট কিনলেন, প্রতিটি ৪০.০০ (ইউনিট কস্ট ২২.০০), সঙ্গে ৫.০০ shipping আর ৮.০০ ট্যাক্স। মোট ৯৩.০০, কার্ডে পরিশোধ। পরের তিন দিনে order-টি তিনটি event তৈরি করে।
একটি ঘটনা, পাঁচটি স্বাধীন প্রতিক্রিয়া। checkout এদের কাউকে ডাকে না।
প্রতিটি event-এর সঙ্গে থাকে ছোট একটা payload, এতটুকু যাতে listener-কে আর producer-এর কাছে ফিরে জিজ্ঞেস করতে না হয়।
stock_movements: type sale, qty -2, রিজার্ভেশন মুক্ত। অন-হ্যান্ড ৪৬
ParcelDelivered
ledger
ডেবিট Customer deposits 93.00; ক্রেডিট Sales 80.00, Shipping income 5.00, Tax payable 8.00। ডেবিট Cost of goods sold 44.00; ক্রেডিট Inventory 44.00
ParcelDelivered
লয়্যালটি
loyalty_entries: customer 77, +80 পয়েন্ট (পণ্যের প্রতি এক একক মুদ্রায় ১ পয়েন্ট), ref order 1042
ParcelDelivered
notification
notifications: ডেলিভারির বার্তা ও রিভিউ আমন্ত্রণ, status queued
অঙ্কটা মিলিয়ে দেখুন: ডেলিভারির journal-এ ডেবিট ৯৩.০০ + ৪৪.০০ = ১৩৭.০০, আর ক্রেডিট ৮০.০০ + ৫.০০ + ৮.০০ + ৪৪.০০ = ১৩৭.০০। মিলে যায় কারণ listenerটাই লেখা হয়েছে ভারসাম্যপূর্ণ এন্ট্রি পোস্ট করতে, মাস শেষে কেউ মিলিয়ে দেখেছে বলে নয়। এখানে ডেলিভারির সময় রাজস্ব ধরা হয়েছে; আপনার অ্যাকাউন্টিং নীতি আলাদা হতে পারে, আর সেটা শুধু ledger listener-এর সিদ্ধান্ত।
Outbox: event যেভাবে কখনও হারায় না
Producer-এর দিকে একটা ফাঁদ আছে। order নেওয়া মানে দুটো লেখা: database-এ order সেভ করা, আর queue-তে OrderPlaced পাঠানো। দুটো আলাদা system, তাই একটা ট্রানজ্যাকশন দিয়ে দুটোকে একসঙ্গে ঢাকা যায় না। database-এ কমিট হলো কিন্তু পাঠানোর আগেই প্রসেস মরে গেল, তাহলে order আছে অথচ event নেই। stock কিছুই জানল না। উল্টো, আগে পাঠালেন আর database রোলব্যাক করল, তাহলে বাকি সবাই এমন order-এ সাড়া দিল যা কখনও সেভই হয়নি। একে বলে dual-write problem।
সমাধান transactional outbox, যার বর্ণনা দিয়েছেন Chris Richardson তাঁর microservices pattern catalogue-এ। order আর তার event একই database-এ, একই ট্রানজ্যাকশনে লেখা হয়, event যায় outbox টেবিলে। হয় দুটো রো-ই থাকে, নয়তো একটাও না। আলাদা একটা relay প্রসেস না-পাঠানো outbox রো পড়ে, queue-তে পাঠায় আর রো-টাকে "sent" চিহ্নিত করে।
একটি ট্রানজ্যাকশন, দুটি রো। দ্বিতীয় রো-টিকে relay queue পর্যন্ত পৌঁছে দেয়, যতবার লাগে।
Relay-ও পাঠানো আর "sent" চিহ্নিত করার মাঝখানে ক্র্যাশ করতে পারে। রিস্টার্ট করে সে event-টা আবার পাঠায়। অর্থাৎ outbox একটা সুনির্দিষ্ট জিনিস দেয়: event কখনও হারায় না, তবে একাধিকবার পৌঁছাতে পারে। পরের পয়েন্ট এখান থেকেই আসে।
"Exactly once" আসলে at least once আর idempotent listener
ভেন্ডররা exactly-once ডেলিভারির প্রতিশ্রুতি দিতে ভালোবাসে। network-এ যুক্ত আলাদা মেশিনের মধ্যে সাধারণভাবে তা সম্ভব নয়: পাঠানোর পর সাড়া না এলে প্রেরক জানে না বার্তা পৌঁছেছে কি না, তাই তাকে বেছে নিতে হয় আবার পাঠাবে, নাকি হারানোর ঝুঁকি নেবে। নির্ভরযোগ্য system আবার পাঠানোটাই বেছে নেয়। payment প্রোভাইডাররা খোলাখুলিই বলে; যেমন Stripe-এর ডকুমেন্টেশন বলে একই webhook event একাধিকবার আসার জন্য তৈরি থাকতে, আর event ID দিয়ে ডুপ্লিকেট ছাঁটাই করতে।
তাই কাজের গ্যারান্টি হলো at least once ডেলিভারি আর idempotent প্রসেসিং। কোনো listener idempotent হয় তখন, যখন একই event দুবার প্রসেস করলে ফল একবার প্রসেস করার সমানই হয়। সাধারণ কৌশলটা ছোট: processed_events নামে একটা টেবিল, যার (listener, event_id)-তে unique key।
প্রতিটি event নিয়ে listener যা করে, আগে দেখা event-টিও বাদ যায় না।
দুটো খুঁটিনাটির ওপর নির্ভর করে কাজটা টিকবে কি না। প্রথমত, "পরিবর্তন প্রয়োগ" আর "event ID রেকর্ড" দুটোই কমিট হতে হবে একই database ট্রানজ্যাকশনে। আগে প্রয়োগ করে পরে রেকর্ড করলে, মাঝখানে ক্র্যাশ হওয়া মানে রিট্রাইয়ে ডাবল পোস্টিং। দ্বিতীয়ত, unique key যেন চেকটাকে atomic করে, যাতে একই event-এর দুটো কপি একসঙ্গে এলে দুটো একসঙ্গে পাস করতে না পারে।
উদাহরণে ফিরি: ParcelDelivered দুবার এলে দ্বিতীয়বার ledger চালাতে গিয়ে evt_5003 আগেই রেকর্ড হয়ে আছে দেখে স্কিপ করে। journal দুবার পোস্ট হয় না, ৮০ লয়্যালটি পয়েন্ট ১৬০ হয় না।
ক্রম, রিট্রাই আর বিনা খরচের ইতিহাস
ক্রম
আলাদা order-এর event যেকোনো ক্রমে প্রসেস হলে ক্ষতি নেই। কিন্তু একই order-এর event-এর ক্ষেত্রে ক্রম মানতেই হয়: PaymentReceived-এর আগে RefundIssued প্রসেস হওয়ার কোনো মানে নেই। দুটো প্রতিরক্ষা একসঙ্গে কাজ করে। order ID ধরে event রুট করুন, যাতে একটা order-এর সব event একই লেন দিয়ে পরপর যায়; আর প্রতিটি event-কে অর্ডারভিত্তিক একটা সিকোয়েন্স নম্বর দিন, যাতে listener বেশি পুরোনো event চিনে তাকে সরিয়ে রাখতে বা এড়িয়ে যেতে পারে।
রিট্রাই আর dead-letter queue
Listener ব্যর্থ হলে, ধরুন database ব্যস্ত ছিল বা courier-এর API ডাউন, event আবার চেষ্টার জন্য ফিরে যায় ক্রমবর্ধমান বিরতিতে, যেমন ১০ সেকেন্ড, ১ মিনিট, ৫ মিনিট, ৩০ মিনিট, ২ ঘণ্টা। ব্যাকঅফ জরুরি: সঙ্গে সঙ্গে রিট্রাই করলে ছোট একটা বিভ্রাট নিজের তৈরি ওভারলোডে পরিণত হয়। নির্দিষ্ট সংখ্যক চেষ্টার পর, ধরা যাক পাঁচবার, event চলে যায় dead-letter queue-এ আর কাউকে alert পাঠানো হয়। Dead letter দেখা যায়, ফিরিয়ে আনা যায়। তুলনা করুন ব্যর্থ নাইটলি এক্সপোর্টের সঙ্গে, যা শুধু অনুপস্থিত।
ইতিহাস
ব্যবসার প্রতিটি পরিবর্তন যেহেতু একটা event, যার সময়, ID আর payload আছে, তাই audit ট্রেইল আলাদা করে বানাতে হয় না। "এই গ্রাহকের ৮০ পয়েন্ট কেন?" প্রশ্নের উত্তর তৈরি: ParcelDelivered evt_5003, নির্দিষ্ট সময়ে Loyalty প্রসেস করেছে। event নির্দিষ্ট মেয়াদ পর্যন্ত রাখুন, আর listener-এ বাগ থাকলে সেটা ঠিক করে ক্ষতিগ্রস্ত event আবার তার ভেতর replay করুন। Idempotency থাকায় replay নিরাপদ।
কখন ব্যবহার করবেন না
Event-driven ডিজাইনে চলমান অংশ বাড়ে: queue, relay, worker, monitoring, আর eventual consistency নিয়ে ভাবার অভ্যাস। ব্রোশার সাইট, এক ব্যবহারকারীর টুল, কিংবা সাধারণ CRUD admin panel, যেখানে একটা টেবিল আপডেট হয় আর একটা screen সেটা পড়ে, সেখানে সরাসরি ফাংশন কলই সহজ আর ভালো। যে ধাপের উত্তর এখনই লাগে, যেমন "কার্ডটা কি বৈধ?", সেটাও তা-ই। ওটা উত্তরসহ command, event নয়।
আমি যে সীমারেখা ধরি: কোনো কাজের তিনটি বা তার বেশি স্বাধীন ফল যখন আলাদা টিম বা module-এর হাতে (stock, টাকা, বার্তা, ডেলিভারি), তখন event-এর খরচ উসুল হতে শুরু করে।
খরচের কথাও সৎভাবে বলা দরকার। Listener চলে order-এর একটু পরে, তাই checkout-এর ঠিক পরে stock পড়া screen কিছুক্ষণ পুরোনো সংখ্যা দেখাতে পারে। ভালো product এটা সামলায় checkout ট্রানজ্যাকশনের ভেতরেই রিজার্ভেশন করে, আর আন্দাজ না করে "প্রসেসিং" দেখিয়ে।
সেখানে পৌঁছানো, আর কাজ করছে কি না বোঝা
ধাপগুলো
আপনার ব্যবসা ইতিমধ্যে যেসব ঘটনা নিয়ে কথা বলে, তার তালিকা করুন: order placed, payment received, parcel delivered, return approved, stock counted। প্রতিটির নাম অতীত কালে দিন।
প্রতিটি payload-এ এত ফিল্ড রাখুন যাতে listener-কে কখনও ফিরে জিজ্ঞেস করতে না হয়, আর প্রতিটি event-কে দিন একটা অনন্য ID ও টাইমস্ট্যাম্প।
যে পরিবর্তনের কথা event বলছে, তার ট্রানজ্যাকশনের ভেতরেই event-কে outbox টেবিলে লিখুন।
একটা relay চালান, যা outbox রো queue-তে পাঠায় আর "sent" চিহ্নিত করে।
প্রতিটি listener idempotent করে বানান, processed-events রেকর্ড থাকবে তার নিজের লেখার ট্রানজ্যাকশনেই।
প্রথম আসল order-এর আগেই ব্যাকঅফসহ রিট্রাই, dead-letter queue আর তার ওপর alert বসান।
একে একে কনজিউমার সরান: আগে stock, তারপর ledger, তারপর বার্তা ও লয়্যালটি। নাইটলি জব সবার শেষে বন্ধ করুন, এক সপ্তাহ সংখ্যা মিলে যাওয়ার পর।
vendor-কে যা জিজ্ঞেস করবেন
Event কি ব্যবসায়িক পরিবর্তনের একই ট্রানজ্যাকশনে লেখা হয়, নাকি পরে পাঠানো হয়?
ব্যর্থ event কোথায় যায়, কাকে alert করা হয়, আর আমি কি সেগুলো replay করতে পারব?
একটা order-এর event-ইতিহাস কি সময় আর কোন হ্যান্ডলার প্রসেস করেছে সহ দেখা যায়?
এই দাবিগুলো আসল system-এ যাচাইয়ের একটা উপায়: StoreConsole-এর মতো self-hosted platform-এ inventory, অ্যাকাউন্টিং ও লয়্যালটি module একই order event-এর ওপর আলাদা listener হিসেবে কাজ করে।
কী মাপবেন
Outbox lag: সবচেয়ে পুরোনো না-পাঠানো রো-র বয়স। কয়েক সেকেন্ড হলে সুস্থ।
Queue depth আর হ্যান্ডলিং সময়, প্রতিটি listener-এর আলাদা।
Dead-letter সংখ্যা: শূন্যের কাছাকাছি থাকা উচিত, চুপচাপ বাড়তে থাকা চলবে না।
Duplicate-skip হার: শূন্য না হলেই বোঝা যায় ডিডুপ্লিকেশন কাজ করছে।
দৈনিক drift চেক: stock ledger-এর ইউনিট বনাম order থেকে প্রাপ্ত ইউনিট, আর ledger-এর রাজস্ব বনাম order-এর রাজস্ব। দুটো ফারাকই শূন্য হওয়া চাই।
সেই সোমবার, নতুন করে
১,৯০০ order-এর ফাইন্যান্স প্রধানের কাছে ফিরে যাই। প্রতিটি order বিক্রির একই ট্রানজ্যাকশনে নিজের event লিখেছে, তাই ১,৯০০টি outbox রো আছে, একটিও হারায়নি। ১,৪১২ নম্বরে ledger listener টাইমআউটে পড়ল। ১,৪১২ থেকে ১,৯০০ পর্যন্ত order queue-তে অপেক্ষা করল, ব্যাকঅফসহ রিট্রাই করল আর কয়েক মিনিটে পোস্ট হয়ে গেল। দুটো order ভুল ট্যাক্স কোডের কারণে পাঁচবার ব্যর্থ হয়ে dead-letter queue-তে গেল, আর শনিবার বিকেলেই alert উঠল, মঙ্গলবারে নয়।
পাশের outlet-এর শনিবারের বিক্রি ঘটার মুহূর্তেই stock রিজার্ভ করেছিল, তাই ১৪টি জ্যাকেট সত্যিই ১৪টিই। সোমবারের drift চেকে দুই কলামেই শূন্য। রিকনসিলিয়েশন স্প্রেডশিট খুলতে হলো না।
মূল কথা
Command অনুরোধ করে আর তা ফিরিয়ে দেওয়া যায়; event এমন তথ্য যা ঘটে গেছে। Producer event ঘোষণা করে, কে শুনছে জানে না।
ব্যবসায়িক পরিবর্তনের একই ট্রানজ্যাকশনে event লিখুন (outbox), তাহলে সেটা কখনও হারাবে না।
Exactly-once ডেলিভারি বাস্তবসম্মত প্রতিশ্রুতি নয়। at-least-once ডেলিভারির সঙ্গে idempotent listener বানান।
প্রসেস-হওয়া event ID রেকর্ড করুন listener-এর লেখার একই ট্রানজ্যাকশনে।
ব্যাকঅফসহ রিট্রাই করুন, তারপর dead-letter আর alert। চোখে-পড়া ব্যর্থতা নিঃশব্দ ফাঁকের চেয়ে ভালো।
যে কাজের তিনটি বা বেশি স্বাধীন ফল আছে, সেখানে ব্যবহার করুন। সাধারণ CRUD-এ এড়িয়ে যান।
আনিছুর রহমান একজন সফটওয়্যার আর্কিটেক্ট এবং StoreConsole-এর নির্মাতা। বাড়তে থাকা ব্যবসার জন্য তিনি কমার্স ও ERP সিস্টেম ডিজাইন করেন — বিশেষ মনোযোগ event-driven আর্কিটেকচার, ডেটার নির্ভুলতা আর নিজের সার্ভারে চালানো সিস্টেমের ওপর।