০০ যে প্রশ্নগুলোর উত্তর খুঁজবো
সেশন ০৮ এ আমরা queue এর worker কে idempotent বানিয়েছিলাম। একই job দুইবার এলেও যেন ক্ষতি না হয়, তার জন্য একটা unique ID আর একটা stored record ব্যবহার করেছিলাম। সেখানে duplicate আসতো queue এর ভেতর থেকে।
কিন্তু duplicate এর সবচেয়ে বড় উৎস আসলে মানুষের হাত আর দুর্বল network। User দুইবার button চাপে। Mobile app নিজে থেকে retry করে। মাঝের load balancer ও কখনো কখনো request আবার পাঠায়। এই সেশনে আমরা idempotency কে পুরো পথে বিছিয়ে দেবো: phone এর button থেকে শুরু করে API, database, Kafka হয়ে শেষ consumer পর্যন্ত। শেষে আপনি বুঝবেন, “exactly-once” কথাটার আসল মানে কী, আর কোথায় সেটা শুধু একটা ভুল প্রতিশ্রুতি।
- ১ প্রথম tap এ টাকা গেলো, কিন্তু উত্তর এলো না। App কীভাবে বুঝবে retry নিরাপদ কিনা?
- ২ HTTP API তে idempotency key কীভাবে কাজ করে? (client-generated key, stored response)
- ৩ একই key এর দুইটা request প্রায় একসাথে এলে কী হবে?
- ৪ Key কতদিন মনে রাখবো, আর dedup table কত বড় হবে? (idempotency window)
- ৫ কিছু কাজ নিজে থেকেই idempotent কেন? (natural idempotency, PUT, SET বনাম INCREMENT)
- ৬ Exactly-once delivery কেন অসম্ভব, কিন্তু effectively-once কীভাবে সম্ভব?
- ৭ Kafka transaction কী দেয়, আর কী দেয় না?
- ৮ Consumer এর দিকে duplicate কীভাবে ধরবো? (inbox pattern)
- ৯ Button থেকে database পর্যন্ত: end-to-end idempotency দেখতে কেমন?
০১ প্রথম tap এ টাকা গেলো, কিন্তু উত্তর এলো না। App কীভাবে বুঝবে retry নিরাপদ কিনা?
শুক্রবার সন্ধ্যা। সুবর্ণ এক্সপ্রেস ভৈরব পার হয়ে ছুটছে। জানালার পাশে বসে শিমুল ফোন খুললো। ছোট বোন তিথির coaching এর ফি আজ রাতের মধ্যেই দিতে হবে। শিমুল “পকেটপে” app এ ৳৫০০ লিখে “Send” চাপলো। Screen এ spinner ঘুরতে লাগলো। Network এর দাগ একটা থেকে শূন্যে নেমে গেলো। পনেরো সেকেন্ড পর লাল লেখা এলো: “কিছু একটা সমস্যা হয়েছে, আবার চেষ্টা করুন।” শিমুল আবার চাপলো। এবার সাথে সাথেই সবুজ টিক: “৳৫০০ পাঠানো হয়েছে।” স্বস্তিতে ফোন পকেটে রাখলো সে।
চট্টগ্রাম পৌঁছে শিমুল balance দেখে চমকে গেলো। ৳২,০০০ ছিলো, এখন ৳১,০০০। পরদিন সকালে পকেটপে এর support এ একটা ticket এলো: “আমার টাকা দুইবার কেটেছে।” ইমরান পকেটপে এর payment টিমের backend engineer। Ticket টা তার টেবিলে এলো। Log খুলে সে পুরো ঘটনাটা সাজালো:
প্রথম tap এর request ঠিকঠাক server এ পৌঁছেছিলো। টাকাও কেটেছিলো। শুধু উত্তরটা ফেরার পথে train এর দুর্বল network এ হারিয়ে গেলো। দ্বিতীয় tap এর request কে server একদম নতুন একটা transfer ভাবলো। তার কাছে “এটা আগের চেষ্টার retry” বোঝার কোনো উপায় ছিলো না।
App এর দিক থেকে দুইটা ঘটনা একই রকম
App timeout পেলে আসলে দুইটার যেকোনো একটা ঘটে থাকতে পারে:
প্রথম ক্ষেত্রে request টাই হারিয়েছে, তাই আবার পাঠানো দরকার। দ্বিতীয় ক্ষেত্রে কাজ হয়ে গেছে, তাই আবার পাঠানো বিপজ্জনক। App এর কাছে দুটো দেখতে হুবহু এক। তাই “retry করবো কিনা” এই প্রশ্নের উত্তর app একা দিতে পারে না। সমাধান একটাই: retry যেন সবসময় নিরাপদ হয়, সেই ব্যবস্থা server কে করতে হবে।
আর retry আসে অনেক দিক থেকে। শিমুল নিজে আবার চাপলো, এটা একটা। এর বাইরেও mobile app এর HTTP library নিজে থেকে retry করে। Load balancer বা API gateway কোনো server থেকে উত্তর না পেলে request টা অন্য server এ পাঠায়। ইমরান হিসাব করে দেখলো, প্রতিদিনের ২০ লাখ transfer এর মধ্যে প্রায় ০.৫% এ app timeout পায়। মানে দিনে প্রায় ১০,০০০টা “কাজ হলো কিনা জানি না” মুহূর্ত।
সেশন ০৮ এ দেখেছি, idempotent কাজ একাধিকবার চালালেও একবারের মতোই ফল দেয়। আর এর জন্য লাগে একটা unique ID, যেটা দেখে বোঝা যায় কাজটা আগে হয়েছে কিনা। এবার প্রশ্ন হলো: HTTP request এর বেলায় সেই ID কে বানাবে, কোথায় রাখবে, আর কতদিন রাখবে।
০২ HTTP API তে idempotency key কীভাবে কাজ করে?
ইমরানের প্রথম সিদ্ধান্ত: ID টা বানাবে app, server নয়। শিমুল যখন confirm screen এ “Send” চাপে, ঠিক তখনই app একটা random ID বানায় (যেমন UUID, 7f3a-91c2...)। প্রথম request আর তার সব retry এই একই ID নিয়ে যায়। এই ID কে বলে idempotency key, আর app এটা পাঠায় একটা HTTP header এ: Idempotency-Key: 7f3a-91c2।
কেন app কেই বানাতে হবে? কারণ server এর কাছে প্রতিটা request ই নতুন। Server নিজে key বানালে প্রতিটা retry একটা নতুন key পেতো, আর পুরো ব্যবস্থাটাই অর্থহীন হয়ে যেতো। “এটা একই চেষ্টা” এই কথাটা শুধু app জানে।
Server এর দিকে কাজটা তিন ধাপের:
- Request এলে server দেখে, এই user এর এই key আগে এসেছে কিনা।
- না এসে থাকলে key টা সেভ করে, আসল কাজটা করে (টাকা কাটে), আর পাঠানো response টাও সেভ করে রাখে। তিনটাই একটা database transaction এ।
- আগে এসে থাকলে কাজটা আর করে না। শুধু জমিয়ে রাখা response টা হুবহু ফেরত দেয়।
শেষ ধাপটার নাম stored response। দ্বিতীয় tap এ শিমুল ঠিক সেই উত্তরটাই পাবে, যেটা প্রথমবার হারিয়ে গিয়েছিলো: “৳৫০০ পাঠানো হয়েছে, TXN-88412”। তার কাছে মনে হবে, দ্বিতীয় চেষ্টায় কাজ হলো। আসলে কাজ হয়েছিলো একবারই।
Table টা দেখতে এমন:
CREATE TABLE idempotency_keys (
user_id BIGINT NOT NULL,
idem_key VARCHAR(64) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL,
response_code INT,
response_body JSONB,
created_at TIMESTAMP NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, idem_key)
);
-- একটা transaction এর ভেতরে:
BEGIN;
INSERT INTO idempotency_keys (user_id, idem_key, request_hash, status)
VALUES (42, '7f3a-91c2', 'e3b0c4...', 'IN_PROGRESS');
UPDATE wallets SET balance = balance - 500 WHERE user_id = 42;
UPDATE idempotency_keys
SET status = 'COMPLETED', response_code = 200,
response_body = '{"txn": "TXN-88412", "amount": 500}'
WHERE user_id = 42 AND idem_key = '7f3a-91c2';
COMMIT;
এখানে দুইটা জিনিস খেয়াল করুন। Primary key শুধু idem_key নয়, (user_id, idem_key)। এতে এক user এর key অন্য user এর key এর সাথে মিলে গেলেও গোলমাল হয় না। আর request_hash এ request body এর একটা hash রাখা হয়। একই key নিয়ে কেউ ভিন্ন body পাঠালে (ধরুন ৳৫০০ এর বদলে ৳৫,০০০), server সেটা ধরে ফেলে, আর একটা error ফেরত দেয় (যেমন 422)। Client এর bug এ একই key ভুল করে আরেকটা কাজে ব্যবহার হলেও তাই কোনো ক্ষতি হয় না।
এই পদ্ধতি নতুন কিছু নয়। Stripe বহু বছর ধরে এভাবেই payment API চালায়, আর Idempotency-Key header টাকে একটা সাধারণ HTTP standard বানানোর কাজ IETF এ চলছে।
Key বানায় client, প্রথম চেষ্টার আগেই। সব retry একই key নিয়ে যায়। Server key এর সাথে response টাও জমিয়ে রাখে, যাতে retry কে হুবহু একই উত্তর দিতে পারে।
০৩ একই key এর দুইটা request প্রায় একসাথে এলে কী হবে?
ইমরান নতুন code টা test করছিলো। Tester একটা কৌশল করলো: একই key দিয়ে দুইটা request পাঠালো, মাত্র ৫০ms ব্যবধানে। প্রথম request তখনো payment gateway এর উত্তরের অপেক্ষায়। দ্বিতীয় request database এ খুঁজে দেখলো, key নেই, কারণ প্রথমটা তখনো শেষ করে লেখেনি। তাই দ্বিতীয়টাও টাকা কাটতে চললো।
সমস্যাটা হলো “আগে খুঁজে দেখি, তারপর লিখি” পদ্ধতি। খোঁজা আর লেখার মাঝের ছোট্ট ফাঁকে আরেকটা request ঢুকে পড়তে পারে। সমাধান হলো খোঁজার বদলে সরাসরি লেখা, আর লেখার দায়িত্ব database এর primary key কে দেওয়া:
প্রথম request key টা IN_PROGRESS অবস্থায় INSERT করে। দ্বিতীয় request একই key INSERT করতে গেলে database তাকে আটকে দেয়, কারণ primary key তে দুইটা একই মান থাকতে পারে না। দ্বিতীয় request তখন 409 (Conflict) পায়, মানে “এই কাজ এখন চলছে, একটু পরে আবার চেষ্টা করুন”। একটু পরে retry করলে কাজটা ততক্ষণে COMPLETED, আর সে জমানো response টাই পায়।
একটা জিনিস মাথায় রাখুন। Server কাজের মাঝখানে crash করলে key টা IN_PROGRESS অবস্থায় আটকে থাকতে পারে। তাই IN_PROGRESS এর একটা সময়সীমা রাখতে হয় (ধরুন ৬০ সেকেন্ড)। সময় পার হলে একটা background job দেখে নেয়, টাকা আসলে কেটেছে কিনা, আর সেই অনুযায়ী row টা ঠিক করে।
০৪ Key কতদিন মনে রাখবো, আর dedup table কত বড় হবে?
যে table এ আগের key বা ID গুলো জমিয়ে রাখা হয় duplicate ধরার জন্য, তাকে সাধারণভাবে বলে dedup table (deduplication table)। ইমরানের idempotency_keys একটা dedup table। প্রতিটা transfer এ একটা নতুন row যোগ হয়। কিন্তু কোনো row কখনো মোছা না হলে কী হবে?
প্রতিদিন transfer = 2,000,000 একটা row (response সহ) ~ 500 bytes একদিনের নতুন row ~ 2,000,000 x 500 B = ~1 GB 24 ঘণ্টার window রাখলে ~ 1 GB (প্রতি ঘণ্টায় পুরোনো row মুছে ফেলা হয়) 7 দিনের window রাখলে ~ 7 GB কখনো না মুছলে ~ 365 GB / বছর, আর index ও সাথে সাথে বড় হয়
বছরে ৩৬৫ GB শুধু পুরোনো key এর জন্য। আর table যত বড় হয়, প্রতিটা INSERT এ index আপডেট করতে তত বেশি সময় লাগে (সেশন ০৭)। অথচ এক বছর আগের key দিয়ে কেউ আর retry করবে না।
তাই key একটা নির্দিষ্ট সময় পর্যন্ত মনে রাখা হয়, তারপর মুছে ফেলা হয়। এই সময়সীমাকে বলে idempotency window। Stripe তাদের key ২৪ ঘণ্টার পর মুছে ফেলার অধিকার রাখে। এই window এর ভেতরে একই key এলে duplicate ধরা পড়ে। Window পার হয়ে একই key এলে server তাকে নতুন request হিসেবেই দেখে।
Window কত বড় হবে, তার একটা পরিষ্কার নিয়ম আছে: সবচেয়ে লম্বা retry এর চেয়ে window বড় হতে হবে। পকেটপে এর app সর্বোচ্চ ২৪ ঘণ্টা পর্যন্ত একটা অসমাপ্ত transfer retry করে। তাই ইমরান window রাখলো ৪৮ ঘণ্টা, দ্বিগুণ নিরাপত্তা। পুরোনো row মোছার জন্য সে table টাকে দিন অনুযায়ী ভাগ করলো (partition)। প্রতিদিন রাতে তিন দিন আগের পুরো partition টা একবারে drop হয়ে যায়। লাখ লাখ row একটা একটা করে DELETE করার চেয়ে এটা অনেক দ্রুত।
| Window ছোট হলে | Window বড় হলে |
|---|---|
| দেরিতে আসা retry duplicate হিসেবে ধরা পড়ে না, আবার টাকা কাটে | Storage আর index বড় হয় |
| Table ছোট, দ্রুত | দেরিতে আসা retry ও নিরাপদ |
০৫ কিছু কাজ নিজে থেকেই idempotent কেন?
এতক্ষণ আমরা key, table, window নিয়ে অনেক কিছু বানালাম। কিন্তু কিছু কাজের জন্য এসবের কোনো দরকারই নেই। শিমুল যদি app এ নিজের নাম “Shimul” থেকে বদলে “Shimul Ahmed” করে, আর সেই request দশবার যায়, তাহলে কী হবে? দশবারই নাম হবে “Shimul Ahmed”। কোনো ক্ষতি নেই।
যে কাজ নিজের গঠনের কারণেই idempotent, তাকে বলে natural idempotency। পার্থক্যটা লুকিয়ে আছে কাজটা আগের মানের উপর নির্ভর করে কিনা, তার মধ্যে:
- INCREMENT ধরনের কাজ আগের মানের উপর ভর করে: “যা আছে তা থেকে ৫০০ কমানো”, “count এ ১ যোগ করা”। প্রতিবার চালালে ফল বদলায়। এগুলো idempotent নয়।
- SET ধরনের কাজ একটা নির্দিষ্ট মান বসায়: “status হবে PAID”, “নাম হবে Shimul Ahmed”। যতবার চালান, শেষ ফল একটাই। এগুলো idempotent।
HTTP তেও এই পার্থক্য method এর নামে লেখা আছে:
| HTTP method | কী করে | Idempotent? |
|---|---|---|
| GET | শুধু পড়ে | হ্যাঁ |
| PUT | পুরো resource টা একটা নির্দিষ্ট মানে বসায় | হ্যাঁ |
| DELETE | মুছে ফেলে (দ্বিতীয়বার মোছার কিছু থাকে না) | হ্যাঁ |
| POST | নতুন কিছু তৈরি করে (নতুন transfer) | না, তাই key লাগে |
একটা ভালো design কৌশল হলো INCREMENT কে যতটা পারা যায় SET বা INSERT এ বদলে ফেলা। ইমরান wallet এর balance সরাসরি কমানোর বদলে একটা ledger table এ লেখে: “TXN-88412: শিমুল থেকে তিথি, ৳৫০০”। আর transfer_id কলামে একটা UNIQUE constraint রাখে। একই transfer দ্বিতীয়বার লিখতে গেলে database নিজেই আটকে দেয়। Balance হিসাব হয় ledger এর যোগফল থেকে। Idempotency key এর পাহারা কোনো bug এর কারণে ফসকে গেলেও এই শেষ পাহারা টপকানো যায় না।
নতুন API বানানোর আগে নিজেকে জিজ্ঞেস করুন: কাজটা কি “একটা মান বসানো” আকারে লেখা যায়? গেলে retry নিয়ে চিন্তা অর্ধেক কমে যায়। না গেলে idempotency key লাগবে।
০৬ Exactly-once delivery কেন অসম্ভব, কিন্তু effectively-once কীভাবে সম্ভব?
ইমরানের টিমের একজন নতুন সদস্য প্রস্তাব দিলো: “এত ঝামেলা কেন? এমন একটা messaging system কিনে ফেলি, যেটা exactly-once delivery দেয়। তাহলে কোনো message দুইবার আসবেই না।” প্রস্তাবটা শুনতে লোভনীয়। কিন্তু network এর উপর দিয়ে সেটা পাওয়া সম্ভব নয়। কারণটা একটা সহজ ছবিতেই বোঝা যায়:
Sender একটা message পাঠালো। Receiver পেয়ে ACK পাঠালো। ACK টা হারিয়ে গেলে sender জানে না message টা পৌঁছেছিলো কিনা। Receiver চাইলে “আমার ACK পেয়েছেন?” জিজ্ঞেস করতে পারে, কিন্তু সেই প্রশ্নটাও হারাতে পারে। যত ধাপ যোগ করুন, শেষ বার্তাটা সবসময় হারানোর ঝুঁকিতে থাকে। Computer science এ এই সমস্যার একটা বিখ্যাত নাম আছে: Two Generals problem। এর প্রমাণ বলে, হারাতে পারে এমন channel এর উপর দিয়ে দুই পক্ষ কখনো পুরোপুরি নিশ্চিত হতে পারে না।
তাই ACK না এলে sender এর হাতে মাত্র দুইটা পথ। আবার পাঠালে duplicate হতে পারে (at-least-once)। না পাঠালে message হারাতে পারে (at-most-once)। তৃতীয় কোনো পথ নেই। সেশন ০৮ এর table এ যে exactly-once কে “প্রায় অসম্ভব” বলা হয়েছিলো, এই হলো তার কারণ।
তাহলে “exactly-once” দাবিগুলোর মানে কী?
Message ঠিক একবার পৌঁছানো অসম্ভব। কিন্তু message এর ফল ঠিক একবার হওয়া সম্ভব। এর জন্য দুইটা জিনিস একসাথে লাগে:
Delivery হবে at-least-once, যাতে কোনো message হারায় না। আর processing হবে idempotent, যাতে duplicate এলে কোনো ক্ষতি না হয়। দুটো মিলে যা পাওয়া যায়, তাকে বলে effectively-once (বা exactly-once processing)। শিমুলের ক্ষেত্রে request দুইবার এসেছে, কিন্তু টাকা কেটেছে একবার। এটাই effectively-once।
কোনো product “exactly-once” লিখলে বুঝে নেবেন, তারা আসলে effectively-once এর কথা বলছে। আর সেই প্রতিশ্রুতি সাধারণত তাদের নিজেদের system এর সীমানার ভেতরেই খাটে। পরের section এ Kafka দিয়ে এই সীমানাটা দেখবো।
০৭ Kafka transaction কী দেয়, আর কী দেয় না?
সেশন ১০ এ দেখেছি, Kafka তে message গুলো topic এর partition এ সারি করে থাকে। প্রতিটা message এর একটা ক্রমিক নম্বর থাকে, যাকে বলে offset। Consumer কতদূর পড়লো, সেটা সে offset commit করে জানায়। Kafka এর “exactly-once semantics” দুইটা জিনিসের উপর দাঁড়িয়ে আছে।
প্রথমটা idempotent producer। Producer প্রতিটা message এর সাথে নিজের একটা ID আর একটা ক্রমিক নম্বর পাঠায়। Network এর কারণে producer একই message আবার পাঠালে broker নম্বর দেখে বুঝে ফেলে, আর দ্বিতীয় কপিটা রাখে না। Kafka 3.0 থেকে এটা default হিসেবে চালু থাকে।
দ্বিতীয়টা Kafka transaction। পকেটপে এর fraud check service একটা topic থেকে transfer পড়ে, যাচাই করে, আর আরেকটা topic এ “approved” event লেখে। Crash হলে দুই রকম বিপদ হতে পারে। Event লেখা হলো কিন্তু offset commit হলো না: restart এর পর একই transfer আবার যাচাই হয়ে দ্বিতীয় event যাবে। আবার offset commit হলো কিন্তু event লেখা হলো না: transfer টা চুপচাপ হারিয়ে যাবে। Kafka transaction এই দুইটা লেখাকে একসাথে বেঁধে দেয়:
# producer (fraud check service)
enable.idempotence = true # retry করা message broker নিজেই বাদ দেয়
transactional.id = fraud-check-1 # restart এর পরও একই producer হিসেবে চেনা যায়
# consumer (যে approved-transfers পড়বে)
isolation.level = read_committed # শুধু commit হওয়া লেখা দেখে
Consumer read_committed mode এ থাকলে অসমাপ্ত transaction এর লেখা কখনো দেখে না। ফলে Kafka থেকে পড়ে Kafka তে লেখার পুরো কাজটা effectively-once হয়।
কিন্তু ছবির নিচের দিকটা খেয়াল করুন। Fraud check service যদি একটা SMS পাঠায়, বা নিজের PostgreSQL database এ কিছু লেখে, সেই কাজ Kafka transaction এর অংশ নয়। Transaction বাতিল হলেও SMS টা চলে গেছে। Restart এর পর আবার যাবে। Kafka এর guarantee Kafka এর সীমানায় এসেই থেমে যায়। সীমানার বাইরের প্রতিটা কাজ আপনাকেই idempotent বানাতে হবে।
০৮ Consumer এর দিকে duplicate কীভাবে ধরবো?
সেশন ১৭ তে outbox pattern দেখেছি। Producer event টাকে নিজের database এর outbox table এ business data এর সাথে একই transaction এ লেখে, আর একটা relay পরে সেটা broker এ পাঠায়। এতে event হারায় না। কিন্তু relay পাঠানোর পর “sent” চিহ্ন দেওয়ার আগে crash করলে একই event দুইবার যায়।
পকেটপে তে শিমুলের transfer শেষ হলে একটা TransferDone event যায়। Wallet service সেটা শুনে তিথির balance এ ৳৫০০ যোগ করে। এই event দুইবার এলে তিথি পাবে ৳১,০০০। শিমুলের কাটা গেছে ৳৫০০, অথচ তিথি পেলো ৳১,০০০। পকেটপে নিজের পকেট থেকে ৳৫০০ গচ্চা দিলো।
এর সমাধান হলো outbox এর আয়না, consumer এর দিকে: inbox pattern। Consumer এর database এ একটা inbox table থাকে, যেখানে প্রতিটা প্রক্রিয়া করা event এর ID লেখা হয়। মূল কৌশল হলো, event এর ID লেখা আর আসল কাজটা, দুটোই একই local transaction এ হয়:
প্রথমবার event এলে inbox এ ID ঢোকে, balance বাড়ে, transaction commit হয়, তারপর Kafka তে offset commit হয়। একই event আবার এলে inbox এ INSERT ব্যর্থ হয়, কারণ ID টা আগেই আছে। পুরো transaction বাতিল হয়, balance আর বাড়ে না। Consumer শুধু offset commit করে সামনে এগিয়ে যায়।
Consumer database এ লেখার পর কিন্তু offset commit এর আগে crash করলেও সমস্যা নেই। Restart এর পর Kafka একই event আবার দেবে, আর inbox সেটাকে duplicate হিসেবে ধরে ফেলবে। সেশন ০৮ এর worker এর idempotency check আর inbox pattern একই চিন্তা থেকে আসে। পার্থক্য হলো, inbox একটা নির্দিষ্ট নিয়মে সাজানো table, আর প্রতিটা consumer এ একইভাবে ব্যবহার করা যায়।
Inbox এর window আর Kafka এর retention
Inbox ও একটা dedup table, তাই তারও একটা window লাগে। এখানে একটা ফাঁদ আছে। সেশন ১০ এ দেখেছি, Kafka message গুলো একটা নির্দিষ্ট সময় (retention, প্রায়ই ৭ দিন) রেখে দেয়, আর consumer চাইলে পুরোনো offset থেকে আবার পড়তে পারে (replay)। ইমরানের টিম একবার bug ঠিক করতে ৫ দিন আগের offset থেকে replay করলো। Inbox এর window ছিলো ২৪ ঘণ্টা। ফলে ২৪ ঘণ্টার চেয়ে পুরোনো event গুলো inbox এ আর ছিলো না, আর সেগুলো আবার প্রক্রিয়া হলো।
শিক্ষাটা পরিষ্কার: inbox এর window কে Kafka এর retention এর সমান বা বেশি রাখুন। অথবা replay এর সময় এমন consumer ব্যবহার করুন, যার কাজ নিজে থেকেই idempotent।
Outbox নিশ্চিত করে event হারায় না (producer এর দিকে)। Inbox নিশ্চিত করে event দুইবার ফল দেয় না (consumer এর দিকে)। দুটো মিলে মাঝের broker at-least-once হলেও পুরো পথটা effectively-once হয়।
০৯ Button থেকে database পর্যন্ত: end-to-end idempotency দেখতে কেমন?
ইমরান এবার পুরো পথটা একসাথে সাজালো। প্রশ্ন একটাই: শিমুলের এক চাপ থেকে তিথির হাতে টাকা পৌঁছানো পর্যন্ত কোথায় কোথায় duplicate জন্ম নিতে পারে, আর প্রতিটা জায়গায় কে পাহারা দেয়?
- Button: একবার চাপার পর button বন্ধ হয়ে যায়, spinner দেখায়। এতে আঙুলের double tap আটকায়। কিন্তু network এর retry এতে আটকায় না।
- App: Key একবারই বানায়, আর phone এর storage এ সেভ রাখে। শিমুল app বন্ধ করে আবার খুললেও অসমাপ্ত transfer একই key নিয়ে retry হয়।
- Payment API: Idempotency table আর stored response। Network, load balancer আর user এর retry এখানে ধরা পড়ে।
- Ledger DB:
transfer_idএর উপর UNIQUE constraint। উপরের কোনো স্তরে bug থাকলেও একই transfer দুইবার লেখা যায় না। - Outbox:
TransferDoneevent একই transaction এ লেখা হয়, সাথে একটাevent_id। Event হারায় না। - Consumer: Wallet service আর SMS service দুজনেরই inbox আছে। তিথি টাকা পায় একবার, “৳৫০০ পেয়েছেন” SMS ও পায় একবার।
খেয়াল করুন, প্রতিটা স্তর আলাদা ধরনের duplicate আটকায়। Button network এর retry চেনে না। API জানে না Kafka তে কী হচ্ছে। Inbox জানে না user কয়বার চেপেছে। তাই একটা স্তরের উপর ভরসা করে বাকিগুলো বাদ দেওয়া যায় না।
নতুন design চালু হওয়ার তিন সপ্তাহ পর এক বৃষ্টির রাতে আরেকজন user ঠিক শিমুলের মতো train এ বসে দুইবার Send চাপলেন। দ্বিতীয় চাপে server জমানো উত্তরটাই ফেরত দিলো: “৳৫০০ পাঠানো হয়েছে, TXN-90217”। টাকা কাটলো একবার। Support এ “দুইবার কেটেছে” ticket এর সংখ্যা মাসে ৩৪০ থেকে নেমে এলো শূন্যে। আর শিমুলের বাড়তি ৳৫০০? ইমরান ledger মিলিয়ে নিজে হাতে সেটা ফেরত পাঠালো, সাথে একটা দুঃখপ্রকাশের SMS।
১০ মগজে প্রেশার দিন
আগে নিজে ভাবুন। তারপর উত্তর খুলে দেখুন: বেশিরভাগ মানুষ কীভাবে ভাবে, সেই চিন্তায় কী বাদ পড়ে, আর আসল কথাটা কোথায়।
উত্তর দেখুন আগে নিজে ভাবুন
সবাই যেভাবে ভাবে
“হ্যাঁ। User একবার চাপলেই button বন্ধ। দ্বিতীয়বার চাপার সুযোগই নেই, তাই duplicate ও নেই।”
Button বন্ধ করা ভালো অভ্যাস, কিন্তু এটা duplicate এর সবচেয়ে ছোট উৎসটাই শুধু আটকায়।
যে জায়গাটা যোগ করা দরকার
শিমুলের ক্ষেত্রে button ঠিকই বন্ধ হয়েছিলো। Timeout এর পর app error দেখিয়ে button আবার চালু করলো, আর শিমুল যুক্তিসঙ্গত কারণেই আবার চাপলো। এর বাইরে app এর HTTP library নিজে retry করে, load balancer অন্য server এ request পাঠায়, আর app crash করে খুললে অসমাপ্ত কাজ আবার পাঠায়। এর কোনোটাই button দেখে না।
তাই আসল পাহারা থাকতে হবে server এ: client এর বানানো idempotency key, stored response, আর database এর UNIQUE constraint। Button বন্ধ করা শুধু UX এর সৌজন্য।
Client এর দিকের যেকোনো সুরক্ষা সহায়ক মাত্র। Duplicate ঠেকানোর দায়িত্ব server এর, কারণ retry যেকোনো জায়গা থেকে আসতে পারে।
উত্তর দেখুন আগে নিজে ভাবুন
সবাই যেভাবে ভাবে
“ভালো বুদ্ধি। একই user একই জনকে একই পরিমাণ পাঠালে hash একই হবে, তাই duplicate ধরা পড়বে। Client কে কিছু মনে রাখতে হবে না।”
শুনতে চতুর, কিন্তু এটা duplicate আর সত্যিকারের দ্বিতীয় কাজের পার্থক্য মুছে ফেলে।
যে জায়গাটা যোগ করা দরকার
ধরুন, শিমুল সকালে তিথিকে ৳৫০০ পাঠালো coaching এর ফি হিসেবে। বিকেলে আবার ৳৫০০ পাঠাতে চাইলো বইয়ের জন্য। দুটোই সত্যিকারের আলাদা transfer। কিন্তু hash একই, তাই window এর ভেতরে দ্বিতীয়টা “duplicate” বলে আটকে যাবে, আর শিমুল সকালের response টা ফেরত পাবে। টাকা যাবে না, অথচ app বলবে গেছে।
Key এর কাজ হলো “এটা একই চেষ্টা” চিহ্নিত করা, “এটা একই রকম কাজ” নয়। তাই প্রতিটা নতুন চেষ্টায় (প্রতিটা নতুন confirm screen এ) একটা নতুন random key লাগে, আর শুধু retry তেই পুরোনো key ফিরে আসে। Body এর hash এর জায়গা হলো request_hash কলাম, যাতে একই key এর সাথে ভিন্ন body ধরা পড়ে।
Key চিহ্নিত করে চেষ্টাকে, বিষয়বস্তুকে নয়। নতুন চেষ্টায় নতুন key, retry তে পুরোনো key।
উত্তর দেখুন আগে নিজে ভাবুন
সবাই যেভাবে ভাবে
“Exactly-once আসলে সম্ভব নয়, তাই কিছু duplicate SMS যাবেই। এটা মেনে নিতে হবে।”
প্রথম অংশ ঠিক, কিন্তু উপসংহারটা অলস। Duplicate SMS ঠেকানো সম্ভব, শুধু Kafka দিয়ে নয়।
যে জায়গাটা যোগ করা দরকার
Kafka transaction শুধু Kafka এর ভেতরের কাজ বাঁধে: topic থেকে পড়া, topic এ লেখা, আর offset commit। SMS পাঠানো একটা বাইরের API call। SMS চলে যাওয়ার পর offset commit এর আগে service crash করলে, restart এর পর Kafka একই event আবার দেবে, আর SMS আবার যাবে।
সমাধান হলো সীমানার বাইরের কাজটাকে আলাদাভাবে idempotent বানানো। Notification service এর inbox table এ event_id লিখে তবেই SMS পাঠানো হোক। আর SMS provider যদি idempotency key নেয়, তাকেও event_id পাঠানো হোক। তাহলে crash এর পরও duplicate SMS এর সম্ভাবনা প্রায় শূন্যে নামে।
কোনো tool এর “exactly-once” শুনলে প্রথম প্রশ্ন করুন: কোন সীমানার ভেতরে? সীমানার বাইরের প্রতিটা side effect এর idempotency আপনার নিজের দায়িত্ব।
১১ যে টার্মগুলো শিখলাম
| টার্ম | মানে |
|---|---|
| Idempotency-Key header | HTTP request এর সাথে পাঠানো একটা header, যেখানে client তার বানানো key টা দেয়। |
| Client-generated key | প্রথম চেষ্টার আগেই client এর বানানো random ID। প্রথম চেষ্টা আর সব retry একই key নিয়ে যায়। |
| Stored response | প্রথমবার পাঠানো response টা জমিয়ে রাখা, যাতে retry কে হুবহু একই উত্তর দেওয়া যায়। |
| Request hash | Request body এর hash। একই key এর সাথে ভিন্ন body এলে সেটা ধরে ফেলে। |
| Dedup table | যে table এ আগের key বা event ID গুলো জমা থাকে, duplicate চেনার জন্য। |
| Idempotency window | কতদিন একটা key মনে রাখা হবে। এর ভেতরে duplicate ধরা পড়ে, এর পরে key মুছে যায়। |
| Natural idempotency | যে কাজ নিজের গঠনের কারণেই idempotent, যেমন SET, PUT, DELETE। কোনো key লাগে না। |
| Two Generals problem | হারাতে পারে এমন network এর উপর দিয়ে দুই পক্ষ কখনো পুরোপুরি নিশ্চিত হতে পারে না, এই প্রমাণিত সমস্যা। |
| Effectively-once | At-least-once delivery আর idempotent processing মিলে ফল ঠিক একবারের মতো হওয়া। |
| Idempotent producer | Kafka producer, যার retry করা duplicate message broker নিজেই বাদ দেয়। |
| Kafka transaction | Kafka তে লেখা আর offset commit একসাথে commit বা বাতিল করার ব্যবস্থা। |
| read_committed | Consumer এর mode, যেখানে শুধু commit হওয়া transaction এর message দেখা যায়। |
| Inbox pattern | Consumer event এর ID inbox table এ লেখে, আর আসল কাজটা একই local transaction এ করে। Duplicate এলে INSERT ব্যর্থ হয়। |
| End-to-end idempotency | Button থেকে শেষ consumer পর্যন্ত প্রতিটা স্তরে আলাদা আলাদা duplicate পাহারা। |
১২ শর্ট সামারি
- Timeout পেলে client জানে না request হারিয়েছে নাকি response। তাই retry নিরাপদ করার দায়িত্ব server এর। Client প্রথম চেষ্টার আগেই একটা idempotency key বানায়, আর server key এর সাথে response জমিয়ে রেখে retry কে হুবহু একই উত্তর দেয়।
- একসাথে আসা duplicate ঠেকাতে key টা primary key হিসেবে INSERT করতে হয়। আর dedup table এর একটা idempotency window লাগে, যেটা সবচেয়ে লম্বা retry এর চেয়ে বড়, কিন্তু অনন্ত নয়।
- SET, PUT আর DELETE এর মতো কাজ নিজে থেকেই idempotent। INCREMENT আর POST এর জন্য key বা UNIQUE constraint লাগে।
- Exactly-once delivery অসম্ভব, কিন্তু at-least-once delivery আর idempotent processing মিলে effectively-once সম্ভব। Kafka transaction শুধু Kafka এর ভেতরে এটা দেয়। বাইরের কাজের জন্য inbox pattern আর end-to-end idempotency লাগে।
১৩ মনে রাখুন
- Timeout মানে “জানি না”, “হয়নি” নয়। তাই retry সবসময় নিরাপদ করে বানান।
- Key বানায় client, প্রথম চেষ্টার আগেই। সব retry একই key নিয়ে যায়, নতুন চেষ্টায় নতুন key।
- খুঁজে দেখে নয়, INSERT করে duplicate ধরুন। Primary key একসাথে আসা দুইটা request এর একটাকেই জিততে দেয়।
- Window সবচেয়ে লম্বা retry বা replay এর চেয়ে বড় রাখুন। Inbox এর বেলায় সেটা Kafka এর retention।
- INCREMENT কে SET বা UNIQUE INSERT এ বদলান। তাহলে idempotency কাজের গঠনেই চলে আসে।
- Exactly-once delivery নেই, effectively-once আছে। At-least-once delivery আর idempotent processing একসাথে লাগে।
- কোনো একটা স্তরের উপর ভরসা করবেন না। Button, API, database আর consumer প্রত্যেকে আলাদা duplicate আটকায়।