Skip to content
Rafe Uddaraj

AI Agent কেন Double Charge করে: Crash-Safe Tool Execution Under the Hood

18 min readBanglaRead in English
AI AgentsDistributed SystemsIdempotencyDurable ExecutionTemporal
On this page

ধরুন আপনার team একটা Subscription Renewal Agent বানিয়েছে। কাজটা সোজা: customer এর card charge করো, তারপর তার account এ entitlement grant করো। এক সকালে support team জানাল, একজন customer এর card থেকে একই renewal এর জন্য দুইবার টাকা কেটেছে। প্রথমে সবাই ধরে নিল, Agent বোধহয় hallucinate করেছে। কিন্তু Log খুলে দেখা গেল, Agent আসলে ঠিক সেটাই করেছে যা framework তাকে শিখিয়ে রেখেছিল: Timeout পেলে retry করো। Model এর কোনো দোষ ছিল না, দোষটা ছিল retry এর জায়গায়।

এই ধরনের incident এর একটা অদ্ভুত দিক আছে। Payment provider এর Idempotency Key পুরোপুরি ঠিক থাকলেও duplicate charge হতে পারে, কারণ retry যদি Key যে layer এ আছে তার এক layer উপরে হয়, তাহলে provider এর চোখে সেটা একদম নতুন একটা request। ইন্টারনেটে এ নিয়ে অনেক ছোট ছোট পোস্ট আছে, যেগুলো বলে "প্রতিটা write tool এ Idempotency Key বসান"। কথাটা ঠিক, কিন্তু অসম্পূর্ণ। কোন Key, কে বানাবে, কোথায় জমা থাকবে, আর crash ঠিক কোন মুহূর্তে হলে কী ঘটবে, এই প্রশ্নগুলোর উত্তর না জানলে Key বসিয়েও আপনি নিরাপদ নন।

এই ব্লগের মূল বক্তব্য একটাই: Idempotency একটা single call এর property না, এটা পুরো execution path এর property। আমরা কোনো theory ধার করে বসাব না। একটা ছোট Agent Runtime বানিয়ে প্রতিটা critical মুহূর্তে process kill করব, Database এ duplicate side effect গুনব, তারপর ছয় ধাপের একটা Guard Ladder বেয়ে উঠব: Naive Resume থেকে শুরু করে Durable Execution, Atomic Commit আর UNKNOWN State পর্যন্ত। প্রতিটা ধাপ শুরু হবে আগের ধাপ ঠিক কোথায় ভাঙে সেটা দেখিয়ে। শেষে আপনার হাতে থাকবে একটা Decision Flowchart আর একটা Production Checklist। এটা আমার আগের Exactly Once Delivery আর Distributed Locks লেখার ধারাবাহিকতা, তাই ওই দুটো পড়া থাকলে যোগসূত্র সহজে ধরতে পারবেন।

Table of Contents

Step 1: The Incident Anatomy

প্রথমে ঘটনার ক্রমটা পরিষ্কার করে নিই। Agent প্রথমে charge tool কে ডাকে, tool wrapper Stripe কে request পাঠায়, আর Stripe card থেকে টাকা কেটে নেয়। কিন্তু Response ফেরার পথে network error হয়, ফলে wrapper কখনো জানতেই পারে না যে charge সফল হয়েছে। Wrapper একটা Error সহ Agent এর কাছে ফেরত দেয়। Framework এর default retry policy তখন পুরো step টা আবার চালায়, আর সেই step এর ভেতরে charge এবং entitlement grant দুটোই ছিল। ফলে Stripe এ দ্বিতীয় charge যায়, তারপর grant হয়।

Double charge incident এর timeline, যেখানে response হারিয়ে যাওয়ার পর পুরো step retry হচ্ছে
Double charge incident এর timeline, যেখানে response হারিয়ে যাওয়ার পর পুরো step retry হচ্ছে

একটা বিষয় খেয়াল করুন: Stripe এর কাছে দ্বিতীয় request টা কোনো retry ছিল না, ছিল একটা সম্পূর্ণ নতুন charge এর অনুরোধ। এই ধরনের incident এ সাধারণত Key বানানো হয় tool wrapper এর ভেতরে, প্রতিটা call এ নতুন করে। Retry ঘটে wrapper এর উপরের layer এ, তাই wrapper আবার নতুন Key বানায়। Idempotency layer পুরোপুরি ঠিকঠাক কাজ করছিল, কিন্তু সে ভুল প্রশ্নের উত্তর দিচ্ছিল। এ ধরনের একটা real post mortem আপনি এই লেখায় (opens in a new tab) পাবেন, যেখানে charge আর entitlement grant দুটোই duplicate হয়েছিল, কারণ retry ছিল Key এর এক layer উপরে।

Hallucination না, Retry Boundary

Double charge দেখতে ঠিক দুইটা সফল tool call এর মতো। তাই Agent এর reasoning ঘেঁটে কারণ খুঁজে লাভ নেই। কারণটা লুকিয়ে থাকে retry কোন layer এ ঘটছে আর Key কোন layer এ আছে, সেই দুইয়ের অমিলে।

Step 2: Intent vs Attempt, The Mental Model

একটা Restaurant এর অ্যানালজি দিয়ে শুরু করি। ওয়েটার আপনার অর্ডার নিয়ে কিচেনে গেল। কিচেন রান্না শুরু করল, কিন্তু ওয়েটার ফেরার পথে কিচেনের সাথে তার ফোন সংযোগ কেটে গেল। ওয়েটার জানে না অর্ডার পৌঁছেছে কিনা, তাই সে আবার অর্ডার দিল, আর আপনার টেবিলে দুইটা খাবার এল। সমাধান হলো প্রতিটা অর্ডারে একটা টোকেন নম্বর লিখে দেওয়া, যাতে কিচেন বলতে পারে "এই নম্বর আমি আগেই পেয়েছি"। এই টোকেন নম্বরই Idempotency Key। কিন্তু একটা প্রশ্ন থেকে যায়: ওয়েটার যদি নিজের খাতাটাই হারিয়ে ফেলে, তাহলে সে কি একই টোকেন নম্বর আবার লিখতে পারবে? ব্লগের বাকি অংশ আসলে এই প্রশ্নেরই উত্তর।

এই অ্যানালজি থেকে দুইটা শব্দ আলাদা করে নেওয়া জরুরি: Intent আর Attempt। Model যখন একটা tool call emit করে, সেটা একটা Intent, মানে "এই customer কে ২৫০ ডলার refund করো" এই সিদ্ধান্ত। Network error, timeout বা crash এর পর যতবার সেই কাজ আবার চেষ্টা করা হয়, প্রতিটা একটা Attempt। Idempotency এর পুরো কাজ হলো অনেকগুলো Attempt কে একটা Intent এ collapse করা। যে Key প্রতি Attempt এ নতুন বানানো হয়, সেটা Attempt এর পরিচয় বহন করে, Intent এর না, তাই সেই Key কোনো কাজেই আসে না।

একই Intent এর তিনটা Attempt একটা Key দিয়ে একটা Side Effect এ মিলছে, আর ভুল সংস্করণে প্রতি Attempt এ নতুন UUID তিনটা Side Effect বানাচ্ছে
একই Intent এর তিনটা Attempt একটা Key দিয়ে একটা Side Effect এ মিলছে, আর ভুল সংস্করণে প্রতি Attempt এ নতুন UUID তিনটা Side Effect বানাচ্ছে

Delivery semantics নিয়ে বিস্তারিত আগের ব্লগে লিখেছি, তাই এখানে শুধু মূল কথাটুকু। Network এ সৎ guarantee আছে দুটো: At-most-once আর At-least-once। Exactly-once আসলে At-least-once delivery আর idempotent processing এর যোগফল, আলাদা কোনো জাদু না। Temporal এর মতো Durable Execution engine এও এই কথা খাটে: Workflow একবারই complete হয় এবং প্রতিটা Activity এর result একবারই record হয়, কিন্তু Activity নিজে at-least-once চলে। Record হওয়া আর execute হওয়া দুটো আলাদা জিনিস, আর duplicate side effect এর জন্ম ঠিক এই ফাঁকে।

একটা প্রশ্ন সবসময় নিজেকে করুন

আমার system এ "result record হওয়া" আর "effect ঘটা" কি একই মুহূর্তে ঘটে? যদি না ঘটে, তাহলে ওই দুই মুহূর্তের মাঝখানে একটা crash window আছে, আর সেখানেই আপনার duplicate লুকিয়ে আছে।

Step 3: Anatomy of a Tool Call and the Four Crash Windows

Agent loop এর ভেতরে একটা tool call এর জীবনচক্র মোটামুটি এরকম: LLM একটা tool বাছে, runtime সেই intent log করে, tool provider এর কাছে effect ঘটায়, runtime completion record লেখে, তারপর result LLM এর কাছে ফেরত যায়। এই পাঁচটা ঘটনার মাঝখানে চারটা ফাঁক আছে, আর আমরা সেগুলোকে বলব W1 থেকে W4।

Windowফাঁকটা কোথায়Crash হলে কী অবস্থা
W1LLM এর সিদ্ধান্তের পরে, intent log হওয়ার আগেকিছুই ঘটেনি
W2Intent log হয়েছে, effect provider এ পৌঁছায়নিIntent আছে, effect নেই
W3Effect commit হয়েছে, completion record লেখা হয়নিEffect আছে, কোনো প্রমাণ নেই
W4Completion record লেখা হয়েছে, LLM কে জানানো হয়নিসব ঠিক, শুধু পরের step বাকি
পাঁচটা ঘটনার timeline যেখানে W1 থেকে W4 চারটা crash window চিহ্নিত, W3 কে Danger Zone হিসেবে হাইলাইট করা
পাঁচটা ঘটনার timeline যেখানে W1 থেকে W4 চারটা crash window চিহ্নিত, W3 কে Danger Zone হিসেবে হাইলাইট করা

W3 সবচেয়ে ভয়ংকর, কারণ সেখানে external service ইতিমধ্যে commit করে ফেলেছে, কিন্তু আমাদের local record এ তার কোনো চিহ্ন নেই। Crash এর পর process আবার উঠলে সে দেখে intent লেখা আছে অথচ completion নেই, আর সে কিছুতেই বলতে পারে না effect ঘটেছিল কিনা। এই অনিশ্চয়তাকে আমরা বলব UNKNOWN state। Naive resume এখানে সবসময় ধরে নেয় effect ঘটেনি, আর ঠিক সেই ধারণাই duplicate এর জন্ম দেয়। Durable Agent Runtime (opens in a new tab) নামের একটা open source project এ ৬০টা crash point পরীক্ষা করা হয়েছিল। এর মধ্যে ৩৪টা crash পড়েছিল effect এর পরে, আর naive baseline সেগুলোর ৫২.৯% এ কাজটা আবার করে ফেলেছে, মোট ৪৪টা বাড়তি action।

Timeout মানে Failure না

Local timeout দেখে কখনো সিদ্ধান্ত নেবেন না যে remote effect ঘটেনি। Timeout শুধু বলে "আমি উত্তর পাইনি", কাজ হয়েছে কিনা সেটা বলে না। এই একটা ভুল ধারণা থেকেই বেশিরভাগ double charge এর জন্ম।

ব্যাপারটা আরও জটিল হয় কারণ একটা Agent এ retry একটা layer এ ঘটে না। HTTP client নিজে retry করে, tool wrapper retry করে, agent framework step retry করে, আর LLM নিজেও error দেখে আবার একই tool call emit করে। প্রতিটা layer আলাদাভাবে যুক্তিসঙ্গত, কিন্তু একসাথে তারা গুণ হয়ে যায়। প্রতি layer এ তিনবার করে retry হলে একটা মাত্র Intent এর জন্য সর্বোচ্চ ৮১টা call যেতে পারে। এখন বুঝতে পারছেন কেন Key রাখার জায়গাটা এত গুরুত্বপূর্ণ: Key যদি সবচেয়ে ভেতরের layer এ থাকে আর retry যদি সবচেয়ে বাইরের layer এ হয়, তাহলে বাইরের layer প্রতিবার নতুন Intent এর মতো আচরণ করবে।

চারটা retry layer উপর থেকে নিচে সাজানো, প্রতিটা layer এ তিনবার retry হলে মোট ৮১টা call এর সম্ভাবনা
চারটা retry layer উপর থেকে নিচে সাজানো, প্রতিটা layer এ তিনবার retry হলে মোট ৮১টা call এর সম্ভাবনা

Step 4: Building the Crash Injection Lab

Theory যথেষ্ট, এবার হাতে-কলমে দেখব। Lab এর design খুব সহজ: একটা scripted LLM (যাতে আচরণ deterministic থাকে), তিনটা tool (charge, send_email, grant_entitlement), আর একটা Side-Effect Ledger Database যেখানে প্রতিটা বাস্তব effect একটা row হিসেবে পড়ে। একটা Crash Injector নির্দিষ্ট window তে process কে SIGKILL করে, তারপর আমরা resume চালাই। সবশেষে একটা Oracle crash-free run এর ledger এর সাথে আমাদের ledger তুলনা করে duplicate এবং lost effect গোনে।

Lab architecture: Scripted LLM, Runtime, Tool Wrappers, Ledger DB, Crash Injector, Reference Run আর Oracle এর সংযোগ
Lab architecture: Scripted LLM, Runtime, Tool Wrappers, Ledger DB, Crash Injector, Reference Run আর Oracle এর সংযোগ

Crash Injector টা আশ্চর্য রকম ছোট। সে শুধু একটা environment variable দেখে, মিলে গেলে নিজের process কে মেরে ফেলে। Runtime এর প্রতিটা ফাঁকে আমরা একটা crashPoint call বসাই।

TypeScript
// crash.ts
export type CrashWindow = "W1" | "W2" | "W3" | "W4";
export function crashPoint(window: CrashWindow) {
if (process.env.CRASH_AT === window) {
console.error(`[lab] simulated crash at ${window}`);
process.kill(process.pid, "SIGKILL"); // no cleanup, no finally blocks, like a real crash
}
}
TypeScript
// runtime.ts (the four windows are exactly the four gaps in the tool call lifecycle)
async function runStep(run: Run, call: ToolCall) {
crashPoint("W1"); // nothing happened yet
await appendEvent(run.id, "TOOL_INVOKED", call); // intent is durable now
crashPoint("W2"); // intent logged, no effect yet
const result = await tools[call.tool](call.args, call); // effect reaches the provider
crashPoint("W3"); // effect done, no record
await appendEvent(run.id, "TOOL_COMMITTED", { callId: call.id, result });
crashPoint("W4"); // recorded, LLM not told yet
return result;
}

Ledger আর Oracle এর জন্য আমাদের একটাই table আর একটাই query লাগে। Tool এর বাস্তব effect, মানে "টাকা কাটা" বা "email যাওয়া", প্রতিবার এই table এ একটা row যোগ করে। Oracle শুধু গোনে প্রতিটা tool কতবার চলেছে।

SQL
CREATE TABLE side_effects (
id BIGSERIAL PRIMARY KEY,
run_id TEXT NOT NULL,
tool TEXT NOT NULL,
detail JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Oracle: expected result is exactly one row per tool
SELECT tool, count(*) AS times
FROM side_effects
WHERE run_id = $1
GROUP BY tool;

প্রতিটা Guard Level আর প্রতিটা Window এর জন্য আমরা একটা করে crash চালাই, কোনো sampling ছাড়া। একটা ছোট shell loop পুরো matrix চালিয়ে দেয়: প্রথম run SIGKILL এ মরে, দ্বিতীয় run resume করে, তারপর Oracle ফলাফল CSV তে লেখে।

Terminal
for level in 0 1 2 3 4 5; do
for w in W1 W2 W3 W4; do
pnpm lab:reset
LEVEL=$level CRASH_AT=$w pnpm lab:run || true # first run dies by SIGKILL
LEVEL=$level pnpm lab:run # resume run, no crash
pnpm lab:oracle --level $level --window $w >> results/summary.csv
done
done

আমরা মাপব চারটা জিনিস: Duplicate Side Effect Count, Lost Side Effect Count, Resume Time, আর বাড়তি LLM call। Seed fixed থাকবে, পুরো lab এক কমান্ডে চলবে, আর ফলাফল CSV হিসেবে repo তে commit করা থাকবে।

Step 5: The Guard Ladder

এবার মূল কাজ। আমরা ছয়টা Guard Level একটার পর একটা বসাব। প্রতিটা Level এর একটা নির্দিষ্ট ক্ষমতা আছে আর একটা নির্দিষ্ট ভাঙার জায়গা আছে। পদ্ধতিটা সহজ: Level N এ উঠে আমরা প্রথমে দেখাব আগের Level কোথায় ব্যর্থ হয়, তারপর সেই ফাঁক বন্ধ করব, আর তারপর দেখব নতুন Level নিজে কোথায় ভাঙে।

ছয় ধাপের Guard Ladder যেখানে প্রতিটা Level কী বন্ধ করে আর কীভাবে ভাঙে তা পাশাপাশি দেখানো
ছয় ধাপের Guard Ladder যেখানে প্রতিটা Level কী বন্ধ করে আর কীভাবে ভাঙে তা পাশাপাশি দেখানো

Level 0: Naive Resume

কোনো guard নেই। Crash এর পর runtime কোনো log দেখে না, পুরো plan শুরু থেকে আবার চালায়। এটা সবচেয়ে সাধারণ অবস্থা, কারণ বেশিরভাগ prototype এভাবেই লেখা হয়।

TypeScript
async function resumeNaive(run: Run) {
// no event log, no key: start the whole plan again
return runAgent(run.input);
}

W1 আর W2 তে crash হলে কোনো সমস্যা নেই, কারণ তখনো কোনো effect ঘটেনি। কিন্তু W3 আর W4 এ crash হলে effect ঘটে গেছে, আর resume সেটা আবার ঘটায়। এখানেই duplicate এর জন্ম।

Level 1: Hash of Args as Key

প্রথম সহজ সমাধান: tool এর arguments এর hash কে Idempotency Key বানানো। শুনতে যুক্তিসঙ্গত, আর যতক্ষণ args হুবহু এক থাকে, কাজও করে। সমস্যাটা শুরু হয় ঠিক তখন, যখন retry আসে LLM এর হাত ধরে। LLM retry তে আগের HTTP request replay করে না। সে error সহ নতুন context নিয়ে আবার reason করে নতুন একটা tool call বানায়, আর তখন args এর শব্দ বদলে যায়, field এর ক্রম বদলে যায়, কখনো নতুন field এসে যায়।

TypeScript
import { createHash } from "node:crypto";
const sha = (v: unknown) =>
createHash("sha256").update(JSON.stringify(v)).digest("hex");
// the first attempt
const first = { customer: "cus_42", amount: 25000, currency: "usd" };
// the retry: same intent, but the model re-reasoned and reworded the args
const second = { currency: "usd", amount: 250, customer: "cus_42", unit: "dollars" };
console.log(sha(first) === sha(second)); // false, so two keys, so two charges

নিঃশব্দ Failure

এই ব্যর্থতায় কোনো error আসে না। Dedup check সফলভাবে পাশ হয়ে যায়, কারণ key টা সত্যিই নতুন। শুধু effect টা দ্বিতীয়বার চলে। Level 1 এর পরীক্ষা সবসময় সবুজ থাকবে, যতক্ষণ না আপনার model সত্যিকারের retry তে args reword করছে।

Level 2: Stable Key from Durable State

সমাধানের মূল নীতি: Key কখনো Model এর output থেকে আসবে না, আসবে Runtime এর durable state থেকে। Model যে tool call block emit করে, তার নিজের একটা ID থাকে। Orchestrator যখন একই turn retry করে, সে একই block ID আবার replay করে, তাই এই ID স্থিতিশীল। আমরা এর সাথে runId আর step মিলিয়ে Key বানাই। কিন্তু একটা শর্ত আছে, আর সেটা অনেকেই মিস করেন: intent কে আগে log এ লিখে ফেলতে হবে (W1 পেরোনোর আগেই)। না লিখলে crash এর পর LLM আবার ডাকা হবে, সে নতুন block ID দেবে, আর আপনি আবার নতুন Key পেয়ে যাবেন।

TypeScript
import { createHash } from "node:crypto";
export function idemKey(runId: string, step: number, toolCallId: string) {
return createHash("sha256").update(`${runId}:${step}:${toolCallId}`).digest("hex");
}
// canonical JSON: sorted keys, so field order never matters
function canonical(v: unknown): string {
if (Array.isArray(v)) return `[${v.map(canonical).join(",")}]`;
if (v && typeof v === "object") {
const o = v as Record<string, unknown>;
return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${canonical(o[k])}`).join(",")}}`;
}
return JSON.stringify(v);
}
// the fingerprint is NOT the key. It only detects "same key, different args"
export const fingerprint = (args: unknown) =>
createHash("sha256").update(canonical(args)).digest("hex");
বাম পাশে args এর hash বদলে যাওয়ায় দুইটা Key আর দুইটা Effect, ডান পাশে persisted intent থেকে একটাই স্থির Key আর একটাই Effect
বাম পাশে args এর hash বদলে যাওয়ায় দুইটা Key আর দুইটা Effect, ডান পাশে persisted intent থেকে একটাই স্থির Key আর একটাই Effect

Provider যদি Idempotency Key সাপোর্ট করে, তাহলে এই Key টা তার কাছে পাঠিয়ে দিন। তাহলে W3 তে crash হলেও resume এ একই Key যাবে, আর provider আগের ফলাফলই ফেরত দেবে।

TypeScript
const intent = await stripe.paymentIntents.create(
{
amount: 25000,
currency: "usd",
customer: "cus_42",
payment_method: pm,
confirm: true,
off_session: true,
metadata: { idem_key: key }, // makes later lookup possible (Level 5)
},
{ idempotencyKey: key }, // the provider dedupes on this
);

Key এর সাথে তিনটা নিয়ম

প্রথমত, Key কে সবসময় caller অনুযায়ী scope করুন, যেমন (account_id, key) জোড়া, কখনো global না। দ্বিতীয়ত, একই Key দিয়ে ভিন্ন args এলে fail fast করুন (এজন্যই fingerprint)। তৃতীয়ত, TTL আপনার সবচেয়ে লম্বা agent run এর চেয়ে বড় রাখুন। Stripe এ Key মোটামুটি ২৪ ঘণ্টা থাকে, আর কোনো Key দিয়ে 400 error এলে retry তেও একই 400 ফেরত আসে, তাই recover করতে হলে নতুন Key লাগে।

Level 2 এর দুর্বলতা পরিষ্কার: এটা শুধু তখনই কাজ করে যখন provider Key মানে। Provider যদি Key উপেক্ষা করে, তাহলে W3 তে crash এর পর আপনার local record এ কিছু নেই, আর provider এ আবার request গেলে নতুন effect ঘটে যায়।

Level 3: Durable Execution

এবার আরেক ধাপ উপরে উঠি। Temporal এর মতো Durable Execution engine প্রতিটা Workflow এর প্রতিটা ধাপ একটা Event History তে লেখে। Worker crash করলে আরেকটা Worker সেই History replay করে state আবার তৈরি করে নেয়। যে Activity আগেই complete হয়েছে, তার result History থেকে পড়া হয়, Activity আবার চলে না। এই কারণেই Workflow code কে deterministic হতে হয়: Workflow এর ভেতরে Date.now() বা random ডাকা যায় না, কারণ replay তে ভিন্ন মান এলে History আর code মিলবে না। বদলে workflow.now() আর workflow.uuid4() এর মতো Temporal এর নিজস্ব primitive ব্যবহার করতে হয়, যেগুলো replay তেও একই মান দেয়।

Python
from datetime import timedelta
from temporalio import activity, workflow
from temporalio.common import RetryPolicy
# `payments` and `entitlements` are your own client wrappers (imports omitted)
@activity.defn
async def charge_customer(customer_id: str, amount_cents: int) -> str:
info = activity.info()
key = f"{info.workflow_id}:{info.activity_id}" # stable across retries
payment = await payments.charge(customer_id, amount_cents, idempotency_key=key)
return payment.id
@activity.defn
async def grant_entitlement(customer_id: str, payment_id: str) -> None:
info = activity.info()
key = f"{info.workflow_id}:{info.activity_id}"
await entitlements.grant(customer_id, payment_id, idempotency_key=key)
@workflow.defn
class RenewalWorkflow:
@workflow.run
async def run(self, customer_id: str, amount_cents: int) -> str:
payment_id = await workflow.execute_activity(
charge_customer,
args=[customer_id, amount_cents],
start_to_close_timeout=timedelta(seconds=30),
retry_policy=RetryPolicy(maximum_attempts=5),
)
await workflow.execute_activity(
grant_entitlement,
args=[customer_id, payment_id],
start_to_close_timeout=timedelta(seconds=15),
)
return payment_id
Temporal এ Worker A crash করার পর Worker B Event History replay করছে, সম্পন্ন Activity এর result History থেকে আসছে আর অ-record Activity আবার চলছে
Temporal এ Worker A crash করার পর Worker B Event History replay করছে, সম্পন্ন Activity এর result History থেকে আসছে আর অ-record Activity আবার চলছে

কিন্তু এখানে Level 3 এর সবচেয়ে বড় শিক্ষাটা লুকিয়ে আছে। Temporal Activity কে at-least-once চালায়। Worker যদি Activity সফলভাবে শেষ করে কিন্তু Temporal Service কে জানানোর আগেই crash করে, তাহলে Activity আবার চলবে। এটাই W3 এর হুবহু প্রতিরূপ, শুধু নামটা আলাদা। তাই Temporal এর নিজের docs ও বলছে, Activity কে idempotent হতে হবে, আর Key বানাতে Workflow ID এর সাথে Activity ID ব্যবহার করা যায়। (Docs Run ID এর সাথে Activity ID এর কথা বলে। আমি এখানে Workflow ID ব্যবহার করেছি, কারণ business level dedup এর জন্য ID টা Workflow এর run বদলালেও একই থাকে। আপনার use case অনুযায়ী ভেবে বেছে নিন।)

Heartbeat এর ফাঁদ

Heartbeat Timeout ছোট করলে Temporal মৃত Worker দ্রুত ধরে ফেলে, ফলে retry দ্রুত হয়। কিন্তু দ্রুত retry মানে বেশি retry, আর বেশি retry মানে বেশি duplicate এর সুযোগ। Heartbeat এ ঘাবড়াবেন না, শুধু মনে রাখুন এতে Key এর গুরুত্ব কমে না, বরং বাড়ে। Local Activity তেও একই সতর্কতা: Workflow Task fail হলে ওই task এর সব Local Activity আবার চলে।

Saga এবং compensation নিয়েও একটা কথা। "Step 3 fail করলে Step 2 আর Step 1 undo করো" শুনতে সুন্দর, কিন্তু compensation নিজেও একটা side effect। Refund tool কে দুইবার চালালে দুইবার refund যায়, তাই compensation কেও একই Key নিয়মে idempotent হতে হবে। Durable Execution ঝামেলা কমায়, ঝামেলা শূন্য করে না।

Level 4: Atomic Effect and Completion Record

এবার একটা মৌলিক নীতিতে আসি: যদি effect আর তার completion record একই Database transaction এ commit হয়, তাহলে ওদের মাঝখানে crash হওয়ার কোনো মুহূর্তই থাকে না। W3 নামের ফাঁকটা তখন অস্তিত্বহীন হয়ে যায়। এই কাজটা সম্ভব যখন effect আপনার নিজের Database এ ঘটে, যেমন entitlement grant, ledger এ credit, বা order status বদলানো।

TypeScript
export async function grantEntitlementAtomic(
runId: string, step: number, customerId: string, plan: string,
) {
const key = `${runId}:${step}:grant`;
const client = await pool.connect();
try {
await client.query("BEGIN");
const ins = await client.query(
`INSERT INTO entitlements (idem_key, customer_id, plan)
VALUES ($1, $2, $3)
ON CONFLICT (idem_key) DO NOTHING
RETURNING idem_key`,
[key, customerId, plan],
);
await client.query(
`INSERT INTO run_events (run_id, seq, type, payload)
VALUES ($1, $2, 'STEP_COMPLETED', $3)
ON CONFLICT (run_id, seq) DO NOTHING`,
[runId, step, JSON.stringify({ key, applied: ins.rowCount === 1 })],
);
await client.query("COMMIT"); // both rows, or neither
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
একটা Single DB Transaction এর ভেতরে Side Effect Row আর STEP_COMPLETED Event, নিচে ভুল design যেখানে দুই আলাদা transaction এর মাঝে torn write
একটা Single DB Transaction এর ভেতরে Side Effect Row আর STEP_COMPLETED Event, নিচে ভুল design যেখানে দুই আলাদা transaction এর মাঝে torn write

এই design এর আরেকটা সুবিধা আছে। প্রতিটা STEP_COMPLETED event একটা append-only log এ জমা হয়, আর agent এর state আলাদা করে serialize করার দরকারই পড়ে না: log replay করলেই state ফিরে আসে। এটা আপনার আগের Transactional Outbox আর Inbox এর ব্লগের সাথে সরাসরি সম্পর্কিত। সেখানে আমরা message পাঠানো আর state বদলানোকে একটা transaction এ বেঁধেছিলাম, এখানে tool effect আর completion record কে বাঁধছি।

Level 4 এর সীমা

Atomic commit শুধু তখনই কাজ করে যখন effect আর record একই Database এ থাকে। Stripe এ টাকা কাটা বা Gmail এ email পাঠানো কখনো আপনার Postgres transaction এর অংশ হতে পারে না। ওগুলোর জন্য Level 5 লাগবে।

Level 5: UNKNOWN State and Reconciliation

শেষ ধাপটা সবচেয়ে সৎ। যখন external API Key সাপোর্ট করে না, আর effect আপনার নিজের Database এও নেই, তখন W3 এর অনিশ্চয়তা মুছে ফেলা যায় না। সেটা স্বীকার করে নিয়ে design করতে হয়। এজন্য প্রতিটা tool operation এর একটা স্পষ্ট state machine থাকবে: PENDING, INVOKED, COMMITTED, UNKNOWN, FAILED। আর নিয়ম একটাই: agent কখনো timeout দেখে সিদ্ধান্ত নেবে না যে effect হয়েছে কিনা। সে operation কে UNKNOWN চিহ্নিত করবে, তারপর provider কে জিজ্ঞেস করবে।

SQL
CREATE TABLE tool_operations (
idem_key TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
tool TEXT NOT NULL,
args_fingerprint TEXT NOT NULL,
state TEXT NOT NULL
CHECK (state IN ('PENDING','INVOKED','COMMITTED','UNKNOWN','FAILED')),
result JSONB,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
TypeScript
async function setState(key: string, state: string, result?: unknown) {
await pool.query(
`UPDATE tool_operations
SET state = $2, result = COALESCE($3, result), updated_at = now()
WHERE idem_key = $1`,
[key, state, result === undefined ? null : JSON.stringify(result)],
);
}
export async function executeOnce(
call: ToolCall,
effect: (key: string) => Promise<unknown>,
lookup?: (key: string) => Promise<unknown | null>, // ask the provider "did this happen?"
) {
const key = idemKey(call.runId, call.step, call.toolCallId);
const fp = fingerprint(call.args);
// atomic claim: only one attempt wins the INSERT
const claim = await pool.query(
`INSERT INTO tool_operations (idem_key, run_id, tool, args_fingerprint, state)
VALUES ($1, $2, $3, $4, 'INVOKED')
ON CONFLICT (idem_key) DO NOTHING
RETURNING idem_key`,
[key, call.runId, call.tool, fp],
);
if (claim.rowCount === 0) {
const { rows } = await pool.query(
`SELECT state, result, args_fingerprint FROM tool_operations WHERE idem_key = $1`,
[key],
);
const op = rows[0];
if (op.args_fingerprint !== fp) throw new Error("IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_ARGS");
if (op.state === "COMMITTED") return op.result; // replay the recorded result
if (op.state === "FAILED") throw new Error("OPERATION_PREVIOUSLY_FAILED");
// INVOKED or UNKNOWN: an earlier attempt may have reached the provider
if (!lookup) {
await setState(key, "UNKNOWN");
throw new NeedsHumanApproval(call, key); // no way to know, so ask a human
}
const found = await lookup(key);
if (found) {
await setState(key, "COMMITTED", found);
return found;
}
// provider has no record: fall through and run the effect once more
}
try {
const result = await effect(key);
await setState(key, "COMMITTED", result);
return result;
} catch (err) {
// 4xx validation error before any effect: FAILED. Anything ambiguous: UNKNOWN
await setState(key, isDefinitelyNotSent(err) ? "FAILED" : "UNKNOWN");
throw err;
}
}
Operation State Machine: PENDING থেকে INVOKED, COMMITTED, FAILED, আর timeout এ UNKNOWN, যেখান থেকে lookup দিয়ে COMMITTED বা FAILED, অথবা Human Approval
Operation State Machine: PENDING থেকে INVOKED, COMMITTED, FAILED, আর timeout এ UNKNOWN, যেখান থেকে lookup দিয়ে COMMITTED বা FAILED, অথবা Human Approval

"Lookup এ কিছু নেই" মানে "Effect হয়নি" না

কিছু search API তে নতুন data দেখা যেতে কিছুটা delay হয়। তাই lookup খালি এলেই ধরে নেবেন না যে effect ঘটেনি। Retry করার সময় যেখানে সম্ভব একই Key ব্যবহার করুন। Production এ INVOKED state এর সাথে একটা lease (যেমন locked_until) যোগ করবেন, যাতে একই সময়ে চলতে থাকা দুটো attempt একে অপরকে ভুল বুঝে duplicate না বানায়।

যে operation কে idempotent করা অসম্ভব, যেমন একটা irreversible production deploy, সেখানে শেষ ভরসা Human Approval। কিন্তু approval টা বাঁধতে হবে হুবহু ওই tool call আর ওই args এর সাথে, নইলে "X অনুমোদন দিলাম, Y চলে গেল" ধরনের ফাঁক তৈরি হয়। এর সাথে প্রতিটা tool কে retry classification দিন: safe, idempotent আর unsafe। আর error response এ ফেরত পাঠান retryable আর retry_after_seconds, যাতে Agent একই retry logic সব failure তে প্রয়োগ না করে। 400 এ retry করে লাভ নেই, 503 এ হয়তো আছে।

Step 6: Results, What the Matrix Tells Us

ছয় Level আর চার Window মিলিয়ে চব্বিশটা ঘর। নিচের Matrix টা প্রতিটা Level এর design guarantee দেখায়: কোন window তে duplicate ঘটে, কোথায় ঘটে না, আর কোথায় একটা শর্তের উপর নির্ভর করে।

Guard Level বনাম Crash Window এর Heatmap, যেখানে DUP, COND আর 0 দিয়ে duplicate এর সম্ভাবনা চিহ্নিত
Guard Level বনাম Crash Window এর Heatmap, যেখানে DUP, COND আর 0 দিয়ে duplicate এর সম্ভাবনা চিহ্নিত

সংখ্যাগুলো আপনি নিজে বের করুন

Matrix এর ঘরগুলো প্রতিটা Level এর design থেকে আসা যৌক্তিক ফলাফল। সঠিক duplicate count আপনি lab চালালে results/summary.csv এ পাবেন। Lab এর repo: agent-crash-lab।

Matrix থেকে তিনটা জিনিস চোখে পড়ে। প্রথমত, W1 আর W2 কোনো Level এই সমস্যা না, কারণ তখনো কোনো effect ঘটেনি। দ্বিতীয়ত, সব কঠিন লড়াই W3 তে। Level 2 আর Level 3 এ W3 এখনো শর্তসাপেক্ষ, কারণ তারা provider এর Key মানার উপর নির্ভর করে। তৃতীয়ত, শূন্যে পৌঁছানোর দুটো উপায় আছে, আর দুটোই আলাদা ধরনের: Level 4 ফাঁকটাই মুছে দেয় (শুধু নিজের Database এর জন্য), আর Level 5 ফাঁকটা স্বীকার করে তার উপর reconcile করে।

প্রতিটা Level এর একটা মূল্যও আছে, আর বিনা মূল্যে কোনো guarantee মেলে না।

Levelবাড়তি storageবাড়তি latencyComplexity
L0 NaiveনেইনেইLow
L1 Hash keyছোট একটা key storeএকটা lookupLow
L2 Stable keyKey আর fingerprint tableপ্রতি call এ একটা writeMedium
L3 Durable executionEvent History আর workflow serviceScheduling hopHigh
L4 Atomic commitএকই Database এ বাড়তি rowবাড়তি network call নেইMedium
L5 UNKNOWN আর reconcileOperation table আর reconcile pathAmbiguity তে একটা lookupHigh

Latency এর ব্যাপারে একটা সংখ্যা দেওয়া যায়: Temporal এর নিজের docs অনুযায়ী Temporal Cloud এ একটা regular Activity এর scheduling overhead মোটামুটি ৫০ ms এর কাছাকাছি। কথাটা মনে রাখবেন, কারণ Level 3 বেছে নেওয়ার সময় এটা আপনার latency budget এ ঢুকবে।

এই Lab এর সীমাবদ্ধতা

Lab এ LLM scripted, তাই এটা বলে না বাস্তবে আপনার model কতবার args reword করে। Provider simulated, আর পুরো ব্যাপারটা একটা node এর একটা Postgres এ চলে। Lab প্রমাণ করে কোন design কোন crash window বন্ধ করে। Production এর load, network partition আর clock skew এখানে নেই, সেগুলো আলাদা পরীক্ষা চায়।

Step 7: How Production Systems Do It

Stripe এর কাছ থেকে শুরু করা যাক, কারণ Idempotency Key এর এই pattern তাদের হাত ধরেই জনপ্রিয় হয়েছে। Stripe Key টা মোটামুটি ২৪ ঘণ্টা ধরে রাখে, আর Brandur Leach এর reference implementation (opens in a new tab) এ একটা recovery_point field আছে। এর মানে multi-step request crash করলে সে শুরু থেকে আবার চলে না, সবশেষ সম্পন্ন ধাপ থেকে resume করে। এটা আমাদের Level 5 এর state machine এর কাছাকাছি, শুধু granularity টা আরও সূক্ষ্ম।

Durable Execution এর দিকে তাকালে, The New Stack এর একটা রিপোর্ট অনুযায়ী Temporal ৩০০০ এর বেশি paying customer এ পৌঁছেছে, আর তাদের পরীক্ষায় agentic worker মাঝপথে kill করলেও data loss বা double charge হয়নি। AI Agent এর জগতে "durable by default" এখন প্রায় একটা procurement শর্ত হয়ে উঠছে। MCP র দিকেও একই চাপ আসছে। Stateless Streamable HTTP তে stream ভাঙলে in-flight request হারিয়ে যায় আর client সেটা আবার পাঠায়, তাই MCP server এর প্রতিটা write idempotent হওয়া এখন সরাসরি production এর প্রয়োজন। Agent framework এর checkpointer ও একটা ভুল বোঝাবুঝির জায়গা: checkpointer থাকলে crash এর পর resume প্রায় তাৎক্ষণিক হয়, কিন্তু checkpoint লেখা হয় step শেষ হওয়ার পর। Effect আর checkpoint এর মাঝের ফাঁকে crash হলে effect আবার চলবেই, তাই checkpointer নিজে duplicate ঠেকায় না।

Production Reference Architecture: Agent Runtime, Durable Workflow Engine, Tool Gateway, তিন ধরনের external target আর Observability trace
Production Reference Architecture: Agent Runtime, Durable Workflow Engine, Tool Gateway, তিন ধরনের external target আর Observability trace

Observability নিয়ে একটা কথা। একটা agent observability guide এর হিসাব অনুযায়ী production এ tool call ৩ থেকে ১৫ শতাংশ সময় fail করে, আর এক জায়গার failure চুপচাপ পুরো workflow এ ছড়িয়ে যেতে পারে। তাই retry আসবেই, প্রশ্ন শুধু আপনি প্রস্তুত কিনা। Incident এ প্রমাণ পাওয়ার জন্য প্রতিটা tool step এর trace এ তিনটা জিনিস রাখুন: derive করা Key, operation এর state, আর ওই attempt টা replay ছিল নাকি fresh effect। এই তিনটা না থাকলে "ঠিক একবার charge হয়েছে" দাবিটার কোনো প্রমাণ আপনার হাতে থাকে না।

Recap and Production Checklist

পুরো ব্লগের সারকথা এক লাইনে: Retry আর Duplicate এর সম্পর্ক ভাঙতে হলে Key বানাতে হবে Durable State থেকে, LLM এর output থেকে না, আর Key কে থাকতে হবে সেই layer এ যেখানে retry ঘটে। W3 নামের ফাঁকটা মুছে ফেলা যায় (Level 4) অথবা স্বীকার করে reconcile করা যায় (Level 5), কিন্তু উপেক্ষা করা যায় না। আর "Exactly-once" আসলে At-least-once delivery আর idempotent effect এর যোগফল, কোনো জাদু না।

কোন Level আপনার দরকার, সেটা ঠিক হয় একটাই প্রশ্নে: tool এর effect ঠিক কোথায় ঘটে?

Decision Flowchart: effect কোথায় ঘটে সেই প্রশ্ন থেকে তিন শাখা, নিজের DB এ Level 4, Key মানা API তে Level 2 বা 3, Key না মানা API তে Level 5
Decision Flowchart: effect কোথায় ঘটে সেই প্রশ্ন থেকে তিন শাখা, নিজের DB এ Level 4, Key মানা API তে Level 2 বা 3, Key না মানা API তে Level 5

Production এ যাওয়ার আগে নিজেকে এই প্রশ্নগুলো করুন:

  • আমার প্রতিটা write tool কি একটা caller-supplied Idempotency Key নেয়?
  • Key কি Durable State থেকে derive হয় (runId, step, tool call ID), নাকি args এর hash থেকে?
  • Intent কি tool চালানোর আগে log এ লেখা হয়?
  • Retry কোন layer এ ঘটে আর Key কোন layer এ আছে? দুটো কি একই layer?
  • W3 এ crash করলে আমার system কী করে? আমি কি কখনো এটা সত্যিই পরীক্ষা করেছি?
  • External API dedupe না করলে আমার কি UNKNOWN state আর reconcile path আছে?
  • যে operation কে idempotent করা যায় না, সেখানে কি exact call আর args এ বাঁধা Human Approval আছে?
  • Key এর TTL কি আমার সবচেয়ে লম্বা agent run এর চেয়ে বড়?
  • Compensation (refund, rollback) কি নিজেও idempotent?
  • প্রতিটা tool step এর trace এ Key, state আর replay-or-fresh লেখা থাকে?

আরও পড়ার জন্য:

All articles

Engineering Roadmap: Why 'Exactly Once' Delivery Is Mostly a Lie

ডিস্ট্রিবিউটেড সিস্টেমে নেটওয়ার্ক লেভেলে এক্স্যাক্টলি-ওয়ানস ডেলিভারি কেন গাণিতিকভাবে অসম্ভব, এবং আইডেমপোটেন্সি, ট্রানজেকশনাল আউটবক্স ও ইনবক্স প্যাটার্ন দিয়ে কীভাবে প্রোডাকশনে ইফেক্টিভলি-ওয়ানস আর্কিটেকচার বানাবেন তার কমপ্লিট ইঞ্জিনিয়ারিং গাইড।

System DesignENBN

QR Code Engineering Handbook: From Optical Physics to High-Concurrency Production Pipelines

একদম শূন্য থেকে কিউআর কোডের ভিজ্যুয়াল অ্যানাটমি, অপটিক্যাল ফিজিক্স, Reed-Solomon Error Correction-এর জটিল গণিত এবং Node.js ও React দিয়ে প্রোডাকশন-রেডি এন্টারপ্রাইজ সিস্টেম ডিজাইন করার একটি কমপ্লিট ইঞ্জিনিয়ারিং গাইড।

System DesignENBN

Get in touch

Questions about a video, an article, or working together.