একজন ব্যবহারকারী একটি বাটনে ক্লিক করেন। রিকোয়েস্টটি আটকে যায়। দশ সেকেন্ড নীরবতা। তারা ফলব্যাক (fallback) বাটনে ক্লিক করেন। এখন একটিমাত্র উদ্দেশ্যের বিপরীতে দুটি জব (job) চলছে। এর ফলে আপনি ডুপ্লিকেট সাইড ইফেক্ট, দ্বিগুণ চার্জ এবং ডেটার এমন বিশৃঙ্খলা পাবেন যা আপনার পুরো বিকেল নষ্ট করে দেবে।
এটি কোনো ফ্রন্টএন্ড বাগ নয়। একটি ডিজেবল করা বাটন বা React-এর ডিবাউন্স টাইমার (debounce timer) আপনাকে রক্ষা করতে পারবে না। প্রথম রিকোয়েস্টটি ইতিমধ্যেই চলতে শুরু করেছিল। নেটওয়ার্কটি কেবল রেসপন্সটি গিলে ফেলেছে। যদি আপনার ব্যাকএন্ড প্রতিটি আগত রিকোয়েস্টকে একটি সম্পূর্ণ নতুন নির্দেশ হিসেবে গণ্য করে, তবে রিট্রাই (retry) করা আপনার জন্য ঝুঁকির কারণ হয়ে দাঁড়াবে। আপনাকে আপনার API ডিজাইন এবং ডেটাবেস স্কিমাতে এটি সমাধান করতে হবে।
সমাধানটি একটি সাধারণ কাঠামোগত বিভাজন দিয়ে শুরু হয়।
Job এবং Attempt-কে আলাদা করুন
একটি Job-কে ব্যবহারকারী কী চান তার একটি স্থায়ী রেকর্ড হিসেবে ভাবুন। এটি মালিক (owner), প্যারামিটার (parameters), টার্গেট প্রোভাইডার এবং সঠিক উদ্দেশ্যটি ধারণ করে। একটি Attempt হলো সেই উদ্দেশ্য পূরণ করার একটি নির্দিষ্ট প্রচেষ্টা।
একটি প্রিন্ট শপের কথা কল্পনা করুন। আপনি একটি ফাইল জমা দিলেন এবং তারা আপনাকে টিকিট #৪৫ দিল। সেই টিকিটটি হলো Job। দোকানটি ইনকজেট প্রিন্টার দিয়ে চেষ্টা করল। সেটি জ্যাম হয়ে গেল। সেটি হলো প্রথম Attempt। তারা ফাইলটি লেজার প্রিন্টারে নিল। সেটি হলো দ্বিতীয় Attempt। পুরো প্রক্রিয়া চলাকালীন টিকিট #৪৫ কখনোই পরিবর্তন হয় না। যদি দোকানটি প্রতিটি প্রিন্টারের জন্য নতুন টিকিট ইস্যু করত, তবে আপনাকে তিনবার টাকা দিতে হতো এবং তিনটি অনাকাঙ্ক্ষিত কপি পেতে হতো।
আপনার ডেটাবেসকেও এটি অনুসরণ করা উচিত। একটি টেবিলে Job থাকবে। অন্য একটি টেবিলে Attempt থাকবে। Job রো (row) অপরিবর্তিত থাকবে, আর তার নিচে Attempt-গুলো জমা হতে থাকবে।
এই বিভাজন আপনাকে নিয়ন্ত্রণ প্রদান করে। এটি আপনাকে একটি idempotency key যুক্ত করার জায়গাও দেয় যা নেটওয়ার্কের সমস্যাতেও টিকে থাকে।
প্রতিটি Job-এর জন্য একটি Idempotency Key বাধ্যতামূলক করুন
প্রতিটি POST রিকোয়েস্ট যা একটি Job তৈরি করে, তাতে অবশ্যই একটি অনন্য (unique) idempotency key থাকতে হবে। এই কী (key) ব্যবহারকারীর নিজস্ব, সেশনের নয়। Owner ID এবং কী-টি একত্রিত করুন, তারপর এই দুটি কলামের ওপর একটি ইউনিক ডেটাবেস কনস্ট্রেইন্ট (unique database constraint) প্রয়োগ করুন।
কেন ডেটাবেস কনস্ট্রেইন্ট? কারণ ইনসার্ট করার আগে অ্যাপ্লিকেশন কোডে অস্তিত্ব পরীক্ষা করা একটি রেস কন্ডিশন (race condition) তৈরি করতে পারে। দুটি হুবহু রিকোয়েস্ট একই মাইক্রোসেকেন্ডের ব্যবধানে চলে আসতে পারে। ডেটাবেসকে প্রয়োগকারী (enforcer) হতে দিন। যদি একজন ব্যবহারকারী একই owner ID এবং কী দুবার পাঠান, তবে দ্বিতীয় রিকোয়েস্টটি ইউনিক ভায়োলেশন (unique violation) শনাক্ত করবে এবং আপনি বিদ্যমান Job-টি রিটার্ন করবেন। উভয় রিকোয়েস্টই একই Job ID পাবে। কোনো ডুপ্লিকেট কাজ শুরু হবে না।
স্কোপ (scope) নিয়ে কঠোর হোন। যদি কেউ কী ব্যবহার করে কিন্তু ইনপুট পেলোড (input payload) পরিবর্তন করে, তবে একটি কনফ্লিক্ট (conflict) রিটার্ন করুন। Idempotency key-টিকে কেবল ব্যবহারকারীর সাথে নয়, বরং একটি নির্দিষ্ট উদ্দেশ্যের সাথে যুক্ত থাকতে হবে। একই কী দিয়ে ভিন্ন ইনপুট দেওয়ার অর্থ হলো ক্লায়েন্ট বিভ্রান্ত, এবং আপনার সিস্টেমের উচিত অনুমান না করে তা প্রত্যাখ্যান করা।
State Transition-কে সুরক্ষিত রাখুন
একটি Attempt হলো একটি state transition, কোনো নতুন Job নয়। আপনার API অবশ্যই একটি নতুন Attempt তৈরি করতে অস্বীকার করতে হবে যদি আগের Attempt-টি এখনও শুরুর বা অজানা (unknown) অবস্থায় আটকে থাকে।
টাইমআউট (timeout) হলো এর মূল কারণ। যখন একটি প্রোভাইডার রিকোয়েস্ট টাইমআউট হয়, ক্লায়েন্ট ব্যর্থতা দেখতে পায়, কিন্তু সার্ভার-সাইড প্রসেসটি তখনও সচল থাকতে পারে। GPU ক্লাস্টারটি এখনও আপনার ইনফারেন্স (inference) রিকোয়েস্ট নিয়ে কাজ করতে পারে। কন্টেইনারটি এখনও blob স্টোরেজে ডেটা লিখছে হতে পারে। আপনি যদি টাইমআউট হওয়া Attempt-টিকে ব্যর্থ হিসেবে চিহ্নিত করেন এবং অবিলম্বে দ্বিতীয় একটি Attempt শুরু করেন, তবে আপনি ডুপ্লিকেট সাইড ইফেক্টের ঝুঁকি নিচ্ছেন।
টাইমআউটকে একটি অজানা (unknown) অবস্থা হিসেবে বিবেচনা করুন, ব্যর্থ অবস্থা হিসেবে নয়। আগেরটি একটি টার্মিনাল স্টেটে (terminal state) না পৌঁছানো পর্যন্ত বা কোনো আউট-অফ-ব্যান্ড (out-of-band) প্রসেস দ্বারা স্পষ্টভাবে বাতিল না করা পর্যন্ত নতুন Attempt ব্লক করে রাখুন। এই বিরতিটি অস্বস্তিকর হতে পারে। এটি ব্যবহারকারীকে অপেক্ষা করতে বাধ্য করে। তবে এটি একই ডাউনস্ট্রিম রিসোর্স পরিবর্তন করার ক্ষেত্রে দুইজন ওয়ার্কারের বিশৃঙ্খলাও রোধ করে।
Compare-and-Swap দিয়ে রেস (Races) সমাধান করুন
সবচেয়ে কঠিন সমস্যাগুলো তখন দেখা দেয় যখন একাধিক Attempt শেষ হয়। হতে পারে আপনার সিস্টেম প্রাইমারি প্রোভাইডারের বিপরীতে প্রথম Attemptটি চালিয়েছে। দশ সেকেন্ড নীরবতার পর, এটি ফলব্যাক প্রোভাইডারের বিপরীতে দ্বিতীয় Attemptটি চালিয়েছে। এখন উভয় Attempt-ই সম্পন্ন হয়েছে। আপনি উভয়কেই একই Job রো-তে তাদের ফলাফল লিখতে দিতে পারেন না।
Compare-and-swap লজিক ব্যবহার করুন। Job রো-তে একটি ভার্সন নম্বর (version number) যোগ করুন। যখন একটি Attempt শেষ হয়, তখন এটি কিছু শর্তসহ একটি আপডেট চালায়:
- বর্তমান ভার্সনটি অবশ্যই সেই ভার্সনের সাথে মিলতে হবে যা Attempt-টি শুরুতে পড়েছিল।
- অন্য কোনো Attempt যেন ইতিমধ্যে ফলাফল স্লটটি দখল না করে থাকে।
- যদি উভয় শর্তই পূরণ হয়, তবে ফলাফল লিখুন এবং ভার্সনটি বৃদ্ধি করুন।
SQL-এর ভাষায়, এটি দেখতে এমন একটি আপডেট স্টেটমেন্টের মতো: WHERE id = $1 AND version = $2 AND completed_by IS NULL। যদি আপডেটটি শূন্যটি রো রিটার্ন করে, তবে বুঝতে হবে অন্য একটি Attempt ইতিমধ্যে জয়ী হয়েছে। দেরিতে আসা রিকোয়েস্টটি উপেক্ষা করতে হবে। এর ফলাফল ফেলে দিন। মার্জ (merge) করবেন না। অ্যাপেন্ড (append) করবেন না। কাজটিকে বাতিল করে দিন। একটি দেরিতে আসা ফলাফল যা আগের বিজয়ীকে ওভাররাইট (overwrite) করে দেয়, তা হলো ডেটা করাপশন (data corruption), এবং একমাত্র নিরাপদ পদক্ষেপ হলো এটি বর্জন করা।
This handles the reverse-order finish cleanly. Attempt A leaves first but returns after thirty seconds. Attempt B leaves second but returns after five seconds. Attempt B wins the compare-and-swap. Attempt A’s update touches zero rows. Your system logs the race, ignores the stale payload, and moves on.
Test the Breakpoints
You will not catch these bugs in happy-path testing. Your suite needs to target the fractures.
- Simulate a double-click. Two simultaneous POST requests with the same idempotency key must return identical job IDs.
- Send the same key with mismatched input. Expect a conflict response. The system must not silently return the existing job if the parameters differ.
- Provoke a timeout. Verify the job lands in an unknown state, not a failed state, and that the system blocks further attempts until the ambiguity clears.
- Force two attempts to finish in reverse order. Confirm that the second one to return loses, even if the first one to leave was the official primary provider.
These tests are not edge-case luxuries. They are the contract your API makes with the rest of the system.
Validate Provider Intent Before You Fail Over
If you run a multi-provider setup, you might be tempted to treat different AI models as interchangeable slots. They share the same code path, the same HTTP client, and the same JSON schema. That does not mean they behave the same.
One model might hallucinate a top-level key. Another might ignore your system prompt formatting. Schema validation catches syntax errors, but it will pass a response that your business logic cannot interpret. A provider might return valid JSON that simply does the wrong thing with your prompt template.
Run provider-specific tests before you allow automatic model switching. Confirm that the fallback model actually respects your output structure at low temperature. Verify that your prompt renders correctly through that provider’s tokenizer. Test the full round trip with real inputs. Automatic failover is only safe when you have proven that the fallback shares the same operational contract.
Keep One Job Per Intent
Fallback paths are good. Uncontrolled fallback multiplication is a bug. Every layer of your stack needs to evaluate whether it has already seen the exact task. The load balancer, the API handler, the database, and the worker must all respect the same identity.
Build your system so that retries and fallbacks surface as new attempts under one stable job. Lock the job down with a database-backed idempotency key. Guard the transitions. Race the attempts. Let exactly one win. That is how you keep a single user click from turning into a weekend of data cleanup.
