সরাসরি পর্বে যান
পর্ব ৩৭ · ৬৬-এর মধ্যে · মডিউল ৬

Background job, scheduler আর workflow engine — দীর্ঘ কাজ নির্ভরযোগ্যভাবে চালানো

Background Jobs, Schedulers & Workflow Engines — Running Long Work Reliably

Taught in Bangla, this lesson explains why slow work leaves the request path, job queues and workers, a job's lifecycle states, retry policy with exponential backoff, jitter, max attempts and DLQ, idempotent jobs under at-least-once execution with timeouts, heartbeats and leases, four cron pitfalls, distributed cron, delayed jobs and timers, workflow engines with durable execution and replay, DAG pipelines, and choosing between a queue, cron and a workflow engine.

এই পর্বে যা শিখবেন

  • কোন কাজ request path থেকে সরাবেন, আর job queue ও worker দিয়ে কীভাবে চালাবেন
  • Job-এর ছয়টি অবস্থা, retry-র নীতি (backoff, jitter, max attempts) আর DLQ
  • At-least-once execution-এ idempotent job, timeout, heartbeat আর lease বাড়ানো
  • Cron-এর চারটি ফাঁদ, distributed cron, delayed job আর timer-এর কাঠামো
  • Workflow engine-এর durable execution, replay আর deterministic কোড; DAG pipeline; আর কখন কোনটি বেছে নেবেন

১ · কেন request path-এ নয় — background job-এর প্রয়োজন

একটি কাল্পনিক অনলাইন দোকানে বিক্রেতা পণ্যের একটি ছবি আপলোড করলেন। সার্ভারকে এখন অনেক কাজ করতে হবে: ছবিটি object storage-এ রাখা, তিন মাপের ছোট সংস্করণ (thumbnail) বানানো, ছবির ধরন যাচাই, search index হালনাগাদ, আর বিক্রেতাকে একটি ইমেইল। সব কাজ যদি অনুরোধের ভেতরেই হয়, বিক্রেতা ঘুরতে থাকা চাকা দেখবেন হয়তো ১০ সেকেন্ড। আর ইমেইল সেবা সেই মুহূর্তে ধীর থাকলে তাঁর আপলোডটিই “ব্যর্থ” দেখাবে — যদিও ছবিটি ঠিকমতো রাখা হয়েছিল।

সমাধান সহজ চিন্তা থেকে আসে: ব্যবহারকারীর এখনই যা জানা দরকার, শুধু সেটুকু অনুরোধের ভেতরে করুন; বাকিটা পরে। অনুরোধটি ছবি রাখে, একটি background job সারিতে রাখে (“photo 7731-এর thumbnail বানাও”), আর সঙ্গে সঙ্গে উত্তর দেয় — HTTP-তে প্রায়ই 202 Accepted, সাথে job-এর id, যা দিয়ে পরে অবস্থা জানা যায় (পর্ব ৮ — REST API নকশা)। আলাদা worker process সারি থেকে কাজ তুলে নিজের গতিতে করে।

রেস্তোরাঁর ওয়েটার আপনার অর্ডার নিয়ে একটি স্লিপ রান্নাঘরে ঝুলিয়ে দেন, তারপর পরের টেবিলে যান। তিনি নিজে চুলার পাশে দাঁড়িয়ে বিরিয়ানি রান্না শেষ হওয়ার অপেক্ষা করলে পুরো রেস্তোরাঁ থেমে যেত। স্লিপের সারিটি job queue, বাবুর্চিরা worker; ভিড় বাড়লে স্লিপ জমে, কিন্তু ওয়েটার অর্ডার নেওয়া থামান না। আর একজন বাবুর্চি অসুস্থ হলে স্লিপটি হারায় না — আরেকজন তুলে নেন।

কোন কাজ সরাবেন — চারটি লক্ষণ

  • ধীর: ছবি বা ভিডিও প্রক্রিয়া, বড় PDF, মাসের রিপোর্ট (ভিডিওর পুরো নকশা পর্ব ৫৯-এ)।
  • বাইরের সেবার ওপর নির্ভর: ইমেইল, SMS, push — এদের বিভ্রাট আপনার নিয়ন্ত্রণে নেই (notification-এর নকশা পর্ব ৫৭-এ)।
  • এখনই ফল লাগে না: ইমেইল ৫ সেকেন্ড পরে গেলে কেউ টের পান না।
  • ভিড়ে জমা রাখা চলে: সারিতে জমুক, worker-রা নিজেদের সামর্থ্যে শেষ করুক (পর্ব ১৮ — Overload সামলানো)।

লাভ তিনটি: latency কমে, কারণ ব্যবহারকারী শুধু দ্রুত অংশের অপেক্ষা করেন; ব্যর্থতা আলাদা থাকে, কারণ ইমেইল সেবার বিভ্রাট এখন একটি সারির দৈর্ঘ্য, ব্যবহারকারীর ত্রুটি নয়; আর ভিড় শোষণ হয়, কারণ worker-এর সংখ্যা চাপ অনুযায়ী আলাদা করে বাড়ানো-কমানো যায়। দাম: কাজটি এখন asynchronous, তাই ব্যবহারকারীকে পরে ফল জানাতে হয় (polling, ইমেইল বা পর্ব ১০ — Real-time যোগাযোগ-এর WebSocket), আর “ব্যর্থ হলে কী হবে” প্রশ্নটি এখন আপনার নকশার দায়।

সাবধান — “ভেতরে একটি thread চালিয়ে দিই” ফাঁদ। Handler-এর ভেতরে আলাদা thread বা setTimeout-এ ইমেইল পাঠালে কাজটি কোথাও টেকসইভাবে লেখা থাকে না; deploy বা crash-এ নিঃশব্দে হারায়। Background job মানে টেকসই সারিতে লেখা কাজ, যা process মরলেও থাকে।

আরেকটি সূক্ষ্ম সমস্যা: অর্ডার commit হলো, job রাখার আগেই সার্ভার মরল — ইমেইল আর যাবে না। প্রচলিত সমাধান outbox pattern: job-কে অর্ডারের সাথে একই transaction-এ লেখা, পরে একটি relay সারিতে পাঠায় (পর্ব ২৫ — Distributed transaction).

২ · Job queue আর worker — একটি job-এর জীবনচক্র

সারি কীভাবে বার্তা রাখে, ack পেলে মোছে আর ack না পেলে আবার দেয় — সেই যন্ত্রের পূর্ণ বিবরণ পর্ব ৩১ — Message queue-এ। এখানে প্রশ্ন একটু ওপরের স্তরের: সেই সারির ওপর কাজ চালানোর নিয়ম কী? একটি job queue সাধারণ message queue-এর ওপর কয়েকটি ধারণা যোগ করে: প্রতিটি job-এর একটি অবস্থা, কতবার চেষ্টা হলো তার গণনা, কখন আবার চালানো যাবে (run_at), আর ফল বা শেষ ত্রুটি।

{
  "id": "job_7731_thumb",
  "type": "make_thumbnails",
  "payload": { "photo_id": 7731, "sizes": [128, 512, 1024] },
  "state": "queued",
  "attempt": 0,
  "max_attempts": 4,
  "run_at": "2026-10-28T04:00:00Z",
  "timeout_s": 120,
  "last_error": null
}

Payload-এ ছবিটি নিজে নেই — শুধু তার id; বড় ডেটা storage-এ, job-এ শুধু উল্লেখ। আর run_at সময়টি UTC-তে — কেন, তা অংশ ৫-এ দেখবেন।

ছয়টি অবস্থা

অবস্থামানেএখান থেকে যায়
queuedসারিতে অপেক্ষায়, যেকোনো worker তুলতে পারেrunning
runningএকটি worker-এর হাতে, সময়সীমা (lease) সহsucceeded, failed, অথবা lease ফুরালে আবার queued
succeededকাজ শেষ, ফল লেখা, ack দেওয়া— (শেষ অবস্থা)
failedএই চেষ্টা ব্যর্থ; এখন সিদ্ধান্ত: আবার, না শেষ?retrying, অথবা dead
retryingঅপেক্ষায়, কিন্তু run_at-এর আগে কেউ তুলবে নাrunning
deadআর চেষ্টা নয় — DLQ-তে, মানুষের নজরেমানুষ ঠিক করে আবার queued (redrive)

Library ভেদে নাম আলাদা — কোথাও “scheduled”, কোথাও “discarded” বা “archived” — কিন্তু প্রায় সব job ব্যবস্থার ভেতরে এই একই state machine.

Worker কীভাবে কাজ তোলে

প্রতিটি worker একটি loop: সারি থেকে একটি job চায়, পায় একটি সময়সীমাসহ (lease বা visibility timeout), কাজ করে, তারপর সফল হলে ack দেয় — job মুছে যায় বা succeeded চিহ্নিত হয়। Deploy-এর সময় worker-কে graceful shutdown করতে হয়: বন্ধের সংকেত (যেমন SIGTERM — OS কোর্সের Signal পর্ব) পেলে নতুন job নেওয়া থামানো, হাতের কাজ শেষ করা বা সারিতে ফেরত দেওয়া।

Broker না রেখে সাধারণ database টেবিলকেও job queue বানানো যায় — ছোট-মাঝারি সিস্টেমে এটি প্রচলিত আর সরল, কারণ job রাখা আর ব্যবসার লেখা একই transaction-এ হয় (outbox-এর দরকারই পড়ে না)। কৌশলটি FOR UPDATE SKIP LOCKED: অনেক worker একসাথে পড়লেও প্রত্যেকে আলাদা সারি পায়, কেউ কারও lock-এর জন্য অপেক্ষা করে না।

-- একজন worker একটি job তোলে; অন্যরা একই সারি এড়িয়ে পরেরটি পায়
UPDATE jobs SET state = 'running', attempt = attempt + 1,
       lease_until = now() + interval '60 seconds', lease_token = lease_token + 1
WHERE id = (
  SELECT id FROM jobs
  WHERE state IN ('queued', 'retrying') AND run_at <= now()
  ORDER BY run_at
  LIMIT 1
  FOR UPDATE SKIP LOCKED
)
RETURNING id, type, payload, attempt, lease_token;

এই query-তে run_at <= now() retry-র অপেক্ষা আর delayed job সামলায়; lease_until worker মরলে job ফেরত আনে; আর lease_token একটি ক্রমিক নম্বর, যা দিয়ে পুরোনো worker-এর দেরিতে আসা লেখা চেনা যায় (অংশ ৪)। খুব বেশি job/s-এ টেবিলের চাপ বাড়ে; তখন আলাদা broker বা log-ভিত্তিক stream (পর্ব ৩৩ — Log-ভিত্তিক streaming).

নিচের ছবিটি এই পর্বের মানচিত্র। ওপরে job আসার দুটি উৎস — API (ব্যবহারকারীর কাজ থেকে) আর scheduler (ঘড়ি থেকে); মাঝে সারি আর worker; নিচে দীর্ঘ, বহু ধাপের কাজের জন্য workflow engine আর তার history store.

API, scheduler, job queue, worker আর workflow engine

Jobs come from the API and the scheduler; workers take them with a lease; failed jobs retry or go to the DLQ; the workflow engine records every step in a history store

Background job আর workflow-এর সাধারণ কাঠামো API সার্ভারScheduler · cron job রাখে · 202সময় হলে job রাখে Job queue queued · retrying run_at পর্যন্ত অপেক্ষা DLQ dead lease heartbeat · ack Worker 1Worker 2Worker N ছবি · ইমেইলরিপোর্টactivity Database · object storage ফল: ছবি, PDF, পাঠানোর লগ দীর্ঘ কাজ · durable execution Workflow engineHistory store ধাপ · timer · signalশুধু যোগ হয় → append · ⇠ replay · বাঁ পথে activity task নীল: job-এর পথ · ধূসর: heartbeat, ack আর ফল লাল: max attempts পেরোলে DLQ
ছবি ৩৭.১ — API আর scheduler job রাখে সারিতে; worker lease নিয়ে job তোলে, চলাকালে heartbeat দিয়ে lease বাড়ায়, শেষে ack (অংশ ২ আর ৪)। ব্যর্থ job run_at নিয়ে আবার সারিতে, সীমা পেরোলে DLQ-তে (অংশ ৩)। Workflow engine প্রতিটি ধাপ history-তে লেখে আর ধাপের কাজগুলো একই ধরনের সারি দিয়ে worker-দের দেয় (অংশ ৭)।

