সেশন ১৮

Idempotency - একবার চাপুন বা দশবার, টাকা কাটবে একবার

এই সেশনের লক্ষ্য: সেশন ০৮ এর idempotency কে আরো গভীরে নিয়ে যাওয়া। HTTP API তে client এর বানানো key আর জমানো response দিয়ে নিরাপদ retry, dedup table কতদিন রাখতে হয়, কোন কাজ নিজে থেকেই idempotent, exactly-once delivery কেন অসম্ভব কিন্তু effectively-once কীভাবে সম্ভব, Kafka transaction কতটুকু দেয়, consumer এর দিকে inbox pattern, আর button থেকে database পর্যন্ত পুরো পথে duplicate ঠেকানো।

০০ যে প্রশ্নগুলোর উত্তর খুঁজবো

সেশন ০৮ এ আমরা 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 1 POST /transfers · ৳500 debit 500 balance 2000 → 1500 200 OK response হারালো 15s পরে timeout TAP 2 আবার POST · ৳500 নতুন request ভেবে debit 500 1500 → 1000 200 OK · "পাঠানো হয়েছে" ফল: একবার পাঠাতে চেয়ে ৳1,000 কাটা গেলো App Payment API Ledger DB
প্রথম tap এ টাকা ঠিকঠাক কেটেছিলো। শুধু response টা train এর দুর্বল network এ হারিয়ে গেলো। App ১৫ সেকেন্ড অপেক্ষা করে error দেখালো। দ্বিতীয় tap কে server চিনতেই পারলো না, তাই আবার ৳৫০০ কাটলো।

প্রথম tap এর request ঠিকঠাক server এ পৌঁছেছিলো। টাকাও কেটেছিলো। শুধু উত্তরটা ফেরার পথে train এর দুর্বল network এ হারিয়ে গেলো। দ্বিতীয় tap এর request কে server একদম নতুন একটা transfer ভাবলো। তার কাছে “এটা আগের চেষ্টার retry” বোঝার কোনো উপায় ছিলো না।

App এর দিক থেকে দুইটা ঘটনা একই রকম

App timeout পেলে আসলে দুইটার যেকোনো একটা ঘটে থাকতে পারে:

REQUEST LOST হারালো কিছুই পায়নি timeout App Server RESPONSE LOST ৳500 কাটলো হারালো timeout App Server App এর চোখে দুটোই হুবহু এক: 15 সেকেন্ড পর timeout বাঁয়েরটায় retry দরকার, ডানেরটায় retry মানে দুইবার কাটা
বাঁ দিকে request টাই server এ পৌঁছায়নি, তাই আবার পাঠানো দরকার। ডান দিকে কাজ হয়ে গেছে, শুধু উত্তরটা ফেরেনি। App এর কাছে দুটোর চেহারা একই, তাই নিরাপদ retry এর ব্যবস্থা server কেই করতে হয়।

প্রথম ক্ষেত্রে 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 জানে।

App: Send চাপলেই একটা key বানায় Idempotency-Key: 7f3a-91c2 · retry তে একই key Payment API: এই key আগে দেখেছি? idempotency_keys table এ খোঁজে হ্যাঁ, COMPLETED না, নতুন key আগের response ফেরত 200 · TXN-88412 · ৳500 গেছে টাকা আবার কাটে না নতুন কাজ key সেভ + debit + response সেভ সব একটা transaction এ
Key বানায় app, server নয়। প্রথম চেষ্টা আর সব retry একই key নিয়ে যায়। Server key টা আগে দেখে থাকলে কাজটা আবার করে না, শুধু জমিয়ে রাখা response টাই ফেরত দেয়। তাই দ্বিতীয় tap এও user একই উত্তর পায়: "৳৫০০ পাঠানো হয়েছে"।

Server এর দিকে কাজটা তিন ধাপের:

  1. Request এলে server দেখে, এই user এর এই key আগে এসেছে কিনা।
  2. না এসে থাকলে key টা সেভ করে, আসল কাজটা করে (টাকা কাটে), আর পাঠানো response টাও সেভ করে রাখে। তিনটাই একটা database transaction এ।
  3. আগে এসে থাকলে কাজটা আর করে না। শুধু জমিয়ে রাখা 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 কে দেওয়া:

req 1 · key 7f3a req 2 · key 7f3a · 50ms পরে req 1: INSERT key, IN_PROGRESS row তৈরি হলো req 2: INSERT একই key duplicate key error req 2 পায়: 409 "আগেরটা চলছে" req 1 পায়: 200 · TXN-88412 PRIMARY KEY (user_id, idem_key) দুইটা row হতে দেয় না একই key এর কাজ একসাথে একটাই চলে App Payment API Database
দুইটা request প্রায় একসাথে এলে দুটোই "key নেই" দেখতে পারে। তাই শুধু খুঁজে দেখা যথেষ্ট নয়। Key টা primary key হিসেবে INSERT করতে হয়। Database একটাকেই জিততে দেয়। হেরে যাওয়া request 409 পায়, আর একটু পরে retry করলে জমানো response টা পায়।

প্রথম 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 balance = balance - 500 আবার চালালে আবার চালালে 2000 → 1500 1500 → 1000 1000 → 500 SET status = 'PAID' WHERE txn = 88412 আবার চালালে আবার চালালে PENDING → PAID PAID → PAID PAID → PAID 3 বার চালালে ৳1,500 কাটা 3 বার চালালেও একই ফল
INCREMENT ধরনের কাজ আগের মানের উপর ভর করে, তাই প্রতিবার নতুন করে ৫০০ কমায়। SET ধরনের কাজ একটা নির্দিষ্ট মান বসিয়ে দেয়, তাই যতবারই চলুক, শেষ মান একটাই। দ্বিতীয় ধরনের কাজ নিজে থেকেই idempotent, তার জন্য কোনো key লাগে না।

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 এর কারণে ফসকে গেলেও এই শেষ পাহারা টপকানো যায় না।

Design এর কৌশল

নতুন API বানানোর আগে নিজেকে জিজ্ঞেস করুন: কাজটা কি “একটা মান বসানো” আকারে লেখা যায়? গেলে retry নিয়ে চিন্তা অর্ধেক কমে যায়। না গেলে idempotency key লাগবে।

০৬ Exactly-once delivery কেন অসম্ভব, কিন্তু effectively-once কীভাবে সম্ভব?

ইমরানের টিমের একজন নতুন সদস্য প্রস্তাব দিলো: “এত ঝামেলা কেন? এমন একটা messaging system কিনে ফেলি, যেটা exactly-once delivery দেয়। তাহলে কোনো message দুইবার আসবেই না।” প্রস্তাবটা শুনতে লোভনীয়। কিন্তু network এর উপর দিয়ে সেটা পাওয়া সম্ভব নয়। কারণটা একটা সহজ ছবিতেই বোঝা যায়:

message ACK: পেয়েছি আমার ACK পৌঁছেছে তো? সেটাও পৌঁছেছে তো? এভাবে চলতেই থাকে: শেষ বার্তাটা হারালে কেউ নিশ্চিত হতে পারে না ACK না এলে sender কী করবে? আবার পাঠাবে duplicate হতে পারে · at-least-once আর পাঠাবে না হারাতে পারে · at-most-once Sender Receiver
Network এ যেকোনো বার্তা হারাতে পারে, ACK ও। ACK না এলে sender জানে না message টা হারিয়েছে নাকি শুধু ACK টা। তৃতীয় কোনো পথ নেই। তাই network এর উপর দিয়ে "ঠিক একবার পৌঁছানো" (exactly-once delivery) কোনো protocol দিতে পারে না।

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 এর ফল ঠিক একবার হওয়া সম্ভব। এর জন্য দুইটা জিনিস একসাথে লাগে:

At-least-once delivery message হারায় না + Idempotent processing duplicate এ ক্ষতি নেই = Effectively-once processing ফল ঠিক একবারের
Message হয়তো দুইবার আসে, কিন্তু ফল হয় একবারের মতো। বাস্তবের যে system "exactly-once" দাবি করে, ভেতরে আসলে সে এই যোগফলটাই করে।

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 এই দুইটা লেখাকে একসাথে বেঁধে দেয়:

একটা Kafka transaction read transaction এর বাইরে transfers offset 1041 Fraud check read → process → write approved-transfers নতুন event লেখা offset commit "1041 পর্যন্ত পড়া শেষ" SMS / বাইরের database এখানে Kafka এর guarantee নেই দুটো একসাথে commit হয়, নাহয় কোনোটাই না · read_committed consumer অসমাপ্ত লেখা দেখে না
Fraud check service একটা topic থেকে পড়ে, আরেকটা topic এ লেখে। নতুন event লেখা আর "এতদূর পড়েছি" (offset commit), দুটোই একটা Kafka transaction এ যায়। মাঝপথে crash হলে দুটোই বাতিল হয়। কিন্তু SMS বা বাইরের কোনো database এ লেখা এই 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 এ হয়:

Kafka: TransferDone event_id: ev-5531 · তিথি +৳500 Wallet service · একটাই local transaction 1. inbox table INSERT ev-5531 (primary key) 2. wallets table তিথির balance + 500 প্রথমবার আবার এলো INSERT সফল, balance বাড়ে COMMIT, তারপর offset commit duplicate key, ROLLBACK balance ছোঁয় না, শুধু offset commit
Consumer আগে event_id টা inbox table এ লেখে, আর একই transaction এ আসল কাজটা করে। একই event দ্বিতীয়বার এলে primary key তাকে ঢুকতে দেয় না, তাই transaction টাই বাতিল হয়। তিথির balance একবারই বাড়ে, event যতবারই আসুক।

প্রথমবার 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 আর inbox, এক জোড়া

Outbox নিশ্চিত করে event হারায় না (producer এর দিকে)। Inbox নিশ্চিত করে event দুইবার ফল দেয় না (consumer এর দিকে)। দুটো মিলে মাঝের broker at-least-once হলেও পুরো পথটা effectively-once হয়।

০৯ Button থেকে database পর্যন্ত: end-to-end idempotency দেখতে কেমন?

ইমরান এবার পুরো পথটা একসাথে সাজালো। প্রশ্ন একটাই: শিমুলের এক চাপ থেকে তিথির হাতে টাকা পৌঁছানো পর্যন্ত কোথায় কোথায় duplicate জন্ম নিতে পারে, আর প্রতিটা জায়গায় কে পাহারা দেয়?

key: 7f3a-91c2 header এ একই key transfer_id: TXN-88412 একই transaction এ event_id: ev-5531 BUTTON APP PAYMENT API LEDGER DB OUTBOX CONSUMER এক tap এর পর বন্ধ, spinner দেখায় double tap আটকায় key একবার বানায়, phone এ সেভ রাখে app বন্ধ হয়ে খুললেও একই key এ retry idempotency_keys table, জমানো response network এর retry আটকায় transfer_id এর উপর UNIQUE constraint code এর bug থাকলেও শেষ পাহারা TransferDone event outbox এ (সেশন 17) event হারায় না, কিন্তু দুইবার যেতে পারে Wallet আর SMS service এ inbox table তিথি টাকা পায় একবার, SMS ও একবার
একটা key button থেকে শুরু হয়ে নিচের দিকে যায়, আর প্রতিটা স্তরে নতুন একটা ID তার সাথে জুড়ে যায়। কোনো একটা স্তর একা যথেষ্ট নয়। প্রতিটা স্তর আলাদা ধরনের duplicate আটকায়।

খেয়াল করুন, প্রতিটা স্তর আলাদা ধরনের duplicate আটকায়। Button network এর retry চেনে না। API জানে না Kafka তে কী হচ্ছে। Inbox জানে না user কয়বার চেপেছে। তাই একটা স্তরের উপর ভরসা করে বাকিগুলো বাদ দেওয়া যায় না।

নতুন design চালু হওয়ার তিন সপ্তাহ পর এক বৃষ্টির রাতে আরেকজন user ঠিক শিমুলের মতো train এ বসে দুইবার Send চাপলেন। দ্বিতীয় চাপে server জমানো উত্তরটাই ফেরত দিলো: “৳৫০০ পাঠানো হয়েছে, TXN-90217”। টাকা কাটলো একবার। Support এ “দুইবার কেটেছে” ticket এর সংখ্যা মাসে ৩৪০ থেকে নেমে এলো শূন্যে। আর শিমুলের বাড়তি ৳৫০০? ইমরান ledger মিলিয়ে নিজে হাতে সেটা ফেরত পাঠালো, সাথে একটা দুঃখপ্রকাশের SMS।

১০ মগজে প্রেশার দিন

আগে নিজে ভাবুন। তারপর উত্তর খুলে দেখুন: বেশিরভাগ মানুষ কীভাবে ভাবে, সেই চিন্তায় কী বাদ পড়ে, আর আসল কথাটা কোথায়।

প্রশ্ন ১: Send চাপার পর button টা disable করে দিলেই তো duplicate payment বন্ধ হয়ে যায়, তাই না?
উত্তর দেখুন আগে নিজে ভাবুন

সবাই যেভাবে ভাবে

“হ্যাঁ। 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 যেকোনো জায়গা থেকে আসতে পারে।

প্রশ্ন ২: Idempotency key হিসেবে hash(user_id + receiver + amount) ব্যবহার করলে কেমন হয়? আলাদা করে UUID বানানোর ঝামেলা থাকে না।
উত্তর দেখুন আগে নিজে ভাবুন

সবাই যেভাবে ভাবে

“ভালো বুদ্ধি। একই 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 ধরা পড়ে।

মূল pattern

Key চিহ্নিত করে চেষ্টাকে, বিষয়বস্তুকে নয়। নতুন চেষ্টায় নতুন key, retry তে পুরোনো key।

প্রশ্ন ৩: "আমরা Kafka এর exactly-once চালু করেছি, তাই আমাদের notification service কখনো একই SMS দুইবার পাঠাবে না।" এই দাবির বিপক্ষে যুক্তি দিন।
উত্তর দেখুন আগে নিজে ভাবুন

সবাই যেভাবে ভাবে

“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 headerHTTP request এর সাথে পাঠানো একটা header, যেখানে client তার বানানো key টা দেয়।
Client-generated keyপ্রথম চেষ্টার আগেই client এর বানানো random ID। প্রথম চেষ্টা আর সব retry একই key নিয়ে যায়।
Stored responseপ্রথমবার পাঠানো response টা জমিয়ে রাখা, যাতে retry কে হুবহু একই উত্তর দেওয়া যায়।
Request hashRequest 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-onceAt-least-once delivery আর idempotent processing মিলে ফল ঠিক একবারের মতো হওয়া।
Idempotent producerKafka producer, যার retry করা duplicate message broker নিজেই বাদ দেয়।
Kafka transactionKafka তে লেখা আর offset commit একসাথে commit বা বাতিল করার ব্যবস্থা।
read_committedConsumer এর mode, যেখানে শুধু commit হওয়া transaction এর message দেখা যায়।
Inbox patternConsumer event এর ID inbox table এ লেখে, আর আসল কাজটা একই local transaction এ করে। Duplicate এলে INSERT ব্যর্থ হয়।
End-to-end idempotencyButton থেকে শেষ consumer পর্যন্ত প্রতিটা স্তরে আলাদা আলাদা duplicate পাহারা।

১২ শর্ট সামারি

  1. Timeout পেলে client জানে না request হারিয়েছে নাকি response। তাই retry নিরাপদ করার দায়িত্ব server এর। Client প্রথম চেষ্টার আগেই একটা idempotency key বানায়, আর server key এর সাথে response জমিয়ে রেখে retry কে হুবহু একই উত্তর দেয়।
  2. একসাথে আসা duplicate ঠেকাতে key টা primary key হিসেবে INSERT করতে হয়। আর dedup table এর একটা idempotency window লাগে, যেটা সবচেয়ে লম্বা retry এর চেয়ে বড়, কিন্তু অনন্ত নয়।
  3. SET, PUT আর DELETE এর মতো কাজ নিজে থেকেই idempotent। INCREMENT আর POST এর জন্য key বা UNIQUE constraint লাগে।
  4. Exactly-once delivery অসম্ভব, কিন্তু at-least-once delivery আর idempotent processing মিলে effectively-once সম্ভব। Kafka transaction শুধু Kafka এর ভেতরে এটা দেয়। বাইরের কাজের জন্য inbox pattern আর end-to-end idempotency লাগে।

১৩ মনে রাখুন