আপনি অর্ডারটি সেভ করেছেন, কিন্তু অর্ডারের আইটেমগুলো ডাটাবেসে পৌঁছায়নি। অথবা হয়তো স্টকের সংখ্যা কমে গেছে, পেমেন্ট গেটওয়ে টাইমআউট দিয়েছে, এবং এখন গ্রাহকের কার্ড থেকে টাকা কেটে নেওয়া হয়েছে কিন্তু কোনো অর্ডারের রেকর্ড নেই। এই পরিস্থিতিগুলো শুনতে সাধারণ মনে হতে পারে, কিন্তু বাস্তবে এগুলো বড় সমস্যা হয়ে দাঁড়ায়। একটি ব্যস্ত অ্যাপ্লিকেশনে এগুলো প্রতিদিনের মাথাব্যথার কারণ হয়ে ওঠে।
এর মূল কারণ প্রায় সবসময়ই একই: ডাটাবেস রাইটগুলো (writes) আলাদা এবং বিচ্ছিন্ন ঘটনা হিসেবে বিবেচনা করা হয়েছে। যখন একটি ব্যর্থ হয়, অন্যগুলো আগের অবস্থাতেই থেকে যায়। এর সমাধান হলো ডাটাবেস ট্রানজ্যাকশন (database transaction), আর Laravel-এ এর জন্য টুলটি হলো DB::transaction()।
একটি ট্রানজ্যাকশন আসলে কী নিশ্চিত করে
একটি ডাটাবেস ট্রানজ্যাকশন একাধিক অপারেশনকে একটি একক কাজের ইউনিটে (unit of work) আবদ্ধ করে। ডাটাবেস ইঞ্জিন গ্যারান্টি দেয় যে এর ভেতরে থাকা সবকিছু হয় স্থায়ীভাবে কমিট (commit) হবে অথবা সম্পূর্ণভাবে রোলব্যাক (rollback) হবে। এখানে মাঝামাঝি কোনো অবস্থা নেই।
একটি ব্যাংক ট্রান্সফারের কথা ভাবুন। সিস্টেমকে অবশ্যই একটি অ্যাকাউন্ট থেকে টাকা কাটতে হবে (debit) এবং অন্য একটি অ্যাকাউন্টে জমা করতে হবে (credit)। যদি ডেবিট করার পর ক্রেডিট করতে ব্যর্থ হয়, তবে টাকাটি শূন্যে হারিয়ে যাবে না। ব্যাংক ডেবিটটি রিভার্স বা পূর্বাবস্থায় ফিরিয়ে দেয়। এই রিভার্সালটিই হলো রোলব্যাক। যদি উভয় ধাপই সফল হয়, তবে ট্রান্সফারটি কমিট হয়, যার মানে হলো নতুন ব্যালেন্স স্থায়ীভাবে সেভ হয়েছে।
এই 'সব অথবা কিছুই না' (all-or-nothing) আচরণটিই আপনার ডেটার সামঞ্জস্য বা কনসিস্টেন্সি (consistency) বজায় রাখে। এটি ছাড়া, আংশিক ব্যর্থতার কারণে আপনার টেবিলগুলোতে অর্ধেক লেখা রেকর্ড ছড়িয়ে ছিটিয়ে থাকবে, এবং সেই রেকর্ডগুলো আপনার ডাটাবেসে অকেজো হয়ে থাকবে কারণ কোনো স্বয়ংক্রিয় প্রক্রিয়া জানে না কীভাবে সেগুলো নিরাপদে পরিষ্কার করতে হয়।
Laravel যেভাবে এটি পরিচালনা করে
সাধারণ SQL-এ, আপনাকে নিজেই BEGIN, COMMIT, এবং ROLLBACK লিখতে হতো এবং প্রতিটি সম্ভাব্য ত্রুটি ধরার (catch) কথা মনে রাখতে হতো যাতে কোনো ট্রানজ্যাকশন মাঝপথে ঝুলে না থাকে। Laravel এই সমস্ত জটিল কাজকে একটি মাত্র মেথডের মাধ্যমে সহজ করে দেয়।
আপনি DB::transaction()-এ একটি ক্লোজার (closure) পাস করেন। Laravel ট্রানজ্যাকশন শুরু করে, আপনার কোডটি চালায়, এবং যদি ক্লোজারটি কোনো এক্সেপশন (exception) ছাড়াই শেষ হয়, তবে এটি স্বয়ংক্রিয়ভাবে কমিট করে। যদি কিছু ব্যর্থ হয়, Laravel সেই এক্সেপশনটি ধরে ফেলে, সবকিছু রোলব্যাক করে এবং ত্রুটিটি পুনরায় থ্রো (rethrow) করে যাতে আপনার লগিং এবং এরর হ্যান্ডলিং প্রত্যাশা অনুযায়ী কাজ করতে পারে।
use Illuminate\Support\Facades\DB;
DB::transaction(function () {
$order = Order::create([/* ... */]);
foreach ($cart->items as $item) {
OrderItem::create([
'order_id' => $order->id,
'product_id' => $item->product_id,
'quantity' => $item->quantity,
]);
Product::find($item->product_id)
->decrement('stock', $item->quantity);
}
Payment::create([
'order_id' => $order->id,
'amount' => $cart->total,
'status' => 'completed',
]);
});
যদি কোনো ফরেন কি (foreign key) না থাকার কারণে বা ডাটাবেস কানেকশন বিচ্ছিন্ন হওয়ার কারণে পেমেন্ট ইনসার্ট ব্যর্থ হয়, তবে অর্ডার, অর্ডারের আইটেম এবং স্টকের পরিবর্তন—সবকিছুই বাতিল হয়ে যাবে। ফলে আপনার ইনভেন্টরি কোনো কারণ ছাড়াই হারিয়ে যাবে না, অথবা এমন কোনো অর্ডারের জন্য শিপমেন্টের প্রয়োজন পড়বে না যার পেমেন্ট করা হয়নি।
যেখানে এটি আপনাকে বাঁচাবে
কিছু ওয়ার্কফ্লো (workflow) সম্পূর্ণভাবে ট্রানজ্যাকশনাল নিরাপত্তার ওপর নির্ভর করে। চেকআউট উদাহরণটি সবচেয়ে স্পষ্ট, তবে এই প্যাটার্নটি সবখানেই দেখা যায়।
ইউজার রেজিস্ট্রেশন। একটি ইউজার রো (row) তৈরি করা, তারপর একটি প্রোফাইল রো, এবং তারপর ডিফল্ট সেটিংস। যদি ভ্যালিডেশনের কোনো সমস্যার কারণে প্রোফাইল ইনসার্ট ব্যর্থ হয়, তবে প্রোফাইলহীন একজন ইউজার একটি 'ঘোস্ট অ্যাকাউন্ট' (ghost account) হয়ে পড়বে। যে কোনো পেজ যা ধরে নেয় যে প্রতিটি ইউজারের একটি প্রোফাইল আছে, সেটি ক্রাশ করবে বা ত্রুটিপূর্ণ UI দেখাবে।
বাল্ক ইমপোর্ট (Bulk imports)। একটি CSV আপলোড যা ৫০টি রেকর্ড ইনসার্ট করছে, তা যেন ২৫টি রেকর্ড রেখে না যায় কারণ ২৬ নম্বর রো-তে একটি ভুল তারিখ ছিল। একটি ট্রানজ্যাকশনে এই ব্যাচটিকে মুড়িয়ে দিলে (wrapping) পুরো ইমপোর্টটি একটি একক পরিষ্কার ইউনিট হিসেবে ব্যর্থ হতে পারে। এর ফলে অ্যাডমিনকে ঘণ্টার পর ঘণ্টা কোন রো-গুলো আংশিকভাবে লিক হয়েছে তা খুঁজতে হবে না, বরং তিনি ফাইলটি ঠিক করে আবার চেষ্টা করতে পারবেন।
ইনভেন্টরি এবং অ্যাকাউন্টিং। যখনই একটি টেবিল কোনো ভৌত সম্পদ (physical resource) ট্র্যাক করে এবং অন্য একটি টেবিল টাকা বা ক্রেডিট ট্র্যাক করে, তখন এই দুটিকে একসাথে পরিবর্তিত হতে হবে। এদের আলাদা করলে অডিট রিপোর্টে অমিল দেখা দেবে এবং রিপোর্টগুলো ভুল তথ্য দেবে।
যখন আপনাকে ম্যানুয়ালি পরিচালনা করতে হবে
ক্লোজার পদ্ধতিটি বেশিরভাগ ক্ষেত্রে কাজ করে, কিন্তু মাঝে মাঝে আপনার আরও নিয়ন্ত্রণের প্রয়োজন হতে পারে। একটি সার্ভিস ক্লাসের ভেতরে জটিল কন্ডিশনাল লজিক, অথবা রানটাইমে সিদ্ধান্ত নেওয়া যে কমিট করবেন কি না, তখন একটি একক ক্লোজার ব্যবহার করা অস্বস্তিকর মনে হতে পারে। সেই মুহূর্তে, আপনি নিজেই ট্রানজ্যাকশন পরিচালনা করতে পারেন:
DB::beginTransaction();
try {
// Run your operations
$order = Order::create([/* ... */]);
// ... more work ...
if ($someBusinessRulePasses) {
DB::commit();
} else {
DB::rollBack();
}
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
catch ব্লকের ভেতরের ক্রমটি লক্ষ্য করুন: প্রথমে রোলব্যাক করুন, তারপর থ্রো করুন। যদি আপনি রোলব্যাক করার আগে থ্রো করেন, তবে কানেকশনে ট্রানজ্যাকশনটি খোলা থেকে যাবে। এটি রো (row) লক করে দিতে পারে, অন্যান্য কুয়েরিকে ডেডলক (deadlock) করতে পারে, অথবা আপনার কানেকশন পুল শেষ করে দিতে পারে। ম্যানুয়াল ট্রানজ্যাকশন শক্তিশালী, কিন্তু এটি পরিষ্কার করার দায়িত্ব আপনার ওপর ছেড়ে দেয়।
ট্রানজ্যাকশনের বাইরে সাইড ইফেক্ট (Side Effects) রাখুন
এটি এমন একটি নিয়ম যা প্রোডাকশনে টিমগুলোকে বিপদে ফেলে। একটি ট্রানজ্যাকশন শুধুমাত্র ডাটাবেসের কাজগুলোই বাতিল করতে পারে। এটি কোনো ইমেল পুনরায় ফেরত পাঠাতে পারে না, ক্লাউড স্টোরেজ থেকে ফাইল মুছে ফেলতে পারে না, অথবা পেমেন্ট API-এর মাধ্যমে কোনো পেমেন্ট রিফান্ড করতে পারে না।
আপনি যদি ট্রানজ্যাকশন ক্লোজারের ভেতরে একটি Mail::send() কল করেন এবং দুই লাইন পরে ডাটাবেস রোলব্যাক হয়, তবুও সেই ইমেলটি গ্রাহকের ইনবক্সে পৌঁছে যাবে। প্রাপকের কাছে এখন এমন একটি ইনভয়েস থাকবে যা আপনার সিস্টেমে বিদ্যমান নেই। একই কথা Slack নোটিফিকেশন, S3-তে ফাইল আপলোড, বা webhook ডিসপ্যাচের ক্ষেত্রেও প্রযোজ্য।
সঠিক ক্রমটি হলো:
- ট্রানজ্যাকশনটি সম্পন্ন করুন এবং আপনার প্রয়োজনীয় যেকোনো ID বা ফলাফল সংগ্রহ করুন।
- তারপর কেবল বাহ্যিক সাইড ইফেক্টগুলো ট্রিগার করুন।
উদাহরণস্বরূপ, কনফার্মেশন ইমেলটি কমিট করার পরে কিউ (queue) করুন, এর ভেতরে নয়:
$order = DB::transaction(function () {
// database work only
return Order::create([/* ... */]);
});
// Side effects happen after the database state is solid
OrderConfirmationJob::dispatch($order);
ট্রানজ্যাকশনের বাইরে বাহ্যিক কলগুলো রাখার দ্বিতীয় কারণ হলো: সময়। একটি ট্রানজ্যাকশন লক (lock) ধরে রাখে এবং একটি ডাটাবেস কানেকশনকে ব্যস্ত রাখে। ট্রানজ্যাকশনের ভেতরে থাকা অবস্থায় Stripe API রেসপন্সের জন্য তিন সেকেন্ড অপেক্ষা করা মানে হলো তিন সেকেন্ডের অনাবশ্যক লক টাইম। ট্রানজ্যাকশনকে সংক্ষিপ্ত এবং দ্রুত রাখুন।
কেন এই বাগটি লুকিয়ে থাকে
একটি সিঙ্গেল ইউজার এবং লোকাল ডাটাবেস সম্বলিত ডেভেলপমেন্ট মেশিনে, সম্পর্কিত ইনসার্টগুলো (inserts) প্রায় সবসময়ই সফল হয়। নেটওয়ার্ক স্থিতিশীল থাকে, ডিস্ক কখনো পূর্ণ হয় না এবং কোনো প্রতিদ্বন্দ্বী লোড (competing load) থাকে না। কোডটি সঠিক মনে হয় কারণ এটি সাধারণত কাজ করে।
প্রোডাকশন পরিবেশ ভিন্ন। দুইজন গ্রাহক ঠিক একই মিলিসেকেন্ডে অর্ডার সাবমিট করেন। একটি ডেপ্লয়মেন্টের সময় কাজ চলাকালীন একটি কিউ ওয়ার্কার (queue worker) রিস্টার্ট নেয়। একটি পেমেন্ট প্রোভাইডার ৩০ সেকেন্ডের জন্য টাইম-আউট হয়। ট্রানজ্যাকশন ছাড়া, এই ঘটনাগুলো অরফান রেকর্ড (orphan records) এবং অমিল টোটাল তৈরি করে যা খুঁজে বের করা অত্যন্ত কষ্টসাধ্য। সবচেয়ে খারাপ বিষয় হলো, স্ট্যান্ডার্ড ফিচার টেস্টগুলো এগুলো খুব কমই ধরতে পারে, কারণ এই ফেইলিওর মোডটি টাইমিং-নির্ভর এবং ইনফ্রাস্ট্রাকচারের সাথে যুক্ত, কোনো সাধারণ লজিক এরর নয়।
সমাধানটি খুব জটিল কিছু নয়। এটি যান্ত্রিক। আপনি যখন দুই বা ততোধিক সম্পর্কিত রাইট (write) অপারেশন দেখবেন, তখন সেগুলোকে একটি ট্রানজ্যাকশনে মুড়িয়ে (wrap) ফেলুন। সময়ের সাথে সাথে, এটি একটি রিকোয়েস্ট ভ্যালিডেশনের মতোই স্বয়ংক্রিয় হয়ে উঠবে।
এটিকে একটি সহজাত অভ্যাস করে তুলুন
DB::transaction() জটিলতা বাড়ায় না। এটি পরে আংশিক ডেটা পরিষ্কার করার চেষ্টার যে লুকানো জটিলতা থাকে, তা দূর করে। যদি ডাটাবেস অপারেশনের একটি গ্রুপ একসাথে সম্পর্কিত হয়, তবে শুরু থেকেই সেটিকে সেভাবেই বিবেচনা করুন। আপনার অ্যাপ্লিকেশন লোডের মধ্যেও সঠিক থাকবে, এরর লগগুলো পরিষ্কার থাকবে এবং আপনার ডাটাবেস অর্ধেক সম্পন্ন রেকর্ডের কবরস্থান হয়ে উঠবে না।