৩ · ব্যর্থ হলে — retry, exponential backoff, jitter, max attempts আর DLQ

Background job ব্যর্থ হবেই — ইমেইল সেবা ৫০৩ দেয়, storage-এ timeout, network-এ ক্ষণিকের বিচ্ছিন্নতা। বেশিরভাগই ক্ষণস্থায়ী (transient), কিন্তু ভুল নীতির retry নিজেই বিপদ। চারটি প্রশ্নের উত্তর লাগে:

  1. কোন ত্রুটিতে retry? Timeout, ৫০৩, ৪২৯, connection ভাঙা — হ্যাঁ। ভুল ইনপুট (৪০০), অনুমতি নেই (৪০৩), ছবির ফাইল নষ্ট — না। এগুলো non-retryable error.
  2. কতক্ষণ পরে? সঙ্গে সঙ্গে নয়। যে সেবা এইমাত্র ডুবল, তাকে সঙ্গে সঙ্গে আবার ডাকলে সে আরও ডোবে — retry storm. তাই exponential backoff: প্রতিটি ব্যর্থতায় অপেক্ষা দ্বিগুণ — ২, ৪, ৮, ১৬ সেকেন্ড — একটি ঊর্ধ্বসীমা (cap) পর্যন্ত।
  3. সবাই একসাথে নয়: ১০,০০০ job একই মুহূর্তে ব্যর্থ হলে, শুধু backoff-এ তারা ঠিক ২ সেকেন্ড পরে আবার একসাথে ফেরে। তাই jitter: অপেক্ষার সময় এলোমেলো করা। প্রচলিত “full jitter” — অপেক্ষা = ০ থেকে min(cap, base × 2n)-এর মধ্যে একটি এলোমেলো মান।
  4. কতবার পর্যন্ত? Max attempts (বা মোট সময়ের সীমা)। সীমা পেরোলে job dead — dead letter queue-এ। যে job প্রতিবার ব্যর্থ হয়, সেই poison job সীমা না থাকলে চিরকাল সারিতে ঘুরত।
wait(n) = random(0, min(cap, base × 2n)) · n = এ পর্যন্ত ব্যর্থতার সংখ্যা
P(একটি job DLQ-তে) = pmax · p = একটি চেষ্টা ব্যর্থ হওয়ার সম্ভাবনা (স্বাধীন ধরে)

প্রতিটি চেষ্টা ৩০% ব্যর্থ হলে ৫ বারে শেষ পর্যন্ত ব্যর্থতার সম্ভাবনা ০.৩৫ ≈ ০.২৪% — কিন্তু শুধু ব্যর্থতাগুলো স্বাধীন হলে। সেবাটি পুরো এক ঘণ্টা বন্ধ থাকলে সব চেষ্টা একসাথে ব্যর্থ; base ২ সেকেন্ডে চার বার অপেক্ষার সর্বোচ্চ যোগফল মাত্র ৪ + ৮ + ১৬ + ৩২ = ৬০ সেকেন্ড। ইমেইলের মতো কাজে তাই অনেক library ঘণ্টা বা দিন জুড়ে retry ছড়ায়; যেমন Ruby-র Sidekiq-এর documentation অনুযায়ী ডিফল্টে প্রায় ২৫ বার, মোটামুটি তিন সপ্তাহ ধরে (লেখার সময়ে; সংস্করণভেদে আলাদা)।

নিচের simulator-এ ১,০০০টি job: বেশিরভাগ “normal” (প্রতি চেষ্টায় ৩০% ব্যর্থ), কিছু “flaky” (৭০% — যেমন একটি অস্থির বাইরের সেবা), কিছু “poison” (সবসময় ব্যর্থ), আর কিছু “invalid” (ভুল ইনপুট, retry চলে না)। Max attempts ৫, base ২ সেকেন্ড, cap ৬০ সেকেন্ড, full jitter; seed স্থির, তাই প্রতিবার একই ফল।

// Seeded job runner: flaky jobs, retries with exponential backoff + full jitter, max attempts, DLQ.
function mulberry32(a) {                     // small seeded PRNG: same run every time
  return () => { a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}
const rnd = mulberry32(37);
const MAX = 5, BASE = 2, CAP = 60;           // max attempts; backoff base and cap (seconds)
const FAIL = { normal: 0.3, flaky: 0.7, poison: 1 };     // chance that one attempt fails
const wait = n => rnd() * Math.min(CAP, BASE * 2 ** n);  // full jitter after failure n

const jobs = [];
for (let i = 1; i <= 1000; i++) {
  const r = rnd();
  jobs.push({ id: i, kind: r < 0.8 ? "normal" : r < 0.95 ? "flaky" : r < 0.98 ? "poison" : "invalid" });
}
const okAt = [0, 0, 0, 0, 0, 0], dlq = { normal: 0, flaky: 0, poison: 0, invalid: 0 };
let attempts = 0, shown = 0;
for (const job of jobs) {
  let t = 0, log = [];
  for (let n = 1; ; n++) {
    attempts++;
    if (job.kind === "invalid") { log.push("t=" + t.toFixed(1) + " 400 bad input -> DLQ"); dlq.invalid++; break; }
    const failed = rnd() < FAIL[job.kind];
    log.push("t=" + t.toFixed(1) + (failed ? " fail" : " ok"));
    if (!failed) { okAt[n]++; break; }
    if (n === MAX) { log.push("-> DLQ after " + MAX); dlq[job.kind]++; break; }
    t += wait(n);                            // retrying: next run_at = now + jittered backoff
  }
  if (shown < 3 && log.length > 2) { console.log("job " + job.id + " (" + job.kind + "): " + log.join(" | ")); shown++; }
}
const ok = okAt.reduce((a, b) => a + b, 0);
console.log("\nsucceeded " + ok + "/1000 (" + (ok / 10).toFixed(1) + "%), total attempts " + attempts);
console.log("succeeded on attempt 1..5: " + okAt.slice(1).join(", "));
console.log("DLQ after " + MAX + " tries: normal " + dlq.normal + ", flaky " + dlq.flaky + ", poison " +
  dlq.poison + "; non-retryable at once: " + dlq.invalid);

আউটপুট:

job 8 (poison): t=0.0 fail | t=2.3 fail | t=8.6 fail | t=22.5 fail | t=39.8 fail | -> DLQ after 5
job 15 (flaky): t=0.0 fail | t=1.9 fail | t=6.2 fail | t=17.1 ok
job 16 (flaky): t=0.0 fail | t=1.9 fail | t=6.4 fail | t=19.6 fail | t=36.7 fail | -> DLQ after 5

succeeded 915/1000 (91.5%), total attempts 1716
succeeded on attempt 1..5: 615, 180, 79, 26, 15
DLQ after 5 tries: normal 3, flaky 26, poison 31; non-retryable at once: 25

প্রথম তিনটি লাইন তিনটি job-এর সময়রেখা। Job ৮ poison — পাঁচবারই ব্যর্থ, শেষে DLQ; চেষ্টার মাঝের ফাঁক এলোমেলো কিন্তু বাড়ছে (২.৩, ৬.৩, ১৩.৯, ১৭.৩ সেকেন্ড), কারণ jitter-এর জানালা প্রতিবার দ্বিগুণ: ০–৪, ০–৮, ০–১৬, ০–৩২. Job ১৫ flaky — চতুর্থ চেষ্টায় সফল। Job ১৬-ও flaky, কিন্তু পাঁচবারই হেরেছে — retry সাফল্য নিশ্চিত করে না, শুধু সম্ভাবনা বাড়ায়।

সারাংশে ১,০০০-এর ৯১৫টি সফল, আর মোট চেষ্টা ১,৭১৬ — job-প্রতি গড়ে ১.৭টি; worker-এর সামর্থ্যের হিসাবে এই বাড়তি ৭০% ধরতে হবে। ৬১৫টি প্রথম চেষ্টাতেই, বাকিরা retry-র কল্যাণে। DLQ-র ভাগটি দেখুন: ৭৯৮টি normal job-এর মাত্র ৩টি (সূত্রে প্রত্যাশা ৭৯৮ × ০.০০২৪৩ ≈ ১.৯), ১৪৬টি flaky-র ২৬টি (প্রত্যাশা ১৪৬ × ০.৭৫ ≈ ২৪.৫), আর ৩১টি poison-এর সবকটি — যারা মিলে ১৫৫টি চেষ্টা নষ্ট করেছে। ২৫টি invalid job এক চেষ্টাতেই DLQ-তে — retry করলে আরও ১০০টি অর্থহীন চেষ্টা হতো।

নিচের stepper-এ একটি job-এর পুরো জীবন, আর শেষে একটি poison job-এর DLQ-যাত্রা। নীতি একই: max attempts ৪, base ২ সেকেন্ড, full jitter.

নীতি: max attempts ৪ · অপেক্ষা = random(0, 2 × 2ⁿ) সেকেন্ড · lease ৩০ সেকেন্ড
Job
অবস্থা
চেষ্টা
পরের run_at
  1. queued. বিক্রেতা ছবি আপলোড করলেন; API ছবিটি storage-এ রেখে job job_7731_thumb সারিতে রাখল আর সঙ্গে সঙ্গে 202 দিল।
  2. running, চেষ্টা ১. Worker W1 job-টি তুলল, সাথে ৩০ সেকেন্ডের lease: এই সময়ে অন্য কেউ এটি তুলবে না। চেষ্টার গণনা এখন ১.
  3. failed → retrying. Storage-এ timeout — ক্ষণস্থায়ী ত্রুটি, তাই retry. প্রথম ব্যর্থতার পরে jitter-এর জানালা ০ থেকে ২ × ২¹ = ৪ সেকেন্ড; এলোমেলোভাবে এল ২.৭. Job ফিরল সারিতে run_at = ১০:০০:০৩.৯ নিয়ে; তার আগে কেউ তুলবে না।
  4. আবার ব্যর্থ. সময় হলে এবার W3 তুলল। Image সেবা দিল ৫০৩. দ্বিতীয় ব্যর্থতায় জানালা ০–৮ সেকেন্ড; এল ৫.১, তাই পরের চেষ্টা ১০:০০:১০.১-এ।
  5. succeeded. তৃতীয় চেষ্টায় W2 তিনটি thumbnail লিখল — নির্দিষ্ট নামে (যেমন 7731_512.jpg), তাই আগের কোনো চেষ্টা অর্ধেক লিখে থাকলেও ঢেকে যায় — তারপর ack.
  6. dead. আরেকটি job job_7790-এর ছবির ফাইলটি নষ্ট; প্রতিবার decode-এ ব্যর্থ। চতুর্থ চেষ্টা = max attempts, তাই আর retry নয়: অবস্থা dead, job গেল DLQ-তে, আর একটি alert. এটি poison job; সীমা না থাকলে চিরকাল ঘুরে worker-এর সময় খেত।
  7. Redrive. একজন প্রকৌশলী DLQ দেখে বুঝলেন, ফাইলটি আসলে একটি নতুন ছবির format, decoder-এ সমর্থন ছিল না। Bug ঠিক করে job-টি আবার সারিতে পাঠালেন (redrive). DLQ ময়লার ঝুড়ি নয় — তদন্তের টেবিল।

Retry কোথায় হবে — এক জায়গায়। Worker নিজে ৩ বার retry করে, job queue ৫ বার, আর ভেতরের HTTP client আরও ৩ বার — তাহলে একটি ব্যর্থ সেবার ওপর সবচেয়ে খারাপ ক্ষেত্রে ৩ × ৫ × ৩ = ৪৫টি অনুরোধ। স্তরে স্তরে retry গুণ হয়। একটি স্তরে retry রাখুন (সাধারণত job-এর স্তরে), বাকিগুলোতে বন্ধ বা ১-এ, আর মোট retry-র অনুপাতে একটি retry budget. Timeout, retry, backoff আর circuit breaker-এর পূর্ণ তত্ত্ব পর্ব ৪৮ — Resilience pattern-এ।

৪ · অন্তত একবার, তাই idempotent — timeout, heartbeat আর lease

একটি অস্বস্তিকর সত্য দিয়ে শুরু করি: job ব্যবস্থা প্রায় সবসময় at-least-once. Worker ইমেইল পাঠাল, তারপর ack-এর ঠিক আগে crash; সারি শুধু জানে ack আসেনি, তাই আবার দেয় — একই ইমেইল দুবার। উল্টো পথ — আগে ack, পরে কাজ — at-most-once: crash হলে কাজটি হারায়। “ঠিক একবার” পাঠানো সাধারণভাবে সম্ভব নয়; যা সম্ভব তা হলো একাধিকবার চালালেও ফল একবারের মতো। Delivery semantics-এর পুরো তত্ত্ব পর্ব ৩৪ — Delivery semantics-এ।

Idempotent job লেখার কৌশল

  • নির্দিষ্ট নামে লেখা: thumbnail 7731_512.jpg নামে — দুবার লিখলেও একটিই ফাইল। “নতুন একটি ফাইল যোগ করো” নয়, “এই নামে ফাইলটি এমন হোক”।
  • Idempotency key: বাইরের সেবাকে ডাকার সময় job-এর id থেকে বানানো একটি idempotency key পাঠানো (যেমন email:order-4217:confirm); যে সেবা key মনে রাখে, সে দ্বিতীয় অনুরোধে আগের ফলটাই ফেরত দেয়। অনেক পেমেন্ট API প্রকাশ্যে এই সুবিধা দেয়।
  • আগে দেখে তারপর করা: sent_emails টেবিলে key-র unique constraint; সারি ঢোকানো না গেলে “আগেই গেছে” — idempotent consumer-এর ধাঁচ। পাঠানো আর চিহ্ন দুটি আলাদা সিস্টেমে বলে ছোট ফাঁক থাকে; বাইরের সেবার নিজের idempotency সবচেয়ে মজবুত।
  • অবস্থা-যাচাই: “অর্ডার বাতিল করো” job প্রথমে দেখে অর্ডারটি এখনো pending কি না; ইতিমধ্যে পরিশোধিত হলে কিছুই না করে সফল।

Timeout, heartbeat আর lease — আটকে থাকা worker

Worker মরে গেলে job কে ফেরত আনবে? Worker নিজে তো জানাতে পারবে না। তাই job তোলার সময়েই একটি lease — “এই job ৬০ সেকেন্ডের জন্য এই worker-এর”। সময়ের মধ্যে ack না এলে job আবার দৃশ্যমান হয়, অন্য worker তোলে। Amazon SQS-এ এর নাম visibility timeout (documentation অনুযায়ী লেখার সময়ে ডিফল্ট ৩০ সেকেন্ড, সর্বোচ্চ ১২ ঘণ্টা); Google Pub/Sub-এ ack deadline.

দীর্ঘ কাজে (৪ মিনিটের রিপোর্ট) lease-ই ৪ মিনিটের বেশি রাখলে মরা worker-এর job ফিরতে ৪ মিনিট। সমাধান: ছোট lease, আর চলাকালে নিয়মিত heartbeat দিয়ে lease বাড়ানো (SQS-এ ChangeMessageVisibility). একটি প্রচলিত নিয়ম: heartbeat-এর বিরতি lease-এর মোটামুটি এক-তৃতীয়াংশ, যাতে একটি-দুটি heartbeat হারালেও lease না ফুরায়। একটি আলাদা timeout-ও লাগে — job-এর সর্বোচ্চ মোট সময় — কারণ heartbeat দিতে থাকা কিন্তু অসীম loop-এ ঘোরা worker-কেও থামাতে হয়।

সবচেয়ে কঠিন ক্ষেত্রটি মরা worker নয়, থেমে থাকা worker: দীর্ঘ garbage collection-এর বিরতি, disk-এ আটকে যাওয়া, বা VM সাময়িক থেমে যাওয়া। তার lease ফুরিয়েছে, job গেছে আরেকজনের কাছে — কিন্তু সে নিজে জানে না, জেগে উঠে কাজ শেষ করে ফল লিখতে চায়। নিচের stepper সেই দৌড়।

Job R-55 · মাসিক statement-এর PDF (~৪ মিনিট) · lease ৬০ s · heartbeat প্রতি ২০ s
ঘড়ি
Lease-এর মালিক · token
Lease শেষ হবে
W1
W2
Storage-এ গৃহীত সর্বোচ্চ token
  1. t = ০: W1 job তুলল। সাথে lease ৬০ সেকেন্ড আর একটি fencing token = ১ — job প্রতিবার কাউকে দেওয়ার সময় বাড়ানো একটি ক্রমিক নম্বর (অংশ ২-এর query-র lease_token).
  2. t = ৪০: heartbeat চলছে। W1 t = ২০ আর ৪০-এ heartbeat দিয়েছে; প্রতিবার lease “এখন + ৬০”, তাই এখন শেষ হবে t = ১০০-এ। কাজ ৩০% হয়েছে।
  3. t = ৪৫ থেকে: W1 আটকে গেল। একটি দীর্ঘ GC বিরতি — process বেঁচে আছে, কিন্তু কিছুই চলছে না, heartbeat-ও না। t = ৬০ আর ৮০-র heartbeat আসেনি।
  4. t = ১০০: lease ফুরাল। Job আবার দৃশ্যমান; W2 তুলল, চেষ্টা ২, token = ২, lease t = ১৬০ পর্যন্ত। W2 শুরুতেই storage-এ token ২ নিবন্ধন করল। এখন একই job দুজনের হাতে — at-least-once ঠিক এভাবেই ঘটে।
  5. t = ১৩০: W1 জেগে উঠল। সে জানে না যে lease গেছে; কাজ শেষ করে PDF লিখতে চাইল token ১ নিয়ে। Storage দেখল ১ < ২ — প্রত্যাখ্যান। W1 ফল ফেলে দিল। Token না থাকলে পুরোনোটির দেরির লেখা নতুনটিকে মুছে দিতে পারত। (Storage নিজে token যাচাই না পারলে বিকল্প: ফল idempotent নামে লেখা।)
  6. t = ৩৪০: W2 শেষ করল। PDF লেখা হলো token ২ দিয়ে, তারপর ack. ফল একটিই, চেষ্টা ২টি। শিক্ষা: heartbeat lease-এর অনেক ছোট বিরতিতে, worker নিজের lease ফুরানোর আগেই নিজে থামবে, আর লেখার সময় token — ঠিক এই ধারণাগুলোর সাধারণ রূপ, distributed lock আর lease, পর্ব ৪৩-এ।

Lease-এর চেয়ে লম্বা কাজ, কিন্তু heartbeat নেই। একটি সাধারণ বাস্তব ভুল: visibility timeout ৩০ সেকেন্ড, কাজ লাগে ২ মিনিট, আর worker সময় বাড়ায় না। প্রতি ৩০ সেকেন্ডে job আবার দৃশ্যমান, নতুন worker তোলে — একই রিপোর্ট চারজন একসাথে বানায়, আর চেষ্টার গণনা বেড়ে শেষে সুস্থ job-ই DLQ-তে যেতে পারে।

৫ · সময় ধরে কাজ — cron আর বড় আকারে তার ফাঁদ

এ পর্যন্ত job এসেছে কোনো ঘটনা থেকে — আপলোড, অর্ডার। আরেক ধরনের কাজ আসে ঘড়ি থেকে: প্রতি রাতে বিক্রির রিপোর্ট, প্রতি ঘণ্টায় মেয়াদোত্তীর্ণ session মোছা, প্রতি মাসের ১ তারিখে subscription-এর বিল। এই দায়িত্ব scheduler-এর, আর তার সবচেয়ে পুরোনো, সবচেয়ে পরিচিত রূপ cron.

# min hour day-of-month month day-of-week   command
0     0    *            *     *             /opt/app/nightly_report     # every day at 00:00
*/15  *    *            *     *             /opt/app/expire_sessions    # every 15 minutes
0     3    1            *     *             /opt/app/monthly_billing    # 03:00 on the 1st

Cron এসেছে ১৯৭০-এর দশকের Unix থেকে; Linux-এর প্রচলিত cron-এর শিকড় Paul Vixie-র ১৯৮৭ সালের সংস্করণে। Kubernetes-এর CronJob-সহ প্রায় সব scheduler আজও সেই পাঁচ ঘরের ভাষা নেয়। কিন্তু cron বানানো হয়েছিল একটি মেশিনের জন্য — হাজারো tenant আর একাধিক সার্ভারের যুগে তার চারটি ফাঁদ।

ফাঁদ ১ · সবাই রাত ১২টায় — thundering herd

একটি SaaS অ্যাপে ১০,০০০ tenant-এর প্রত্যেকের রাতের রিপোর্ট 0 0 * * *-এ রাখলে ঠিক ০০:০০:০০-এ ১০,০০০ job একসাথে — একটি thundering herd. নিচের simulator একই ১০,০০০ job তিনভাবে চালায়: সবাই ০০:০০-এ, অথবা tenant id-এর hash দিয়ে এক ঘণ্টা বা ১০ মিনিটে ছড়ানো। Worker-রা মিলে সেকেন্ডে ৪০টি job শেষ করে (ধরা যাক)।

// Cron herd: 10,000 tenants' nightly report. All at 00:00:00, or spread by a hash of the tenant id?
function fnv1a(str) {                        // FNV-1a, 32-bit
  let h = 0x811c9dc5;
  for (let i = 0; i < str.length; i++) h = Math.imul(h ^ str.charCodeAt(i), 0x01000193) >>> 0;
  return h;
}
const TENANTS = 10000, WINDOW = 3600;        // spread window: 00:00 to 01:00 (seconds)
const CAP = 40;                              // workers finish 40 jobs per second (assumed)

function run(name, startOf) {
  const arrivals = new Array(WINDOW).fill(0);
  for (let i = 0; i < TENANTS; i++) arrivals[startOf("tenant-" + i)]++;
  let backlog = 0, maxBacklog = 0, sec = 0, done = 0;
  while (done < TENANTS) {                   // one loop = one second of the night
    backlog += sec < WINDOW ? arrivals[sec] : 0;
    maxBacklog = Math.max(maxBacklog, backlog);
    const now = Math.min(CAP, backlog);
    backlog -= now; done += now; sec++;
  }
  const busiest = Math.max(...arrivals);
  console.log(name.padEnd(22) + String(busiest).padStart(7) + String(maxBacklog).padStart(9) +
    String(Math.ceil(maxBacklog / CAP)).padStart(10) + " s" + String(sec).padStart(8) + " s");
}
console.log("schedule              max/s  backlog  worst wait  all done");
run("all at 00:00:00", () => 0);
run("hash % 3600 s", id => fnv1a("nightly-report:" + id) % WINDOW);
run("hash % 600 s", id => fnv1a("nightly-report:" + id) % 600);

আউটপুট:

schedule              max/s  backlog  worst wait  all done
all at 00:00:00         10000    10000       250 s     250 s
hash % 3600 s               9        9         1 s    3600 s
hash % 600 s               28       28         1 s     600 s

সবাই ০০:০০-এ হলে সর্বোচ্চ চাপ এক সেকেন্ডে ১০,০০০ — সারিতে ১০,০০০ জমে, আর সেকেন্ডে ৪০টি করে শেষ হলে শেষ job শুরু হয় প্রায় ২৫০ সেকেন্ড পরে; ততক্ষণ সব worker ১০০% ব্যস্ত, আর একসাথে এত রিপোর্ট-query database-কে চাপে। Hash দিয়ে এক ঘণ্টায় ছড়ালে গড় ১০,০০০ ÷ ৩,৬০০ ≈ ২.৭৮/s, আর সবচেয়ে ব্যস্ত সেকেন্ডেও মাত্র ৯টি; সারি কখনো ৪০ ছাড়ায় না, তাই কেউ অপেক্ষা করে না। ১০ মিনিটে ছড়ালে গড় ≈ ১৬.৭, সর্বোচ্চ ২৮ — এখনো সামর্থ্যের মধ্যে, আর সব শেষ ১০ মিনিটে। দাম: শেষ tenant-এর রিপোর্ট আসে প্রায় এক ঘণ্টা (বা ১০ মিনিট) পরে — সেই প্রতিশ্রুতি আগেই ঠিক করুন।

Hash কেন, random কেন নয়? Hash একই tenant-কে প্রতিদিন একই সময় দেয়, তাই রিপোর্ট আসে অনুমানযোগ্য সময়ে। একই চিন্তা cache-এর মেয়াদে TTL jitter (পর্ব ২৯) আর client-দের retry-তে jitter.

ফাঁদ ২ · আগের run শেষ হয়নি — overlap

প্রতি ১৫ মিনিটের job একদিন database-এর ধীরতায় ২০ মিনিট নিল — পরের run শুরু, দুটি মিলে আরও ধীর, তৃতীয়টি এল; জমতে জমতে সিস্টেম ডোবে। প্রতিটি scheduled job-এর একটি overlap policy লাগে: দুটিই চলুক (Allow), নতুনটি বাদ (Forbid), নাকি পুরোনোটি থামিয়ে নতুনটি (Replace). Kubernetes CronJob-এ ঠিক এই তিনটি মানের concurrencyPolicy আছে (লেখার সময়ে ডিফল্ট Allow — অর্থাৎ নিরাপদ বিকল্পটি আপনাকেই বেছে নিতে হয়)।

ফাঁদ ৩ · Time zone আর DST

“রাত ২:৩০-এ” — কোন দেশের রাত? আরও সূক্ষ্ম DST: যেসব দেশে গ্রীষ্মে ঘড়ি এক ঘণ্টা এগোয়, সেই রাতে ০২:০০ থেকে সরাসরি ০৩:০০ — “০২:৩০” সময়টাই নেই, তাই local সময়ের job বাদ পড়তে পারে; আর ঘড়ি পেছানোর রাতে ০১:০০–০২:০০ দুবার আসে, job দুবার চলতে পারে। আচরণ implementation-ভেদে আলাদা — যেমন Debian-এর cron-এর manual অনুযায়ী তিন ঘণ্টার কম ঘড়ি-বদলে বাদ পড়া job বদলের পরপরই চালানো হয়, আর পুনরাবৃত্ত সময়ের job আবার চালানো হয় না।

বাংলাদেশে এখন DST নেই (মান সময় UTC+6); ২০০৯ সালে কয়েক মাসের জন্য চালু হয়ে বছর শেষে আবার তুলে দেওয়া হয়েছিল। কিন্তু ব্যবহারকারী বা সার্ভার অন্য দেশে থাকলে, বা নিয়ম বদলালে, ফাঁদটি আপনারও। নিরাপদ অভ্যাস: scheduler আর সব timestamp UTC-তে; ব্যবহারকারীর “আমার সময়ে সকাল ৯টা” দরকার হলে তাঁর time zone-এর নামসহ (যেমন Asia/Dhaka) রাখা, আর DST-এর রাতে কী হবে তা স্পষ্ট লেখা।

ফাঁদ ৪ · Scheduler বন্ধ ছিল — বাদ পড়া run

রাত ১২টায় deploy চলছিল, বা scheduler-এর মেশিন বন্ধ — রাতের রিপোর্ট চলেনি। এখন কী? একটি misfire policy লাগে: বাদ পড়া সব run পরপর চালানো (বিলিংয়ে প্রতিটি মাস জরুরি), শুধু সর্বশেষটি (session মোছায় একবারই যথেষ্ট), নাকি বাদ। Kubernetes CronJob-এ startingDeadlineSeconds ঠিক করে কত দেরি পর্যন্ত বাদ পড়া run তবুও শুরু হবে। আর নকশার একটি ভালো অভ্যাস: job-কে “গত ২৪ ঘণ্টার কাজ করো” না বলে “২০২৬-১০-২৭ তারিখের কাজ করো” বলা — নির্দিষ্ট সময়-জানালার job পরে চালালেও একই ফল দেয়, আর দুবার চালালেও (অংশ ৬)।

৬ · Distributed cron, delayed job আর timer

একটি মেশিনে cron চালালে সেই মেশিনই single point of failure: সে মরলে বিলিং চলে না। স্বাভাবিক প্রতিক্রিয়া — তিনটি সার্ভারেই একই crontab. ফল: প্রতিটি job তিনবার, প্রতিটি গ্রাহকের বিল তিনবার। Scheduler-এর আসল চাহিদা দুটি, আর দুটি একে অপরের বিপরীতে টানে: একটি মেশিন মরলেও সময়সূচি চলবে, অথচ প্রতিটি নির্ধারিত run একবারই শুরু হবে। এই দুটি মেলানোই distributed cron.

একজন নেতা, মেয়াদসহ

প্রচলিত পথ: কয়েকটি scheduler instance চলে, কিন্তু শুধু একজন — leader — job ছাড়ে। কে leader, তা ঠিক হয় leader election দিয়ে, প্রায়ই etcd, ZooKeeper বা একটি database-এর একটি lease-এর মাধ্যমে: যে lease ধরে আছে সে leader, নিয়মিত নবায়ন করে; মরলে lease ফুরায়, অন্য instance leader হয়। থেমে থাকা পুরোনো leader-এর ঝুঁকি আর fencing token-এর পুরো আলোচনা পর্ব ৪৩ — Coordination — distributed lock, lease আর fencing token-এ, আর consensus-এর ভিত্তি পর্ব ৪২-এ।

দ্বিতীয় স্তরের পাহারা — run-এর অনন্য key

Leader election কখনো নিখুঁত নয়, তাই প্রতিটি run-কে একটি অনন্য পরিচয় দিন: job_name + নির্ধারিত সময়, যেমন monthly_billing@2026-11-01T03:00Z. Run শুরুর আগে এই key একটি unique constraint-ওয়ালা টেবিলে লেখা হয়; দুজন leader একসাথে চেষ্টা করলেও একজনই সফল। আর job নিজে যদি নির্দিষ্ট সময়-জানালার কাজ করে (অংশ ৫-এর শেষ অনুচ্ছেদ) ও idempotent হয়, তাহলে বাদ পড়া run পরে চালানো বা ভুলে দুবার চালানো — দুটোই নিরাপদ। মূল চিন্তা: scheduler “একবার” নিশ্চিত করার চেষ্টা করে, কিন্তু নিরাপত্তা আসে job-এর idempotency থেকে।

Delayed job আর timer

সব সময়ভিত্তিক কাজ বারবার ঘটে না। “অর্ডারের ৩০ মিনিট পরেও পরিশোধ না হলে বাতিল করুন”, “ট্রায়াল শেষের ৩ দিন আগে মনে করিয়ে দিন” — এগুলো একবারের delayed job. সরল বাস্তবায়ন অংশ ২-এর query-তেই আছে: job রাখার সময় run_at = এখন + ৩০ মিনিট, আর worker শুধু run_at <= now() তোলে (run_at-এ index থাকলে এটি সস্তা)।

Priority queue (heap)

সবচেয়ে কাছের run_at সবসময় মাথায়; যোগ আর বের করা O(log n). Redis-এর sorted set-এ score = সময় দিয়েও প্রচলিত। Heap-এর গঠন DSA কোর্সের Heap পর্বে।

সাধারণ পছন্দ

Timing wheel

ঘড়ির ডায়ালের মতো একটি গোল array, প্রতিটি ঘর একটি সময়-খণ্ড (যেমন ১ সেকেন্ড); timer রাখা হয় তার ঘরের তালিকায়, কাঁটা প্রতি খণ্ডে এক ঘর এগোয়। যোগ-বাদ প্রায় O(1). Timing wheel (Varghese ও Lauck, ১৯৮৭)।

লাখো timeout-এ

Database poll

প্রতি কয়েক সেকেন্ডে run_at-এর index দেখে পাকা job তোলা — টেকসই আর সরল; memory-র timer restart-এ হারায়।

টেকসই timer

“Scheduling”-এর আরেক অর্থ — কোন process এখন CPU পাবে — OS কোর্সের CPU scheduling-এর ভিত্তি পর্বে।

৭ · Workflow engine — durable execution, event history আর replay

একটি কাল্পনিক ঋণ-আবেদন: আবেদন জমা → নথি যাচাই (বাইরের সেবা) → ঝুঁকির হিসাব → ৫০,০০০ টাকার বেশি হলে একজন ম্যানেজারের অনুমোদন (হয়তো দুই দিন) → চুক্তির PDF → টাকা পাঠানো → SMS. সাতটি ধাপ, কয়েকটি সেবা, কয়েক দিন। কোনো ধাপ ব্যর্থ হলে retry; টাকা পাঠানো ব্যর্থ হলে আগের ধাপগুলোর ক্ষতিপূরণ (saga, পর্ব ২৫)। Job queue দিয়ে বানালে (প্রতিটি ধাপ একটি job, যা পরেরটি রাখে) সব যুক্তি — কোন ধাপে, কতবার চেষ্টা, ম্যানেজার উত্তর না দিলে কী — দশটি handler আর একটি অবস্থার টেবিলে ছড়িয়ে পড়ে। Event দিয়ে সেবাগুলোর নিজে নিজে সমন্বয় (choreography) বনাম কেন্দ্রীয় পরিচালনা (orchestration)-র তুলনা পর্ব ৩২ — Pub/sub আর event-driven architecture-এ।

Workflow engine উল্টো প্রস্তাব দেয়: পুরো প্রক্রিয়াটি একটি সাধারণ function হিসেবে লিখুন — ধাপের পর ধাপ, if-else, loop, এমনকি “দুই দিন অপেক্ষা” — আর engine নিশ্চিত করবে যে function-টি মেশিন মরলেও শেষ পর্যন্ত চলবে। এর নাম durable execution. কৌশলটি তিনটি অংশে:

  • Activity: বাইরের জগতে আসল কাজ করে এমন প্রতিটি ধাপ — API ডাকা, টাকা পাঠানো, ইমেইল — একটি activity. Engine এর timeout, heartbeat আর retry (backoff সহ) সামলায়, অংশ ৩ আর ৪-এর মতোই। Activity অন্তত একবার চলে, তাই idempotent হতে হয়।
  • Event history: প্রতিটি activity ডাকা, তার ফল, প্রতিটি timer, প্রতিটি বাইরের বার্তা — সব একটি শুধু-যোগ-করা event history-তে টেকসইভাবে লেখা। State আলাদা করে রাখা হয় না; history-ই সত্য — event sourcing-এর চিন্তা (পর্ব ৩৫ — Event sourcing আর CQRS).
  • Replay: worker মরলে নতুন worker workflow function শুরু থেকে আবার চালায়। প্রতিটি activity-ডাকার মুহূর্তে engine history দেখে: ফল আগেই আছে? তাহলে কাজটি আবার না করে লেখা ফলটি ফেরত। এই replay-এ function-এর ভেতরের সব variable আবার ঠিক আগের অবস্থায় পৌঁছায়, তারপর বাকি অংশ নতুন করে চলে।

নিচের প্রোগ্রামে এই যন্ত্রের একটি ছোট খেলনা-রূপ: চার ধাপের একটি অর্ডার workflow, প্রথম worker তৃতীয় ধাপের মাঝে crash করে, দ্বিতীয় worker history থেকে replay করে শেষ করে। আসল engine-এর history অনেক বিস্তারিত।

// Tiny durable execution: the event history survives a worker crash; replay skips finished steps.
const history = [{ type: "WorkflowStarted" }];   // the engine's durable, append-only store
const ran = {};                              // how many times each activity REALLY executed
const activities = {                         // side effects live here (other services, email ...)
  reserveStock: o => "hold-" + o, chargePayment: o => "txn-" + o * 3,
  makeInvoice: t => "INV-" + t.slice(4), sendEmail: inv => "mail-for-" + inv,
};
function workflow(call, order) {             // deterministic: same history, same calls, same order
  call("reserveStock", order);
  const txn = call("chargePayment", order);
  const inv = call("makeInvoice", txn);
  call("sendEmail", inv);
}
function worker(name, crashInside) {
  let seq = 0;
  const call = (act, arg) => {
    const n = seq++, done = history.find(e => e.n === n && e.type === "Completed");
    if (done) { console.log(name + " replay  #" + n + " " + act + " = " + done.result); return done.result; }
    if (!history.some(e => e.n === n)) history.push({ n, type: "Scheduled", act });
    ran[act] = (ran[act] || 0) + 1;
    const result = activities[act](arg);     // the real work happens ...
    if (n === crashInside) throw new Error(name + " crashed inside #" + n + " before recording it");
    history.push({ n, type: "Completed", act, result });   // ... and is recorded durably
    console.log(name + " execute #" + n + " " + act + " = " + result);
    return result;
  };
  try { workflow(call, 4217); history.push({ type: "WorkflowCompleted" }); }
  catch (e) { console.log("!! " + e.message); }
}
worker("A", 2);                              // first worker dies while making the invoice
worker("B", -1);                             // engine hands the workflow to another worker
console.log("\nhistory:");
for (const e of history)
  console.log(("  " + e.type.padEnd(18) + (e.n ?? "") + " " + (e.act || "") + " " + (e.result || "")).trimEnd());
console.log("real executions: " + JSON.stringify(ran));

আউটপুট:

A execute #0 reserveStock = hold-4217
A execute #1 chargePayment = txn-12651
!! A crashed inside #2 before recording it
B replay  #0 reserveStock = hold-4217
B replay  #1 chargePayment = txn-12651
B execute #2 makeInvoice = INV-12651
B execute #3 sendEmail = mail-for-INV-12651

history:
  WorkflowStarted
  Scheduled         0 reserveStock
  Completed         0 reserveStock hold-4217
  Scheduled         1 chargePayment
  Completed         1 chargePayment txn-12651
  Scheduled         2 makeInvoice
  Completed         2 makeInvoice INV-12651
  Scheduled         3 sendEmail
  Completed         3 sendEmail mail-for-INV-12651
  WorkflowCompleted
real executions: {"reserveStock":1,"chargePayment":1,"makeInvoice":2,"sendEmail":1}

Worker A প্রথম দুটি ধাপ সত্যিই চালায়, প্রতিটির ফল history-তে লেখে। তৃতীয় ধাপে invoice বানানো হয়েছিল, কিন্তু “Completed” লেখার আগেই crash — history-তে শুধু Scheduled 2. Worker B একই function শুরু থেকে চালায়: #০ আর #১-এর ফল history-তে আছে, তাই “replay” — stock আবার আটকানো হয়নি, টাকা আবার কাটা হয়নি, কিন্তু txn variable ঠিক আগের মান txn-12651 ফিরে পেয়েছে। #২-এর কোনো Completed নেই, তাই আবার চালানো — শেষ লাইনে makeInvoice: 2: invoice সেবা সত্যিই দুবার ডাকা হয়েছে। এটিই at-least-once-এর দাম, আর এজন্যই activity-কে idempotent হতে হয় (যেমন invoice সেবা order 4217-এর জন্য একটিই invoice রাখে)।

কেন workflow কোড deterministic হতে হবে

ধরুন workflow-এর ভেতরে লেখা: if (Math.random() < 0.5) call("A") else call("B"). প্রথম run-এ A ডাকা হয়েছিল, history-তে A-র ফল। Replay-তে random এবার B বলল — কোড এখন B-এর ফল চায়, history-তে আছে A-র। Engine এই অমিল ধরে ত্রুটি দেয় (non-determinism). একই বিপদ সরাসরি ঘড়ি পড়ায় (new Date()-এর মান replay-তে আলাদা), network বা ফাইল পড়ায়, আর map-এর ক্রমহীন iteration-এ। তাই নিয়ম: workflow কোড deterministic; বাইরের জগতের সবকিছু activity-তে, আর সময়, random বা ঘুম engine-এর দেওয়া API দিয়ে, যা প্রথমবারের মান history-তে লিখে replay-তে সেটাই ফেরত দেয়।

কোড বদলালেও একই সমস্যা: তিন দিন ধরে চলা একটি workflow-এর মাঝে আপনি একটি নতুন ধাপ যোগ করে deploy করলেন; পুরোনো history-র replay নতুন কোডে মেলে না। তাই workflow versioning — বদলের জায়গায় একটি version চিহ্ন রেখে পুরোনো চলমান workflow-গুলোকে পুরোনো পথে চলতে দেওয়া।

Timer, signal আর মানুষের অপেক্ষা

দুই দিন অপেক্ষা workflow-এ একটি লাইন: await sleep("2 days"). এটি thread আটকে রাখে না — engine history-তে একটি durable timer লেখে, worker অন্য কাজে যায়; সময় হলে engine workflow-কে আবার জাগায়, replay করে, আর কোড ঘুমের পরের লাইন থেকে চলে। ম্যানেজারের অনুমোদন আসে একটি signal হিসেবে — বাইরে থেকে পাঠানো একটি টেকসই বার্তা, যা history-তে লেখা হয়। “অনুমোদনের signal অথবা ৪৮ ঘণ্টা, যেটি আগে” — এই human-in-the-loop প্রক্রিয়া কয়েক লাইনের কোড।

Order 4217 · চার ধাপ: reserveStock → chargePayment → makeInvoice → sendEmail
Worker
কোড কোন ধাপে
History-র শেষ event
আসল execution
  1. A চালায় #০. History-তে WorkflowStarted. Worker A function চালায়; reserveStock আগে কখনো হয়নি, তাই Scheduled লেখা, stock আটকানো, ফল hold-4217 Completed হিসেবে লেখা।
  2. A চালায় #১. chargePayment — টাকা কাটা হলো, ফল txn-12651 history-তে।
  3. Crash. #২-এর Scheduled লেখা হলো, invoice সেবা invoice বানাল — কিন্তু ফল লেখার আগেই A-র মেশিন বন্ধ। History-তে শুধু Scheduled 2. Engine জানে না invoice হয়েছিল কি না।
  4. B-এর replay. Activity-র timeout বা worker-এর heartbeat না পেয়ে engine workflow-টি B-কে দেয়। B function শুরু থেকে চালায়: #০ আর #১-এর ফল history-তে — কিছুই আবার করা হয় না, শুধু ফল ফেরত; txn-এ আবার txn-12651.
  5. #২ আবার চলে। #২-এর Completed নেই, তাই activity আবার চলে — invoice সেবা দ্বিতীয়বার ডাকা। Idempotent হলে (order 4217-এর জন্য একটিই invoice) ক্ষতি নেই; না হলে দুটি invoice. এবার Completed 2 লেখা হলো।
  6. শেষ। #৩ sendEmail প্রথমবার চলে, তারপর WorkflowCompleted. মোট: তিনটি ধাপ একবার করে, একটি দুবার। শর্ত: activity idempotent, কোড deterministic.

৮ · Data pipeline-এর DAG, আর কোনটি কখন — queue, cron না workflow engine

Workflow engine-এর একটি আলাদা পরিবার data pipeline-এর জন্য। প্রতি রাতে: বিক্রির টেবিল থেকে ডেটা তোলা → পরিষ্কার করা → গ্রাহক আর পণ্যের টেবিলের সাথে জোড়া → দৈনিক সারাংশ → dashboard হালনাগাদ, আর পাশাপাশি ঝুঁকির একটি মডেলের জন্য আলাদা হিসাব। এখানে কাজগুলোর মধ্যে নির্ভরতা একটি DAG — দিকযুক্ত, চক্রহীন graph: তীর মানে “এটি শেষ হলে তবে ওটি”, আর চক্র নেই বলে topological ক্রমে চালানো যায় (DSA কোর্সের Topological sort).

Apache Airflow এই ধাঁচের পরিচিত open-source উদাহরণ — ২০১৪ সালে Airbnb-তে শুরু, পরে Apache Software Foundation-এর প্রকল্প। এখানে সবচেয়ে গুরুত্বপূর্ণ নকশা-নীতি অংশ ৫-এর শেষ অভ্যাসটিই: প্রতিটি task একটি নির্দিষ্ট তারিখের (logical date) কাজ করে, আর সেই তারিখের output পুরোটা নতুন করে লেখে (“২০২৬-১০-২৭-এর partition মুছে আবার লেখা” ধাঁচ)। তাহলে task দুবার চললেও একই ফল, আর পুরোনো তারিখগুলোর জন্য আবার চালানো — backfill — নিরাপদ। Batch আর stream processing-এর গভীর আলোচনা পর্ব ৩৬ — Batch আর stream processing-এ।

# Illustrative only: a nightly sales pipeline as a DAG of idempotent, date-partitioned tasks
with DAG("nightly_sales", schedule="30 0 * * *", catchup=True, max_active_runs=1) as dag:
    extract   = extract_sales(ds="{{ ds }}")       # rewrite partition for the logical date
    clean     = clean_sales(ds="{{ ds }}")
    customers = load_customers(ds="{{ ds }}")
    summary   = build_daily_summary(ds="{{ ds }}")
    dashboard = refresh_dashboard(ds="{{ ds }}")
    extract >> clean >> summary >> dashboard
    customers >> summary                           # summary waits for both branches

schedule একটি cron expression; catchup হলো misfire policy; max_active_runs=1 হলো overlap policy; আর ds প্রতিটি task-কে তার তারিখ দেয় — idempotency-র ভিত্তি।

DAG engine বনাম durable execution engine

দুটোকেই “workflow” বলা হয়, তাই গুলিয়ে যায়। DAG engine (Airflow-ধাঁচ) সাধারণত আগে থেকে জানা, স্থির আকারের graph চালায় — প্রতিটি task মিনিট বা ঘণ্টার, প্রায়ই সময়সূচি ধরে, ডেটা-কেন্দ্রিক। Durable execution engine (Temporal- বা Cadence-ধাঁচ) চালায় কোডে লেখা যেকোনো যুক্তি — loop, শর্ত, signal, মাসব্যাপী timer — প্রতিটি ব্যবসায়িক ঘটনার (অর্ডার, আবেদন) জন্য আলাদা একটি workflow, একসাথে হয়তো লাখো। AWS Step Functions-এর মতো managed সেবা এর মাঝামাঝি: workflow একটি JSON-ভিত্তিক state machine হিসেবে লেখা হয় (সীমা ও দাম লেখার সময়ের documentation দেখে নিন)।

বেছে নেওয়া — সবচেয়ে সরলটি দিয়ে শুরু

প্রশ্নJob queue + workerScheduler / cronWorkflow engine
কাজ শুরু হয়কোনো ঘটনা থেকে (আপলোড, অর্ডার)ঘড়ি থেকেঘটনা বা ঘড়ি — তারপর বহু ধাপ
ধাপএকটি, স্বাধীনএকটি (বা একটি job ছাড়া)অনেক, নির্ভরশীল, শাখাসহ
সময়কালসেকেন্ড থেকে মিনিটযেকোনোমিনিট থেকে মাস; মানুষের অপেক্ষাসহ
অবস্থা কোথায়job-এর ভেতরে (চেষ্টা, run_at)সময়সূচি আর শেষ runevent history — engine-এর দায়
ব্যর্থতায় ক্ষতিপূরণনিজে লিখতে হয়নিজে লিখতে হয়কোডে স্বাভাবিক try/catch-এ saga
দামসবচেয়ে সরল, পরিচিতসরল; বড় আকারে চারটি ফাঁদনতুন একটি সিস্টেম চালানো, determinism-এর শৃঙ্খলা, শেখার সময়

একটি ব্যবহারিক নিয়ম: স্বাধীন একক কাজে job queue; ঘড়ি-ধরা কাজে scheduler; আর যখন দেখবেন job-গুলো একে অপরকে ডাকছে, অবস্থার টেবিলে “step” কলাম জন্মেছে, আর “তিন দিন পরে যদি…” ধরনের যুক্তি পাঁচ জায়গায় ছড়িয়ে আছে — তখন workflow engine-এর কথা ভাবুন।

আরও গভীরে: Uber ২০১৭ নাগাদ Cadence নামে একটি durable execution engine open source করে; এর মূল নির্মাতাদের কয়েকজন ২০১৯ নাগাদ এর একটি fork থেকে Temporal শুরু করেন — দুটিই ওপরের replay-ভিত্তিক মডেল ব্যবহার করে। Google-এর SRE বইয়ের (২০১৬) একটি অধ্যায় আর ACM Queue-র “Reliable Cron across the Planet” (২০১৫) বর্ণনা করে কীভাবে Google-এর distributed cron Paxos দিয়ে leader আর প্রতিটি launch-এর অবস্থা replicate করত, যাতে leader বদলেও একটি run দুবার শুরু না হয়। Timing wheel-এর মূল paper Varghese ও Lauck-এর “Hashed and Hierarchical Timing Wheels” (SOSP ১৯৮৭)। আর backoff-এ jitter-এর বিভিন্ন রূপের তুলনা AWS Architecture Blog-এ Marc Brooker-এর “Exponential Backoff And Jitter” (২০১৫)-এ।

পরীক্ষা ও ইন্টারভিউতে — বিশ্ববিদ্যালয়, চাকরি বা system design ইন্টারভিউর পরীক্ষায় এই ধরনের প্রশ্ন আসতে পারে:

  • Job queue কেন at-least-once, আর তাতে কী করবেন? Effect-এর পরে কিন্তু ack-এর আগে crash হলে সারি আবার দেয়; job idempotent করুন — নির্দিষ্ট নামে লেখা, idempotency key, unique constraint.
  • Full jitter-এ তৃতীয় ব্যর্থতার পরে অপেক্ষা কত (base ২ s, cap ৬০ s)? random(০, min(৬০, ২ × ২³)) = ০ থেকে ১৬ সেকেন্ড।
  • Visibility timeout-এর চেয়ে লম্বা কাজ হলে? Job আবার দৃশ্যমান হয়ে দ্বিতীয় worker তোলে; heartbeat দিয়ে lease বাড়ান, আর fencing token বা idempotent লেখা।
  • তিন সার্ভারে একই cron চালালে সমস্যা কী, সমাধান কী? প্রতিটি run তিনবার; leader election বা lease দিয়ে একজন scheduler, আর job + নির্ধারিত সময়-এর unique key.
  • Workflow কোড deterministic কেন? Crash-এর পরে replay একই history-তে একই ধাপ একই ক্রমে ডাকবে, তবেই লেখা ফলগুলো ঠিক জায়গায় বসে; ঘড়ি, random, I/O তাই activity বা engine-এর API-তে।

৯ · AI-এর যুগে — job আর workflow-এর নকশা যাচাই

“ইমেইল পাঠানো ধীর, কী করব?” — AI প্রায় সবসময় বলবে, “Kafka বা RabbitMQ বসান, worker বানান, retry দিন।” দিক ঠিক, কিন্তু আসল কঠিন প্রশ্নগুলোতে উত্তর অস্পষ্ট। এই বিষয়ে AI-এর সাধারণ ভুলগুলো:

AI-এর ভুলকেন ভুলকীভাবে ধরবেন
ছোট কাজে “Kafka, Redis আর microservices”, বা দুই ধাপে পূর্ণ workflow engineDatabase-এর একটি job টেবিলেই হয়তো যথেষ্ট; প্রতিটি নতুন সিস্টেম চালানোর দাম আছেJob/s আর সময়কাল লিখে সবচেয়ে সরল বিকল্পের সীমা হিসাব করুন
“Exactly-once delivery নিশ্চিত”Effect আর ack-এর মাঝের crash থেকেই যায়; প্রতিশ্রুতি আসলে idempotency-রপ্রশ্ন: “ইমেইল পাঠানোর পরে আর ack-এর আগে crash হলে?”
Jitter ছাড়া backoff, সীমাহীন retry, স্তরে স্তরে retryএকসাথে ফেরা ঢল, poison job চিরকাল, ৩ × ৫ × ৩ গুণ চাপঅংশ ৩-এর মতো একটি seeded simulation চালান
Cron-এ time zone, DST, overlap আর বাদ পড়া run-এর কথা নেইবড় আকারে এগুলোই আসল বিভ্রাটচারটি ফাঁদের তালিকা ধরে failure-mode পর্যালোচনা
ACID-এর আর CAP-এর “consistency” গুলিয়ে ফেলাJob-এর প্রশ্ন সাধারণত দুটি সিস্টেমে লেখার পারমাণবিকতা (outbox) আর কপির staleness — আলাদা জিনিসজিজ্ঞাসা করুন: কোন অর্থে? (পর্ব ২৪ আর ৪০)

AI-কে কাজটি এমনভাবে দিন যাতে প্রতিটি সিদ্ধান্তের পেছনে সংখ্যা, ব্যর্থতার ক্ষেত্র আর বিকল্প দেখাতে হয়:

তুমি একজন অভিজ্ঞ backend ইঞ্জিনিয়ার। একটি কাল্পনিক অনলাইন দোকানের background job ব্যবস্থার নকশা করো। তথ্য (ধরে নেওয়া): peak-এ ঘণ্টায় ২ লাখ ছবি আপলোড, প্রতিটির thumbnail বানাতে গড়ে ১.৫ সেকেন্ড; ইমেইল সেবা মাঝে মাঝে ৫০৩ দেয়; ১০,০০০ বিক্রেতার প্রত্যেকের জন্য রাতের বিক্রির রিপোর্ট; অর্ডার-পূরণে ৫টি ধাপ, একটিতে মানুষের অনুমোদন। ১. কোন কাজ job queue, কোনটি scheduler, কোনটি workflow engine-এ — কারণসহ; সবচেয়ে সরল বিকল্প আগে। ২. Worker-এর সংখ্যা হিসাব করো (retry-র বাড়তি চেষ্টাসহ); প্রতিটি অনুমান আলাদা লেখো। ৩. Retry নীতি: কোন ত্রুটিতে retry, backoff আর jitter-এর সূত্র, max attempts, DLQ আর তার নজরদারি। ৪. প্রতিটি job কীভাবে idempotent হবে; effect-এর পরে কিন্তু ack-এর আগে crash হলে কী হবে। ৫. রাতের রিপোর্টে thundering herd, overlap, time zone ও DST আর বাদ পড়া run কীভাবে সামলাবে। কোনো product-এর সীমা বা ডিফল্ট বললে সূত্র দাও; নিশ্চিত না হলে বলো "জানি না"।

AI-এর job নকশা হাতে পেলে — যাচাইয়ের চেকলিস্ট

AI দেয় দ্রুত খসড়া, যাচাইয়ের দায় আপনার।
চেষ্টা গুনুন, দুবার চালিয়ে দেখুন, ঘড়ির ফাঁদ খুঁজুন।

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। সূত্র মুখস্থ নয়, কেন কোন নকশা বেছে নেওয়া হয় সেটাই আসল।

প্র ০১Job কেন “ঠিক একবার” চালানো যায় না? তাহলে টাকা পাঠানোর মতো কাজ কীভাবে নিরাপদে background-এ চালাবেন?

সমস্যাটি একটি মুহূর্তে লুকিয়ে: worker কাজটি করল (টাকা পাঠাল), তারপর সারিকে জানানোর (ack) আগে crash. Worker যে মেশিনে চলছিল সে নেই; সারি শুধু জানে ack আসেনি। কাজটি হয়েছিল কি না জানার কোনো উপায় সারির কাছে নেই — network-এর ওপাশে কী ঘটেছে, তা এপাশ থেকে নিশ্চিত হওয়া যায় না (পর্ব ৩৮-এ partial failure). সারির সামনে দুটি পথ: আবার দেওয়া (কাজ দুবার হতে পারে) বা না দেওয়া (কাজ হারাতে পারে)। প্রায় সব ব্যবস্থা প্রথমটি বেছে নেয়, কারণ হারানো কাজ খুঁজে পাওয়া কঠিন, দ্বিতীয় কপি চেনা তুলনায় সহজ।

তাই “exactly-once” শব্দটি যেখানে দেখবেন, সাধারণত তার মানে effectively once: বার্তা একাধিকবার আসতে পারে, কিন্তু ফল একবারের। Kafka-র transaction-এর মতো ব্যবস্থা একটি নির্দিষ্ট গণ্ডির ভেতরে (পড়া, প্রক্রিয়া, একই সিস্টেমে লেখা) এটি দেয়; কিন্তু বাইরের পেমেন্ট গেটওয়ে বা ইমেইল সেবা সেই গণ্ডির বাইরে।

টাকা পাঠানোর job-এর জন্য তাই কয়েক স্তরের পাহারা: (১) job-এর id থেকে একটি idempotency key, যা গেটওয়েকে পাঠানো হয় — অনেক পেমেন্ট API প্রকাশ্যে এই সুবিধা দেয়, দ্বিতীয় অনুরোধে আগের ফলটিই ফেরত আসে; (২) নিজের database-এ একটি payment সারি pending অবস্থায় আগে লেখা, unique key-সহ, তারপর গেটওয়ে ডাকা, তারপর succeeded; (৩) অনিশ্চিত অবস্থায় (timeout) আবার পাঠানোর আগে গেটওয়ের কাছে সেই key-এর অবস্থা জিজ্ঞাসা করা; (৪) প্রতিদিন গেটওয়ের হিসাবের সাথে নিজের খাতা মেলানো (reconciliation) — কারণ সব পাহারার পরেও কোনো ফাঁক থাকতে পারে। মুদি দোকানের খাতার মতো: দোকানি প্রতি রাতে খাতার যোগফল ক্যাশবাক্সের সাথে মেলান, ভুল ধরা পড়ে পরদিন সকালে, মাস শেষে নয়।

মূল উপলব্ধি: at-least-once একটি প্রকৃতির নিয়ম, নকশার ব্যর্থতা নয়। প্রশ্নটি “কীভাবে একবার চালাব” থেকে বদলে করুন “দুবার চললে কী হবে, আর সেটা কীভাবে নিরীহ করব”।

প্র ০২Google কেন তার distributed cron-এ Paxos ব্যবহার করেছিল? একটি cron job একবার চালাতে এত আয়োজন কেন?

Google-এর SRE বইয়ের (২০১৬) “Distributed Periodic Scheduling with Cron” অধ্যায় আর ACM Queue-তে প্রকাশিত “Reliable Cron across the Planet” (২০১৫) লেখায় এই নকশার বর্ণনা আছে। প্রকাশিত বিবরণ অনুযায়ী মূল চ্যালেঞ্জ দুটি ছিল: একটি মেশিনের cron সেই মেশিনের সাথে মরে, আর বহু মেশিনে চালালে একই run বহুবার। সমাধানে কয়েকটি replica একটি Paxos-ভিত্তিক গোষ্ঠী গঠন করত; শুধু leader job ছাড়ত, আর প্রতিটি launch-এর আগে ও পরে তার অবস্থা (“শুরু হতে যাচ্ছে”, “শুরু হয়েছে”) গোষ্ঠীতে replicate করা হতো। Leader বদলালে নতুন leader সেই খাতা দেখে জানত কোন run শুরু হয়েছিল, কোনটি অসমাপ্ত।

লেখাটির একটি সূক্ষ্ম স্বীকারোক্তি সবচেয়ে শিক্ষণীয়: leader launch-এর মাঝখানে মরলে নতুন leader নিশ্চিত হতে পারে না job আসলে শুরু হয়েছিল কি না — ঠিক প্র ০১-এর সমস্যা। তাই প্রতিটি job-কে বেছে নিতে বলা হয়েছিল: বাদ পড়া ভালো (at-most-once — যেমন একটি মেইলিং) নাকি দুবার চলা (at-least-once — যেমন garbage collection-এর মতো idempotent পরিষ্কার)। আর launch-এর নাম নির্ধারিত সময় থেকে তৈরি করা হতো, যাতে বাইরের সিস্টেমে দুবার শুরু চেনা যায় — অংশ ৬-এর “run-এর অনন্য key”-র চিন্তা। আরেকটি পর্যবেক্ষণও লেখায় আছে: মানুষ “প্রতিদিন রাত ১২টায়” লেখে, তাই হাজারো job একই মুহূর্তে; সমাধানে schedule-এর ভাষায় “এই সময়-জানালার যেকোনো সময়ে” বলার সুবিধা রাখা হয়েছিল, যাতে scheduler নিজে ছড়িয়ে দেয় — অংশ ৫-এর hash-ছড়ানোর সমতুল্য।

এত আয়োজন কেন? Cron job প্রায়ই সিস্টেমের সবচেয়ে উপেক্ষিত অংশ, অথচ বিলিং, backup, ডেটা মোছার আইনি বাধ্যবাধকতা — এগুলো cron-এ চলে। একটি বাদ পড়া backup কেউ টের পায় না, যতদিন না restore লাগে।

মূল উপলব্ধি: distributed cron আসলে একটি consensus-এর সমস্যা, সময়সূচির নয়; আর consensus-এর পরেও শেষ নিরাপত্তা আসে job-এর নিজের idempotency থেকে।

প্র ০৩Temporal বা Cadence-ধাঁচের engine অবস্থা সরাসরি save না করে কেন পুরো history replay করে? এর দাম কী?

বিকল্পটি ভাবুন: প্রতিটি ধাপের পরে workflow-এর পুরো অবস্থা — সব variable, call stack, কোন লাইনে আছে — একটি snapshot হিসেবে save করা। সাধারণ ভাষায় চলমান function-এর stack serialize করা কঠিন আর ভাষাভেদে প্রায় অসম্ভব। Replay-র কৌশল এই সমস্যা পাশ কাটায়: অবস্থা save করার দরকার নেই, কারণ deterministic কোড আর ধাপগুলোর লেখা ফল থাকলে অবস্থা যেকোনো সময় নতুন করে গড়া যায়। এটি event sourcing-এর সেই পুরোনো চিন্তা (পর্ব ৩৫): event-ই সত্য, অবস্থা তার থেকে হিসাব।

Cadence ২০১৭ নাগাদ Uber open source করে; তার নির্মাতাদের কয়েকজন পরে Temporal শুরু করেন (২০১৯ নাগাদ, Cadence-এর একটি fork থেকে)। Microsoft-এর Durable Functions-এও replay-ভিত্তিক মডেল। দামগুলো বাস্তব:

  • Determinism-এর শৃঙ্খলা: অংশ ৭-এর নিয়ম ভাঙলে replay-তে ত্রুটি; নতুন দলের সবচেয়ে সাধারণ ভুল।
  • History-র আকার: প্রতিটি ধাপ history বাড়ায়; বছরব্যাপী loop চালানো workflow-এর history অসীম হতে পারে। Engine-গুলো সীমা রাখে (Temporal-এর documentation-এ লেখার সময়ে প্রায় ৫০,০০০ event বা ৫০ MB-এর কাছাকাছি সীমার উল্লেখ আছে — সংস্করণভেদে যাচাই করুন), আর সমাধান “continue-as-new”: অবস্থা নিয়ে একটি নতুন, খালি history-র workflow শুরু।
  • Versioning: চলমান workflow-এর কোড বদলানো সাবধানে, version চিহ্ন দিয়ে।
  • নতুন একটি সিস্টেম: engine-এর নিজের database, cluster, monitoring, upgrade — বা একটি managed সেবার খরচ।

লাভটিও বিশাল: retry, timer, timeout, saga-র ক্ষতিপূরণ — যা আগে দশটি handler আর একটি অবস্থার টেবিলে ছড়ানো ছিল, তা এখন একটি function-এ পড়া যায়, পরীক্ষা করা যায়।

মূল উপলব্ধি: replay অবস্থা save করার সমস্যাকে কোড deterministic রাখার সমস্যায় বদলে দেয় — দ্বিতীয়টি শৃঙ্খলা দিয়ে মেটানো যায়, প্রথমটি প্রায়ই যায় না।

প্র ০৪Database-এর একটি টেবিলকেই job queue বানানো কি খারাপ অভ্যাস? কখন আলাদা broker লাগে?

অনেক বছর এটিকে “anti-pattern” বলা হতো: database সারি হিসেবে বানানো নয়, অনেক worker একই সারির জন্য lock-এ লড়ে, poll করা অপচয়। দুটি পরিবর্তন ছবিটা বদলেছে। প্রথমত SKIP LOCKED (PostgreSQL ৯.৫ আর MySQL ৮.০ থেকে) — worker-রা একে অপরের lock-এর জন্য অপেক্ষা না করে আলাদা সারি পায়, তাই lock-এর লড়াই প্রায় নেই। দ্বিতীয়ত বোঝাপড়া যে transaction-এর সুবিধা বিশাল: অর্ডার লেখা আর “ইমেইল পাঠানো”-র job রাখা একই transaction-এ — হয় দুটোই, নয় কোনোটিই নয়। আলাদা broker-এ এটির জন্য outbox লাগে (পর্ব ২৫)। Ruby-র GoodJob বা Elixir-এর Oban-এর মতো library এই ধাঁচের ওপর বানানো।

সীমাগুলো তবু আছে। প্রতিটি job কয়েকবার লেখা হয় (রাখা, তোলা, শেষ), তাই খুব বেশি হারে — মোটামুটি হাজারো job প্রতি সেকেন্ডের ওপরে, hardware আর নকশাভেদে — database-এর write আর পরিষ্কারের (PostgreSQL-এ vacuum) চাপ বাড়ে, আর সেই চাপ মূল ব্যবসার query-কেও ধীর করে। Poll-এর বিরতি latency যোগ করে (LISTEN/NOTIFY-এর মতো সুবিধা তা কমায়)। আর একই job অনেক সেবার শোনার দরকার হলে (fan-out) বা দিনের পর দিন event আবার পড়ার দরকার হলে টেবিল ঠিক জিনিস নয় — তখন pub/sub বা log-ভিত্তিক stream (পর্ব ৩২, ৩৩)।

একটি বাস্তবসম্মত পথ: শুরুতে database-ভিত্তিক job টেবিল — একটি সিস্টেম কম, transaction-এর নিরাপত্তা বেশি; metric রাখুন (সারির দৈর্ঘ্য, পুরোনো job-এর বয়স, প্রতি সেকেন্ডে job, টেবিলের আকার); আর সীমার কাছে পৌঁছালে বা fan-out লাগলে broker-এ সরান। ঈদের আগে বাসের কাউন্টারের মতো: সাধারণ দিনে একটি খাতাই যথেষ্ট, ভিড়ের মৌসুমে আলাদা টোকেন-ব্যবস্থা।

মূল উপলব্ধি: “সঠিক” সারি সেটিই যা আপনার মাপের চাপ সামলায় আর সবচেয়ে কম নতুন ব্যর্থতার পথ খোলে। মাপুন, তারপর বদলান।

প্র ০৫Data pipeline-এর task-কে কেন তারিখভিত্তিক আর idempotent রাখার কথা বলা হয়? Backfill-এ এর ভূমিকা কী?

Maxime Beauchemin — Airflow-এর প্রথম নির্মাতা — ২০১৮ সালের দিকে “Functional Data Engineering” নামে একটি লেখায় এই নীতিগুলো জনপ্রিয় করেন। মূল চিন্তা functional programming-এর বিশুদ্ধ function থেকে: একটি task একটি নির্দিষ্ট ইনপুট-partition (যেমন ২০২৬-১০-২৭-এর বিক্রি) নেয় আর একটি নির্দিষ্ট output-partition পুরোটা নতুন করে লেখে — যোগ করে না, বদলায় না, প্রতিস্থাপন করে। এর তিনটি ফল:

  • Retry নিরাপদ: task অর্ধেক লিখে মরলে আবার চালালে partition আবার পুরো লেখা হয় — দ্বিগুণ সারি নেই।
  • Backfill সম্ভব: ঝুঁকির মডেলে একটি bug পেলেন, গত ৯০ দিনের হিসাব ভুল। Bug ঠিক করে ৯০টি তারিখের জন্য DAG চালান — প্রতিটি তার partition প্রতিস্থাপন করে। “গত ২৪ ঘণ্টা” ধরনের task-এ এটি অসম্ভব, কারণ “গত” মানে আজকের ঘড়ি।
  • পুনরুৎপাদন: একই ইনপুট-partition আর একই কোডে একই ফল — অডিট আর তদন্তে অমূল্য।

দাম আছে: পুরো partition আবার লেখা বড় টেবিলে খরচসাপেক্ষ, তাই partition-এর মাপ (দিন, ঘণ্টা) ভেবে ঠিক করতে হয়; দেরিতে আসা ডেটা (গতকালের একটি লেনদেন আজ এল) পুরোনো partition আবার চালানোর কারণ হয় — stream processing-এর watermark-এর চিন্তা (পর্ব ৩৬)। আর উৎস-ডেটা নিজেই বদলে গেলে (মুছে ফেলা সারি) একই ইনপুট আর পাওয়া যায় না; তাই কাঁচা ডেটার অপরিবর্তনীয় কপি রাখার অভ্যাস। Data warehouse-এর partition নিয়ে আরও DBMS কোর্সের Data warehousing পর্বে।

মূল উপলব্ধি: যে task তারিখ নেয় আর partition প্রতিস্থাপন করে, তাকে যতবার খুশি, যেকোনো দিন চালানো যায় — retry, backfill আর অডিট তিনটিই তখন একটি সাধারণ কাজ।

অনুশীলন

আগে নিজে হিসাব করুন বা লিখুন, তারপর উত্তর মেলান। তৃতীয় প্রশ্নে আগে অনুমান, তারপর অংশ ৭-এর প্রোগ্রাম বদলে চালিয়ে যাচাই।

  1. হিসাব — worker আর retry-র সময়: peak-এ ঘণ্টায় ২,০০,০০০টি thumbnail job আসে, প্রতিটি চেষ্টায় গড়ে ১.৫ সেকেন্ড লাগে, আর retry মিলিয়ে মোট চেষ্টা আসল job-এর ১.১ গুণ (ধরা যাক)। (ক) সেকেন্ডে কতটি চেষ্টা? (খ) গড়ে একসাথে কয়টি worker ব্যস্ত? (গ) worker-দের ৭০%-এর বেশি ব্যস্ত রাখতে না চাইলে কয়টি লাগবে? (ঘ) Max attempts ৫, base ২ সেকেন্ড, cap ৬০ সেকেন্ড, full jitter — প্রথম চেষ্টা থেকে শেষ চেষ্টা পর্যন্ত অপেক্ষার সর্বোচ্চ যোগফল কত, আর গড়ে কত?
    • (ক) ২,০০,০০০ × ১.১ ÷ ৩,৬০০ ≈ ৬১.১ চেষ্টা/s.
    • (খ) Little-এর সূত্র L = λW (পর্ব ৪ — Latency, throughput আর percentile): ৬১.১ × ১.৫ ≈ ৯১.৭টি worker গড়ে একসাথে ব্যস্ত।
    • (গ) ৯১.৭ ÷ ০.৭ ≈ ১৩০.৯, তাই ১৩১টি worker (বা সমান concurrency-র slot).
    • (ঘ) পাঁচ চেষ্টার মাঝে চারটি অপেক্ষা, জানালা min(৬০, ২ × ২ⁿ): n = ১ থেকে ৪ — ৪, ৮, ১৬, ৩২ সেকেন্ড। সর্বোচ্চ যোগফল ৬০ সেকেন্ড; full jitter-এ প্রতিটির গড় জানালার অর্ধেক, তাই গড় ৩০ সেকেন্ড. এক মিনিটের বেশি বিভ্রাটে তাই job DLQ-তে যাবে।
  2. নকশা সমালোচনা: একটি কাল্পনিক subscription সেবার বর্তমান নকশা: (১) checkout-এর API handler-এর ভেতরেই ইমেইল পাঠানো, SMS পাঠানো আর PDF invoice বানানো, তারপর উত্তর; (২) তিনটি app server-এর প্রত্যেকটির crontab-এ 0 0 1 * * charge_subscriptions, সার্ভারের local time zone-এ; (৩) worker-রা SQS-ধাঁচের সারি থেকে রিপোর্ট job তোলে, visibility timeout ৩০ সেকেন্ড, রিপোর্টে লাগে ৩–৫ মিনিট; (৪) retry: সঙ্গে সঙ্গে, সীমাহীন। সমস্যাগুলো চিহ্নিত করুন আর একটি ভালো নকশা প্রস্তাব করুন।
    • (১) Request path-এ ধীর কাজ: checkout-এর latency তিনটি বাইরের সেবার যোগফল, আর SMS সেবার ৫০৩ checkout-কে ব্যর্থ দেখায় যদিও অর্ডার হয়েছে। প্রতিকার: অর্ডার আর তিনটি job একই transaction-এ (job টেবিল বা outbox), উত্তর সঙ্গে সঙ্গে।
    • (২) তিনগুণ বিল: তিনটি সার্ভার একই মুহূর্তে চার্জ করবে। সাথে local time zone — সার্ভার ভিন্ন region-এ হলে ভিন্ন সময়, আর DST থাকলে বাদ বা দ্বিগুণ। আর সব গ্রাহক একই মুহূর্তে — পেমেন্ট গেটওয়েতে ঢল। প্রতিকার: একটি scheduler (leader election বা managed scheduler), UTC; সে প্রতিটি গ্রাহকের জন্য একটি job রাখে charge:{customer}:{2026-11} key-সহ (unique constraint আর গেটওয়ের idempotency key), hash দিয়ে কয়েক ঘণ্টায় ছড়ানো; misfire policy: বাদ পড়া মাস অবশ্যই চালানো।
    • (৩) Lease-এর চেয়ে লম্বা কাজ: প্রতি ৩০ সেকেন্ডে আরেকজন একই রিপোর্ট তুলবে। প্রতিকার: প্রতি ১০ সেকেন্ডে heartbeat দিয়ে lease বাড়ানো, job timeout ১০ মিনিট, ফল idempotent নামে লেখা।
    • (৪) Retry storm আর poison job: সঙ্গে সঙ্গে আর সীমাহীন retry ডুবন্ত সেবাকে আরও ডোবায়, poison job চিরকাল ঘোরে। প্রতিকার: retryable/non-retryable ভাগ, exponential backoff + full jitter, max attempts, DLQ-তে alert, আর এক স্তরে retry.
  3. আউটপুট অনুমান করুন: অংশ ৭-এর durable_workflow_replay.js-এ worker("A", 2)-কে worker("A", 3) করলে প্রথম আটটি লাইন আর শেষ লাইনটি কী হবে? কোন activity দুবার চলবে, আর বাস্তবে তার ফল কী? আগে লিখুন, তারপর চালিয়ে মেলান।

    অনুমান: A এবার তিনটি ধাপ শেষ করে, চতুর্থ ধাপ sendEmail-এর ভেতরে crash — ইমেইল চলে গেছে, কিন্তু Completed লেখা হয়নি। B তিনটি ধাপ replay করে, #৩ আবার চালায়। History একই থাকবে (প্রতিটি ধাপের একটি Scheduled আর একটি Completed).

    আসল আউটপুট (প্রথম আট লাইন আর শেষ লাইন):

    A execute #0 reserveStock = hold-4217
    A execute #1 chargePayment = txn-12651
    A execute #2 makeInvoice = INV-12651
    !! A crashed inside #3 before recording it
    B replay  #0 reserveStock = hold-4217
    B replay  #1 chargePayment = txn-12651
    B replay  #2 makeInvoice = INV-12651
    B execute #3 sendEmail = mail-for-INV-12651
    real executions: {"reserveStock":1,"chargePayment":1,"makeInvoice":1,"sendEmail":2}

    এবার দুবার চলেছে sendEmail — গ্রাহক একই ইমেইল দুবার পাবেন। প্রতিকার: ইমেইল সেবাকে একটি idempotency key (যেমন order-4217:confirm) পাঠানো, বা পাঠানোর আগে unique টেবিলে চিহ্ন। শিক্ষা: crash যেকোনো ধাপের ভেতরে হতে পারে, তাই প্রতিটি activity-কেই দুবার চলার জন্য প্রস্তুত রাখতে হয়।

  4. বেছে নিন — queue, scheduler না workflow engine: নিচের প্রতিটির জন্য সবচেয়ে সরল উপযুক্ত ব্যবস্থা বেছে কারণ লিখুন: (ক) প্রোফাইল ছবি আপলোডের পরে তিন মাপের ছবি; (খ) প্রতি ঘণ্টায় মেয়াদোত্তীর্ণ OTP মোছা; (গ) নতুন কর্মী যোগ দিলে: account খোলা, ল্যাপটপের অনুরোধ, ম্যানেজারের অনুমোদন (৩ দিন পর্যন্ত), প্রথম সপ্তাহের শেষে একটি জরিপ; (ঘ) প্রতি রাতে পাঁচটি নির্ভরশীল ধাপে বিক্রির ডেটা থেকে dashboard; (ঙ) অর্ডারের ৩০ মিনিট পরেও পরিশোধ না হলে বাতিল।
    • (ক) Job queue: একটি ঘটনা, একটি স্বাধীন কাজ, সেকেন্ডের; idempotent নামে লেখা।
    • (খ) Scheduler: ঘড়ি-ধরা, একক, idempotent (“মেয়াদ পেরোনো সব মুছে ফেলা” দুবার চললেও একই); overlap: Forbid; বাদ পড়লে পরের run-ই যথেষ্ট।
    • (গ) Workflow engine: বহু ধাপ, কয়েকটি সেবা, মানুষের অপেক্ষা (signal + ৩ দিনের timer), এক সপ্তাহের timer — job আর টেবিল দিয়ে বানালে ছড়ানো state machine.
    • (ঘ) DAG pipeline (Airflow-ধাঁচ): স্থির নির্ভরতার graph, সময়সূচি ধরে, তারিখভিত্তিক idempotent task আর backfill.
    • (ঙ) Delayed job: অর্ডারের সময় run_at = +৩০ মিনিটের একটি job; চলার সময় অবস্থা যাচাই (“এখনো pending?”) — পরিশোধ হয়ে গেলে কিছুই না করে সফল।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি background job প্রথমবার ক্ষণস্থায়ী ত্রুটিতে ব্যর্থ হয়ে দ্বিতীয়বারে সফল হলে যে ধাপগুলো পেরোয়, সেগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 📥 API job সারিতে রাখে আর 202 উত্তর দেয় (queued)
  2. 👷 একটি worker lease নিয়ে job তোলে (running)
  3. ⚠️ বাইরের সেবার timeout — চেষ্টা ব্যর্থ (failed)
  4. ⏳ Backoff আর jitter দিয়ে run_at ঠিক করে অপেক্ষা (retrying)
  5. 🔁 run_at পেরোলে আরেকটি worker job তোলে (running)
  6. ✅ ফল লিখে ack — job শেষ (succeeded)

একটি job-এর retry নীতি full jitter-সহ exponential backoff: অপেক্ষা = random(0, min(cap, base × 2ⁿ)), যেখানে n হলো এ পর্যন্ত ব্যর্থতার সংখ্যা, base = ২ সেকেন্ড, cap = ৬০ সেকেন্ড। তৃতীয় ব্যর্থতার পরে পরের চেষ্টার আগে অপেক্ষা কোন সীমার মধ্যে?

  • ০ থেকে ৮ সেকেন্ড
  • ০ থেকে ১৬ সেকেন্ড
  • ঠিক ১৬ সেকেন্ড
  • ০ থেকে ৬০ সেকেন্ড

একটি সারির visibility timeout ৩০ সেকেন্ড। একটি রিপোর্ট job-এ লাগে ২ মিনিট, আর worker চলাকালে timeout বাড়ায় না। কয়েকটি worker সারি থেকে কাজ তুলছে। কী ঘটার সম্ভাবনা সবচেয়ে বেশি?

  • সারি অপেক্ষা করবে যতক্ষণ না প্রথম worker শেষ করে
  • ৩০ সেকেন্ড পরে job সারি থেকে মুছে যাবে
  • ৩০ সেকেন্ড পরে job আবার দৃশ্যমান হবে আর অন্য worker-ও একই job চালাতে শুরু করবে
  • প্রথম worker-কে সারি নিজেই থামিয়ে দেবে

একটি SaaS অ্যাপে ১০,০০০ tenant-এর রাতের রিপোর্ট job সবই ঠিক ০০:০০:০০-এ সারিতে আসে। Worker-রা মিলে সেকেন্ডে ৫০টি job শেষ করে। মোটামুটি কতক্ষণ পরে শেষ job-টি শুরু হবে?

  • প্রায় ২০০ সেকেন্ড
  • প্রায় ৫০ সেকেন্ড
  • প্রায় ১ সেকেন্ড
  • প্রায় ৩,৬০০ সেকেন্ড

একটি দেশে DST শুরুর রাতে ঘড়ি ০২:০০ থেকে সরাসরি ০৩:০০-এ যায়। সার্ভারের local time zone-এ একটি cron job প্রতিদিন ০২:৩০-এ চলার কথা। সেই রাতের জন্য সবচেয়ে নিরাপদ নকশা কোনটি?

  • কিছু না — cron নিজেই ঠিক ০২:৩০-এ চালাবে
  • একই job দুটি সার্ভারে চালানো, যাতে একটি বাদ পড়লেও অন্যটি চলে
  • Job প্রতি মিনিটে চালানো
  • Schedule UTC-তে রাখা, আর job-কে নির্দিষ্ট তারিখের idempotent কাজ বানানো

Replay-ভিত্তিক একটি workflow engine-এ workflow function-এর ভেতরে সরাসরি লেখা: if (Math.random() < 0.5) charge() else refund(), যেখানে charge আর refund দুটি activity. Worker crash-এর পরে replay-তে কী সমস্যা হতে পারে?

  • Activity-গুলো retry হবে না
  • Replay-তে ভিন্ন শাখা বেছে নিতে পারে, ফলে history-র সাথে কোড মেলে না
  • Event history অসীম বড় হয়ে যাবে
  • History-র সব event মুছে যাবে

একটি worker গ্রাহককে ইমেইল পাঠাল, তারপর ack দেওয়ার ঠিক আগে crash করল। সারি at-least-once. ইমেইল দুবার যাওয়া ঠেকানোর সবচেয়ে কার্যকর উপায় কোনটি?

  • Job-এর id থেকে বানানো একটি idempotency key দিয়ে পাঠানো, যাতে একই key-এর দ্বিতীয় অনুরোধ চেনা যায়
  • ইমেইল পাঠানোর আগেই ack দেওয়া
  • Visibility timeout ১২ ঘণ্টা করা
  • Max attempts ১ করা

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. ধীর বা ভঙ্গুর কাজ request path-এর বাইরে: অনুরোধ job টেকসই সারিতে রাখে (প্রয়োজনে একই transaction-এ), উত্তর দেয় 202; worker পরে করে। Thread-এ ছুড়ে দেওয়া background job নয়।
  2. Job-এর অবস্থা: queued → running → succeeded, বা failed → retrying → running; সীমা পেরোলে dead → DLQ. Retry শুধু ক্ষণস্থায়ী ত্রুটিতে, backoff + full jitter, max attempts, এক স্তরে।
  3. Job ব্যবস্থা at-least-once: প্রতিটি job idempotent — নির্দিষ্ট নামে লেখা, idempotency key, unique constraint. দীর্ঘ কাজে ছোট lease + heartbeat, আটকে থাকা worker-এর লেখা আটকাতে fencing token.
  4. Cron-এর চার ফাঁদ: ০০:০০-এর ঢল (hash দিয়ে ছড়ান), overlap, time zone ও DST (UTC), বাদ পড়া run (misfire policy). একাধিক scheduler-এ leader বা lease আর job + নির্ধারিত সময়-এর unique key; job তারিখভিত্তিক ও idempotent.
  5. Workflow engine: event history-তে প্রতিটি ধাপ, crash-এর পরে replay করে শেষ হওয়া ধাপ বাদ; শর্ত — কোড deterministic, activity idempotent. DAG-এ data pipeline আর backfill. সবচেয়ে সরলটি দিয়ে শুরু করুন।

পরের পর্বে: মডিউল ৬ এখানে শেষ — queue, pub/sub, log, delivery semantics, event sourcing, stream processing আর দীর্ঘ কাজ। মডিউল ৭-এ distributed systems-এর তত্ত্ব: পর্ব ৩৮-এ দেখবেন Distributed system-এর কঠিন সত্য — partial failure, অনির্ভরযোগ্য network আর clock — এই পর্বে বারবার দেখা “ack আসেনি, কাজ হয়েছিল কি না জানি না” সমস্যার গোড়া। আর lease ও fencing token-এর পূর্ণ তত্ত্ব আসবে পর্ব ৪৩-এ, notification সিস্টেমের পুরো নকশা পর্ব ৫৭-এ।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান