এই পর্বে যা শিখবেন
- কোন কাজ request-এর ভেতরে থাকবে আর কোনটি queue-তে যাবে, মেপে ঠিক করতে
- BullMQ দিয়ে producer আর worker বানাতে: concurrency, retry, delay, rate limit, progress আর jobId
- Crash, stalled job, duplicate delivery আর graceful shutdown সামলাতে
- Transaction-এর পরে outbox দিয়ে job দিতে, আর Redis না থাকলে PostgreSQL-এর queue বেছে নিতে
- সঠিক time zone-এ cron চালাতে, আর queue-র স্বাস্থ্য মাপতে
১ · ধীর কাজ request থেকে বের করা
বাজারঘরে একজন ক্রেতা অর্ডার দিলেন। এখন তিনটি কাজ বাকি: একটি invoice PDF বানানো, ক্রেতাকে email পাঠানো, আর গুদামের স্টক রিপোর্ট হালনাগাদ করা। প্রশ্ন হলো, এই কাজগুলো কি ক্রেতাকে “অর্ডার হয়েছে” বলার আগে করতে হবে?
রেস্তোরাঁর কথা ভাবুন। ওয়েটার অর্ডার নিয়ে রান্নাঘরের দেয়ালে একটি টিকিট ঝুলিয়ে দেন, তারপর পরের টেবিলে যান। তিনি রান্না শেষ হওয়া পর্যন্ত চুলার পাশে দাঁড়িয়ে থাকেন না। রান্নাঘরে কয়েকজন রাঁধুনি টিকিট একটি একটি করে নেন। ওয়েটার হলো আপনার API, দেয়ালের টিকিটের সারি হলো job queue, আর রাঁধুনিরা হলেন worker. টিকিট দেয়ালে থাকে বলে একজন রাঁধুনি চলে গেলেও অর্ডার হারায় না।
যে কাজ ক্রেতার উত্তরের জন্য জরুরি নয়, কিন্তু অবশ্যই কোনো এক সময় হতে হবে, তাকে বলে background job. Email, ছবির ছোট সংস্করণ বানানো (পর্ব ৩০-এ File upload পর্বে sharp দেখেছেন), invoice, রিপোর্ট, webhook পাঠানো, এসবই এর উদাহরণ। কেন এগুলো request path-এ রাখা হয় না, তার সিস্টেম-স্তরের তত্ত্ব আছে Job আর workflow পর্বে। এখানে আমরা সেটি Node.js-এ বানাব, চালাব আর ভাঙব।
বাজারঘরের background কাজের পথ
The API only records the job; separate worker processes do the slow work
আগে আর পরে: আসল মাপ
একটি ছোট ল্যাবে দুটি route বানানো হলো। প্রথমটি একই request-এ pdfkit দিয়ে একটি আসল invoice PDF বানায়, তারপর email provider-কে ডাকে। Provider এখানে নিজের বানানো একটি local server, যা বাইরের সেবার মতো ৫০০ ms পরে উত্তর দেয়। দ্বিতীয় route শুধু queue-তে একটি job যোগ করে উত্তর দেয়, আর একটি worker পেছনে একই কাজ করে।
সত্যিকারের run · Express 5.3.0, BullMQ 6.5.3, Redis 8.10.3, Node.js 24.15 · একটি local কম্পিউটারে একবার · প্রতিটি route-এ পরপর ৩০টি অনুরোধ
POST /orders-inline 30 requests p50 515.4 ms p95 518.2 ms max 518.4 ms
POST /orders-queued 30 requests p50 0.7 ms p95 3.7 ms max 5.4 ms
worker finished 33 jobs, mails logged: 66
pdf size 1742 bytes
৫১৫ ms-এর ৫০০ ms হলো provider-এর জন্য অপেক্ষা, বাকি ১৫ ms-এর মতো PDF আর HTTP-র খরচ। Queue-র পথে ক্রেতা ৭০০ গুণেরও বেশি দ্রুত উত্তর পেলেন, আর কোনো কাজ বাদ পড়েনি: ৩৩টি job (৩টি warm-up সহ) শেষ, আর provider মোট ৬৬টি email পেয়েছে, দুই route-এর ৩৩টি করে।
Queue কাজকে দ্রুত করে না, কাজকে ক্রেতার অপেক্ষা থেকে আলাদা করে। কাজটি এখনো ৫১৫ ms নেয়, শুধু এখন সেটি এমন জায়গায় হয় যেখানে ব্যর্থ হলে আবার চেষ্টা করা যায়, আর ভিড় হলে সারিতে অপেক্ষা করা যায়।
কোনটি queue-তে যাবে, তার একটি সহজ পরীক্ষা আছে। ক্রেতার পরের পর্দায় কি এই কাজের ফল লাগবে? অর্ডারটি ডেটাবেসে লেখা হয়েছে কি না, স্টক কমেছে কি না, এগুলো লাগবে, তাই সেগুলো request-এর ভেতরে, transaction-এ (পর্ব ৪৩)। Email এক মিনিট দেরিতে গেলে কেউ টের পান না, তাই সেটি job.
Fire-and-forget: একই process-এ পেছনে চালানো
সবচেয়ে সহজ চেষ্টা হলো await ছাড়া promise ছেড়ে দেওয়া। একে বলে fire-and-forget. উত্তর সঙ্গে সঙ্গে যায়, কাজ পেছনে চলে। নিচের route provider-এর সীমা মানতে email একটি একটি করে পাঠায়, একটি মেমরির শিকলে।
let chain = Promise.resolve();
app.post('/orders', async (req, res) => {
const order = await saveOrder(req.body); // ডেটাবেসে, সংক্ষেপে
chain = chain // await নেই
.then(() => invoicePdf(order))
.then(() => sendEmail(order.email, `Invoice ${order.id}`))
.catch((e) => console.error('email failed', e.message));
res.status(201).json({ id: order.id });
});এরপর ল্যাবে ৫টি অর্ডার পাঠানো হলো, আর ১০০ ms পরে API process-টি মেরে ফেলা হলো, যেমনটি deploy, crash বা মেমরি শেষ হলে ঘটে। একই পরীক্ষা হলো queue-র সংস্করণেও, যেখানে আলাদা worker পরে চালু হয়।
সত্যিকারের run · একটি local কম্পিউটারে একবার · process মারা হয়েছে SIGKILL দিয়ে
[fire-and-forget] 5 orders answered: 201 201 201 201 201
[fire-and-forget] API process killed 100 ms later
[fire-and-forget] emails the provider received: 1 of 5
[queue] 5 orders answered: 201 201 201 201 201
[queue] API process killed 100 ms later
[queue] jobs waiting in Redis: 5
[queue] emails the provider received: 5 of 5
দুই ক্ষেত্রেই ক্রেতারা 201 পেয়েছেন, মানে তাঁদের কাছে সব ঠিক। কিন্তু প্রথম ক্ষেত্রে চারজন কোনোদিন invoice পাবেন না, আর কোনো log-এও তা লেখা নেই, কারণ log লেখার process-টিই নেই। কাজের তালিকা ছিল process-এর মেমরিতে, আর মেমরি process-এর সাথেই মরে। Queue-র ক্ষেত্রে তালিকা ছিল Redis-এ, process-এর বাইরে।
Fire-and-forget কখন চলে? হারালে ক্ষতি নেই এমন কাজে: একটি analytics ping, cache গরম করা, log পাঠানো। অর্ডার, টাকা, ক্রেতাকে দেওয়া কথা, এসবে নয়। পর্ব ৩১-এর (Log আর config) graceful shutdown কিছু ক্ষতি কমায়, কিন্তু crash বা SIGKILL-এ সেটিও চলে না।
২ · BullMQ: producer, worker আর concurrency
Node.js-এ Redis-ভিত্তিক queue-র একটি বহুল ব্যবহৃত library হলো BullMQ. লেখার সময়ে সর্বশেষ version 6.5.3. Version 6 (জুলাই ২০২৬) কিছু বড় বদল এনেছে: ioredis এখন আপনাকে নিজে install করতে হয়, পুরোনো repeat option সরিয়ে job scheduler এসেছে, আর changelog-এ PostgreSQL backend-এর কথাও আছে। শেষেরটি আমরা চালাইনি। Redis-এর জন্য পর্ব ৪৪-এর (Cache আর Redis) মতো একটি বহনযোগ্য Redis 8.10.3 নিজের port-এ চালানো হয়েছে।
দুটি ভূমিকা আছে। Producer job যোগ করে, সাধারণত আপনার API. Worker job নেয় আর চালায়, আর সেটি একটি আলাদা Node.js process. দুজনের মধ্যে কথা হয় শুধু Redis-এর মাধ্যমে।
// npm i bullmq ioredis (BullMQ 6-এ ioredis নিজে লাগবে)
import { Queue } from 'bullmq';
export const connection = { host: process.env.REDIS_HOST, port: Number(process.env.REDIS_PORT) };
export const queue = new Queue('bazarghar', { connection });
// API-র ভেতরে, অর্ডার commit হওয়ার পরে
await queue.add('order-confirmed',
{ orderId: 501 }, // job data: শুধু id, পুরো অর্ডার নয়
{ jobId: 'order-confirmed-501' });import { Worker } from 'bullmq';
import { connection } from './queue.js';
const worker = new Worker('bazarghar', async (job) => {
const order = await orders.findById(job.data.orderId); // তাজা ডেটা ডেটাবেস থেকে
const pdf = await invoicePdf(order);
await job.updateProgress(50);
await sendInvoiceEmail(order, pdf);
return { pdfBytes: pdf.length }; // completed job-এর result
}, { connection, concurrency: 4 });
worker.on('failed', (job, err) => log.warn({ jobId: job?.id, err }, 'job failed'));দুটি অভ্যাস লক্ষ করুন। প্রথম, job data ছোট: শুধু orderId. Job Redis-এ বসে থাকে, হয়তো কয়েক মিনিট। এর মধ্যে অর্ডার বাতিল হতে পারে, ঠিকানা বদলাতে পারে। তাই worker তাজা ডেটা ডেটাবেস থেকে পড়ে। আর password, card নম্বর বা token job data-য় কখনো নয়, কারণ সেটি Redis-এ সাধারণ লেখা হিসেবে থাকে আর dashboard-এ দেখা যায়। দ্বিতীয়, jobId একটি অর্থবহ নাম। BullMQ-তে একই jobId দিয়ে দ্বিতীয়বার যোগ করলে নতুন job হয় না। এর নাম deduplication.
সত্যিকারের run · BullMQ 6.5.3 · একটি local কম্পিউটারে একবার
dedupe: first id=invoice-101 second id=invoice-101 jobs in queue=1
প্রথম চেষ্টায় আমরা order-confirmed:501 লিখেছিলাম, আর BullMQ সাথে সাথে থামিয়েছে: Error: Custom Id cannot contain :. কারণ BullMQ নিজে Redis key-তে : ব্যবহার করে। তাই নাম হলো order-confirmed-501. AI প্রায়ই এই ভুলটি লেখে।
Concurrency: একসাথে কয়টি
Concurrency ঠিক করে একটি worker process একসাথে কয়টি job চালাবে। Email-এর মতো অপেক্ষার কাজে এটি বড় লাভ দেয়, কারণ Node.js অপেক্ষার সময় অন্য কাজ করতে পারে (পর্ব ২৪-এর event loop). ২০০ ms অপেক্ষার ১২টি job দিয়ে মাপা হলো:
সত্যিকারের run · একটি local কম্পিউটারে একবার
concurrency 1: 12 jobs x 200 ms finished in 2546 ms
concurrency 4: 12 jobs x 200 ms finished in 651 ms
তত্ত্বে ১২ × ২০০ = ২,৪০০ ms আর ৩ × ২০০ = ৬০০ ms. বাকিটুকু Redis-এর সাথে যাওয়া-আসার খরচ। তবে PDF বা ছবির মতো CPU-র কাজে concurrency বাড়ালে লাভ হয় না, কারণ একটি Node.js thread একবারে একটি হিসাবই করে। সেখানে বেশি process চালাতে হয়। নিচের মডেলে একই ব্যাপারটি নকল ঘড়িতে দেখুন: ছয়টি ভিন্ন দৈর্ঘ্যের job, ১, ২ আর ৪ জন worker.
// শেখানোর মডেল: নকল ঘড়িতে একটি queue আর কয়েকজন worker (আসল BullMQ নয়)
function simulate(concurrency) {
const jobs = [ // [নাম, কত ms লাগে]
['invoice-101', 300], ['email-101', 500], ['thumb-7', 200],
['invoice-102', 300], ['email-102', 500], ['report', 900],
];
const queue = jobs.map(([name, ms]) => ({ name, ms }));
const slots = Array(concurrency).fill(0); // প্রতিটি worker কখন ফাঁকা হবে
const log = [];
let maxWait = 0;
while (queue.length) {
const job = queue.shift(); // সবার আগে আসা job আগে
const w = slots.indexOf(Math.min(...slots));
const start = slots[w];
slots[w] = start + job.ms;
maxWait = Math.max(maxWait, start);
log.push(` W${w + 1} ${job.name.padEnd(11)} ${String(start).padStart(4)} -> ${slots[w]} ms`);
}
console.log(`concurrency ${concurrency}: all done at ${Math.max(...slots)} ms, ` +
`longest wait in queue ${maxWait} ms`);
if (concurrency === 2) console.log(log.join('\n'));
}
[1, 2, 4].forEach(simulate);একজন worker-এ সব শেষ 2700 ms-এ, যা সব কাজের যোগফল, আর শেষ job সারিতে 1800 ms অপেক্ষা করেছে। দুজনে 1700 ms. চারজনে 1200 ms, কিন্তু এর নিচে নামবে না: report নিজেই ৯০০ ms নেয় আর ৩০০ ms-এ শুরু হয়েছে। Worker বাড়ালে অপেক্ষা কমে, কিন্তু সবচেয়ে লম্বা job-এর সময় কমে না। দুজনের তালিকায় দেখুন, W1 ছোট কাজগুলো পরপর নিয়ে গেছে, কারণ যে আগে ফাঁকা হয় সে-ই পরের job নেয়।
Delay, rate limit আর progress
Delayed job নির্দিষ্ট সময় পরে চালানোর জন্য, যেমন “কার্টে জিনিস রেখে ৩০ মিনিট কিছু না করলে মনে করিয়ে দিন”। Rate limit ঠিক করে worker সময়ের একটি ঘরে কয়টির বেশি job নেবে না, যেমন SMS provider সেকেন্ডে কয়টির বেশি নেয় না। নিচের run-এ ১,৫০০ ms delay-র একটি reminder আর ৬টি SMS, সীমা প্রতি ১,০০০ ms-এ ২টি।
await queue.add('reminder', { cart: 7 }, { delay: 1500 });
const w = new Worker('rl3', handler, {
connection, concurrency: 5,
limiter: { max: 2, duration: 1000 }, // প্রতি ১ সেকেন্ডে সর্বোচ্চ ২টি
});সত্যিকারের run · একটি local কম্পিউটারে একবার · নাম@ms মানে কখন শুরু
limiter max 2 per 1000 ms: sms@7 sms@7 sms@1010 sms@1010 sms@2024 sms@2024 reminder@3034
Concurrency ৫ থাকলেও প্রতি সেকেন্ডে ঠিক দুটি। আর reminder-এর delay ছিল ১,৫০০ ms, অথচ চলেছে ৩,০৩৪ ms-এ। কারণ ১,৫০০ ms-এ সে চালানোর যোগ্য হয়েছিল, কিন্তু সীমা তখন ভরা। মনে রাখুন: delay মানে “এর আগে নয়”, “ঠিক তখন” নয়। Job নিজের অগ্রগতিও জানাতে পারে: job.updateProgress(50), যা অন্য process QueueEvents দিয়ে শোনে, আর পর্ব ৬১-এর SSE দিয়ে ক্রেতার পর্দায় পাঠানো যায়।
৩ · Retry: exponential backoff, jitter আর dead letter
Email provider মাঝে মাঝে বলে “এখন ব্যস্ত, পরে চেষ্টা করুন” (SMTP 421). এটি একটি সাময়িক ব্যর্থতা: একটু পরে চেষ্টা করলেই হয়ে যাবে। এর জন্য retry. কিন্তু সঙ্গে সঙ্গে আবার চেষ্টা করলে ব্যস্ত provider-এ চাপ আরও বাড়ে। তাই অপেক্ষা প্রতিবার দ্বিগুণ হয়: exponential backoff. আর সবাই যেন একই মুহূর্তে না ফেরে, তাই অপেক্ষায় একটু এলোমেলো অংশ: jitter.
await queue.add('invoice-email', { orderId: 101 }, {
jobId: 'invoice-email-101',
attempts: 4, // মোট ৪ বার, প্রথমটিসহ
backoff: { type: 'exponential', delay: 1000, jitter: 0.5 },
});BullMQ 6.5.3-এর source অনুযায়ী n-তম ব্যর্থতার পরে সর্বোচ্চ অপেক্ষা delay × 2^(n−1). jitter: 0.5 হলে আসল অপেক্ষা এর ৫০% থেকে ১০০%-এর মধ্যে এলোমেলো। নিচের stepper-এ সেই job-এর আসল জীবন: provider প্রথম দুবার 421 দিল, তৃতীয়বার নিল।
1queue.add('invoice-email', …)2attempt 1 → throw SMTP 4213attempt 2 → throw SMTP 4214attempt 3 → updateProgress(25, 50, 100)5return { messageId: 'msg-101' }
| সময় | |
| state | |
| attemptsMade | |
| পরের অপেক্ষার পরিসর | |
| আসল অপেক্ষা |
- যোগ হলো। Job Redis-এ
waitingঅবস্থায়। এখনো কেউ ধরেনি।attemptsMade০। - প্রথম চেষ্টা ব্যর্থ। ৯ ms-এ worker ধরল, ১১ ms-এ 421 পেল। Job
delayed-এ গেল। সর্বোচ্চ অপেক্ষা ১০০০ × 2⁰ = ১০০০ ms, jitter-এ পরিসর ৫০০ থেকে ৯৯৯, আর এলো ৫৪০ ms. - দ্বিতীয় চেষ্টাও ব্যর্থ। ৫৫১ ms-এ আবার চলল, আবার 421. এবার সর্বোচ্চ ১০০০ × 2¹ = ২০০০ ms, পরিসর ১০০০ থেকে ১৯৯৯, আসল অপেক্ষা ১৫৩০ ms.
- তৃতীয় চেষ্টা। ২০৮২ ms-এ চলল, provider নিল। পথে job তিনবার অগ্রগতি জানাল। এই মুহূর্তে job
active, আরattemptsMadeএখনো ২, কারণ চেষ্টাটি শেষ হয়নি। - শেষ। ২০৮৬ ms-এ
completed, result-সহ। চতুর্থ চেষ্টা আর লাগেনি। ক্রেতা ২ সেকেন্ড দেরিতে email পেলেন, আর তিনি তা জানতেও পারলেন না।
সত্যিকারের run · BullMQ 6.5.3 · একটি local কম্পিউটারে একবার · stepper-এর সময় এখান থেকে
9 ms run invoice-email-101 attempt 1
11 ms fail invoice-email-101 attemptsMade=1 "SMTP 421 try again later"
11 ms fail sms-101 attemptsMade=1 "invalid phone number 01X"
368 ms fail sms-101 attemptsMade=2 "invalid phone number 01X"
551 ms run invoice-email-101 attempt 2
552 ms fail invoice-email-101 attemptsMade=2 "SMTP 421 try again later"
986 ms fail sms-101 attemptsMade=3 "invalid phone number 01X"
2082 ms run invoice-email-101 attempt 3
2083 ms progress invoice-email-101 25%
…
2086 ms done invoice-email-101 result={"messageId":"msg-101"}
--- counts {"waiting":0,"active":0,"delayed":0,"completed":1,"failed":1}
--- failed: sms-101 attemptsMade=3 reason="invalid phone number 01X" data={"orderId":101,"phone":"01X"}
একই run-এ sms-101 নামের আরেকটি job ছিল, যার ফোন নম্বরই ভুল। সে তিনবার চেষ্টা করে (অপেক্ষা ৩৫৭ আর ৬১৮ ms, jitter ছাড়া ৩০০ আর ৬০০-এর কাছাকাছি) শেষে failed. এই চেষ্টাগুলো অপচয়, কারণ ভুল নম্বর অপেক্ষা করলে ঠিক হয় না। এ ধরনের স্থায়ী ভুলে BullMQ-র UnrecoverableError ছুড়ুন, তাতে বাকি চেষ্টা বাদ যায়।
সত্যিকারের run · attempts: 5 থাকা সত্ত্বেও
unrecoverable: state=failed attemptsMade=1 of 5
after retry: state=completed
দ্বিতীয় লাইনটি দেখায় failed job কোথায় যায়। BullMQ-তে সেটি মোছে না, failed তালিকায় কারণ আর data-সহ থাকে। queue.getFailed() সেই তালিকা দেয়। এটিই আপনার dead letter দৃশ্য: যে চিঠি ঠিকানায় পৌঁছায়নি, ডাকঘর তা ফেলে না দিয়ে আলাদা তাকে রাখে। ভুল সারিয়ে (এখানে ফোন নম্বর ঠিক করে) job.retry() দিলে job আবার সারিতে যায়। একটি poison message, মানে যে job প্রতিবার worker-কে ভাঙে, তাও শেষে এখানেই থামে, বারবার সারি আটকায় না। Retry-র তত্ত্ব, আর retry যে retry storm বানাতে পারে, তার বিস্তারিত Message queue পর্বে।
// শেখানোর মডেল: exponential backoff আর jitter, বীজ-নির্ভর PRNG, নকল ঘড়ি
function mulberry32(a) {
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(62);
// BullMQ-এর সূত্রের মতো: max = delay * 2^(n-1), jitter থাকলে [max*(1-j), max)
function backoff(attemptsMade, delay, jitter) {
const max = Math.round(2 ** (attemptsMade - 1) * delay);
if (!jitter) return max;
return Math.floor(rnd() * max * jitter + max * (1 - jitter));
}
const ATTEMPTS = 5, DELAY = 1000;
for (const jitter of [0, 0.5]) {
let now = 0, line = '';
for (let n = 1; n < ATTEMPTS; n++) { // n-তম চেষ্টা ব্যর্থ হলো
const wait = backoff(n, DELAY, jitter);
now += wait; line += ` +${wait}`;
}
console.log(`jitter ${jitter}: waits${line} ms, last try at ${now} ms`);
}
// ১০০০টি job একসাথে প্রথমবার ব্যর্থ হলো: দ্বিতীয় চেষ্টা কোন ১০০ ms-এর ঘরে
for (const jitter of [0, 0.5]) {
const bins = new Map();
for (let i = 0; i < 1000; i++) {
const b = Math.floor(backoff(1, DELAY, jitter) / 100) * 100;
bins.set(b, (bins.get(b) || 0) + 1);
}
const out = [...bins].sort((x, y) => x[0] - y[0]).map(([k, v]) => `${k}:${v}`);
console.log(`jitter ${jitter}: ${out.join(' ')}`);
}Jitter ছাড়া অপেক্ষা +1000 +2000 +4000 +8000, শেষ চেষ্টা 15000 ms-এ: ১০০০ × (১ + ২ + ৪ + ৮). Jitter ০.৫-এ +873 +1196 +2353 +5676, প্রতিটি নিজের পরিসরের ভেতরে, আর শেষ চেষ্টা আগে আসে। আসল লাভ শেষ দুই লাইনে। Provider এক মুহূর্তের জন্য পড়ে গেলে ১,০০০টি job একসাথে ব্যর্থ হয়। Jitter ছাড়া সবাই ঠিক ১,০০০ ms পরে একসাথে ফেরে: 1000:1000. Jitter-এ তারা পাঁচটি ঘরে ছড়িয়ে পড়ে, প্রতিটিতে প্রায় ২০০টি (500:226 600:193 …)। সবচেয়ে ব্যস্ত মুহূর্তের চাপ প্রায় পাঁচ ভাগের এক ভাগ।
৪ · Worker মাঝপথে মরলে: stalled job আর graceful shutdown
Worker job ধরার সময় Redis-এ একটি lease-এর মতো lock নেয়, আর কাজ চলাকালীন নিয়মিত সেটি নবায়ন করে। Process মরে গেলে নবায়ন থেমে যায়, lock-এর মেয়াদ ফুরায়, আর অন্য worker সেই job-টিকে stalled বলে চিহ্নিত করে আবার সারিতে পাঠায়। কোনো মানুষের হাত লাগে না। Run-এ lockDuration আর stalledInterval ২,০০০ ms রাখা হয়েছে, যাতে দ্রুত দেখা যায়। ডিফল্ট মান এর চেয়ে বড়।
- Redis
waitingjob যোগ হলো - W1
activelock নিয়ে চালাচ্ছে - W1 মৃত
SIGKILLlock আর নবায়ন হয় না - W2
stalledআবার চালায়
queue.add('order-confirmed', { orderId: 101 },
{ jobId: 'order-confirmed-101' })
W1 চালু, W2 এখনো নেই
picked order-confirmed-101 (attemptsStarted=1) email sent for 101 building stock report...
W1 killed with SIGKILL in the middle of the job email গেছে, রিপোর্ট অর্ধেক, job এখনো active
detected stalled job order-confirmed-101, moved back to wait picked order-confirmed-101 (attemptsStarted=2)
email sent for 101 ← দ্বিতীয়বার building stock report... finished order-confirmed-101 emails received by provider: 2
- Job যোগ হলো। অর্ডার ১০১-এর জন্য একটি job, নামসহ id. এক worker (W1) চালু আছে।
- W1 কাজ শুরু করল। সে lock নিল, email পাঠাল (১২২ ms), তারপর স্টক রিপোর্ট বানাতে লাগল, যাতে ২.৫ সেকেন্ড লাগে।
- W1 মারা গেল। ১৬১৩ ms-এ
SIGKILL. কোনো cleanup চলেনি। Redis-এ job এখনোactive, কারণ কেউ তাকে অন্য কিছু বলেনি। - W2 টের পেল। নতুন worker চালু হলো, আর তার stalled পরীক্ষায় দেখা গেল lock-এর মেয়াদ শেষ অথচ job active. সে job-টি
wait-এ ফেরাল আর নিজেই ধরল।attemptsStartedএখন ২। - পুরো কাজ আবার। W2 শুরু থেকে চালাল, তাই email আবার গেল। Job শেষ পর্যন্ত
completed, কিছু হারায়নি, কিন্তু ক্রেতা একই invoice দুবার পেলেন। এটিই at-least-once delivery-র দাম।
সত্যিকারের run · দুটি আলাদা worker process · একটি local কম্পিউটারে একবার · stepper-এর মান এখান থেকে
=== plain handler
607 ms W1 picked order-confirmed-101 (attemptsStarted=1)
729 ms W1 email sent for 101
729 ms W1 building stock report...
1613 ms W1 killed with SIGKILL in the middle of the job
5900 ms W2 detected stalled job order-confirmed-101, moved back to wait
5901 ms W2 picked order-confirmed-101 (attemptsStarted=2)
6025 ms W2 email sent for 101
6025 ms W2 building stock report...
8538 ms W2 finished order-confirmed-101
result: state=completed attemptsStarted=2 stalledCounter=1 emails received by provider: 2
Job stalled হলে সাধারণ retry-র attempts খরচ হয় না, আলাদা maxStalledCount গোনা হয় (ডিফল্ট ১)। এর বেশিবার stalled হলে job failed-এ যায়, যাতে যে job প্রতিবার process-কে মেরে ফেলে (যেমন মেমরি শেষ করে), সে চিরকাল ঘুরতে না থাকে।
Graceful shutdown: deploy-এর সময় ভদ্রভাবে থামা
Deploy-এর সময় পুরোনো process-কে SIGTERM পাঠানো হয়। পর্ব ৩১-এ (Log আর config) HTTP server-এর graceful shutdown দেখেছেন। Worker-এর নিয়মও একই: নতুন job নেওয়া বন্ধ, চলমান job শেষ, তারপর বিদায়। BullMQ-তে এর জন্য worker.close().
for (const sig of ['SIGTERM', 'SIGINT']) {
process.once(sig, async () => {
log.info({ sig }, 'closing worker');
await worker.close(); // নতুন job নয়, চলমানগুলো শেষ হওয়া পর্যন্ত অপেক্ষা
await pool.end(); // তারপর ডেটাবেস
process.exit(0);
});
}সত্যিকারের run · concurrency 1, দুটি ২ সেকেন্ডের job · signal-এর বদলে সরাসরি worker.close() ডাকা হয়েছে
7 ms start report-1
511 ms shutdown requested: worker.close()
2011 ms end report-1
2014 ms worker closed
2014 ms counts {"waiting":1,"active":0,"completed":1}
close() চলমান report-1-এর জন্য দেড় সেকেন্ড অপেক্ষা করল, আর report-2 ছুঁলো না। সেটি waiting-এ নিরাপদে আছে, নতুন process নেবে। সতর্কতা একটি: platform শুধু নির্দিষ্ট সময় অপেক্ষা করে (container-এ প্রায়ই ৩০ সেকেন্ডের মতো), তারপর SIGKILL. তাই ১০ মিনিটের job graceful shutdown-এও বাঁচবে না। লম্বা কাজকে ছোট ধাপে ভাগ করুন, আর প্রতিটি ধাপকে idempotent রাখুন। এতে আমরা পরের অংশে যাচ্ছি।
৫ · Idempotent job: duplicate আসবেই
আগের run-এর শিক্ষা পরিষ্কার: queue কথা দেয় job হারাবে না, কথা দেয় না job একবারই চলবে। Worker কাজ শেষ করে Redis-কে “শেষ” বলার ঠিক আগে মরলে কাজটি দুবার হবে, আর কোনো queue এটি পুরোপুরি ঠেকাতে পারে না। তাই সমাধান queue-তে নয়, handler-এ: handler হবে idempotent, মানে দুবার চালালেও ফল একবারের সমান।
পর্ব ৩৩-এ (Status code আর error format) HTTP-র idempotency key দেখেছেন। এখানে কৌশলটি একই, শুধু key হলো job-এর অর্থ, যেমন invoice-email:101. ল্যাবে PostgreSQL-এ একটি ছোট টেবিল যোগ হলো, আর একই crash পরীক্ষা আবার চালানো হলো।
// CREATE TABLE sent_emails (key text PRIMARY KEY, sent_at timestamptz DEFAULT now());
async function sendInvoiceOnce(orderId) {
const key = `invoice-email:${orderId}`;
const { rowCount } = await pool.query('SELECT 1 FROM sent_emails WHERE key = $1', [key]);
if (rowCount) return; // আগে পাঠানো, বাদ
await sendEmail(order.email, `Invoice ${orderId}`, { idempotencyKey: key });
await pool.query('INSERT INTO sent_emails (key) VALUES ($1) ON CONFLICT DO NOTHING', [key]);
}সত্যিকারের run · একই crash পরীক্ষা, PostgreSQL 18.6 (embedded-postgres) · একটি local কম্পিউটারে একবার
=== idempotent handler
606 ms W1 picked order-confirmed-101 (attemptsStarted=1)
759 ms W1 email sent for 101
1615 ms W1 killed with SIGKILL in the middle of the job
5905 ms W2 detected stalled job order-confirmed-101, moved back to wait
5905 ms W2 picked order-confirmed-101 (attemptsStarted=2)
5930 ms W2 email already sent for 101, skip
8433 ms W2 finished order-confirmed-101
result: state=completed attemptsStarted=2 stalledCounter=1 emails received by provider: 1
এবার W2 টেবিলে চিহ্ন পেয়ে email বাদ দিল, আর শুধু অসমাপ্ত রিপোর্টটি শেষ করল। কিন্তু একটি ছোট ফাঁক এখনো আছে: email যাওয়ার পরে আর INSERT-এর আগে মরলে চিহ্ন থাকবে না। Email আর ডেটাবেস এক transaction-এ বাঁধা যায় না। তিনটি পথ আছে। Provider যদি idempotency key নেয় (অনেকেই নেয়, নিজের provider-এর docs দেখে নিশ্চিত হন), তাহলে duplicate সে-ই বাদ দেয়। নয়তো ফাঁকটি মেনে নিন, কারণ duplicate email বিরক্তিকর হলেও ক্ষতিকর নয়। আর টাকার কাজে ফাঁক মেনে নেওয়া যায় না, সেখানে লেখা আর চিহ্ন একই ডেটাবেস transaction-এ হতে হবে।
// শেখানোর মডেল: at-least-once delivery, তাই একই job দুবার আসে
function run(title, handler) {
const provider = { sent: [], keys: new Set() }; // নকল email provider
const done = new Set(); // নকল sent_emails টেবিল
const queue = [
{ id: 'oc-101', order: 101, crash: true }, // email-এর পরে worker মারা যাবে
{ id: 'oc-102', order: 102 },
{ id: 'oc-102', order: 102 }, // relay আবার দিল, duplicate
];
while (queue.length) {
const job = queue.shift();
try { handler(job, provider, done); }
catch (e) { queue.push({ ...job, crash: false }); } // redelivery
}
const count = (o) => provider.sent.filter((x) => x === o).length;
console.log(`${title.padEnd(22)} order 101: ${count(101)} email, order 102: ${count(102)} email`);
}
function send(provider, order, key) {
if (key && provider.keys.has(key)) return; // provider নিজেই duplicate বাদ দেয়
if (key) provider.keys.add(key);
provider.sent.push(order);
}
run('plain', (job, p) => {
send(p, job.order);
if (job.crash) throw new Error('SIGKILL');
});
const guarded = (useKey) => (job, p, done) => {
const key = `invoice-email:${job.order}`;
if (done.has(key)) return; // আগে পাঠানো? তাহলে বাদ
send(p, job.order, useKey ? key : undefined);
if (job.crash) throw new Error('SIGKILL'); // send আর mark-এর মাঝখানে
done.add(key);
};
run('check, send, mark', guarded(false));
run('+ provider idem key', guarded(true));তিনটি লাইন তিনটি স্তর দেখায়। plain: দুই অর্ডারেই 2 email. check, send, mark: অর্ডার ১০২-এর সাধারণ duplicate ধরা পড়ল (1 email), কিন্তু ১০১-এ মরা ঠিক send আর mark-এর মাঝখানে, তাই সেখানে এখনো 2 email. + provider idem key: দুটিতেই 1 email, কারণ শেষ পাহারা দিল provider নিজে। Idempotency একটি শিকল: যে কড়ায় চিহ্ন রাখা যায় না, সেখানে পরের কড়াকে duplicate চিনতে হয়।
“Exactly-once” দাবি দেখলে। কিছু সিস্টেম নিজের ভেতরে exactly-once দেয়, যেমন একই ডেটাবেসের transaction-এ লেখা আর চিহ্ন। কিন্তু বাইরের জগতে (email, SMS, পেমেন্ট) একটি কাজ ঘটল কি না, তা ব্যর্থতার মুহূর্তে নিশ্চিতভাবে জানার উপায় নেই। তাই ব্যবহারিক সূত্র: at-least-once delivery + idempotent handler = কার্যত একবার. পেমেন্টে এর বাস্তব রূপ পর্ব ৬৫-এ আসবে।
৬ · Outbox: commit-এর পরে job দেওয়া
এবার একটি সূক্ষ্ম প্রশ্ন। অর্ডার যায় PostgreSQL-এ, job যায় Redis-এ। দুটি আলাদা জায়গায় পরপর লেখা, যাকে পর্ব ৪৩-এ dual write বলেছি। Commit-এর পরে queue.add-এর আগে process মরলে অর্ডার আছে, invoice কোনোদিন যাবে না। উল্টো ক্রমে job আগে দিলে আরও খারাপ: transaction rollback হলে worker এমন অর্ডারের email পাঠাবে যা নেই।
সমাধান transactional outbox, যা পর্ব ৪৩-এ (Application-এ transaction) message broker-এর জন্য দেখেছেন। এখানে broker-এর জায়গায় আমাদের BullMQ queue. অর্ডারের সাথে একই transaction-এ outbox টেবিলে একটি সারি, আর একটি ছোট relay সেটি পরে queue-তে দেয়। চালাকিটি হলো jobId: relay outbox সারির id দিয়েই job-এর নাম দেয়।
async function relayOnce() {
const c = await pool.connect();
try {
await c.query('BEGIN');
const { rows } = await c.query(`SELECT id, topic, payload FROM outbox
WHERE published_at IS NULL ORDER BY id LIMIT 10 FOR UPDATE SKIP LOCKED`);
for (const r of rows) await queue.add(r.topic, r.payload, { jobId: `outbox-${r.id}` });
await c.query('UPDATE outbox SET published_at = now() WHERE id = ANY($1)', [rows.map((r) => r.id)]);
await c.query('COMMIT');
} catch (e) { await c.query('ROLLBACK'); throw e; } finally { c.release(); }
}
setInterval(() => relayOnce().catch((e) => log.error(e)), 1000);FOR UPDATE SKIP LOCKED মানে দুটি relay চললে একজন যে সারি ধরেছে, অন্যজন সেটি এড়িয়ে যায়, অপেক্ষা করে না। নিচের stepper-এ relay ইচ্ছা করে ভাঙা হলো: job দেওয়ার পরে, চিহ্ন দেওয়ার আগে।
1BEGIN; INSERT order + outbox; COMMIT2relay: SELECT … FOR UPDATE SKIP LOCKED3relay: queue.add(jobId outbox-1…5)4relay: crash → ROLLBACK5relay আবার: একই ৫টি add, তারপর UPDATE6removeOnComplete: true হলে?
| orders | |
| অপ্রকাশিত outbox | |
| Redis-এ job | |
| handler কতবার চলল |
- একসাথে commit. প্রতিটি অর্ডার আর তার outbox সারি একই transaction-এ। Process এখানে মরলেও দুটিই আছে, বা দুটির একটিও নেই। “অর্ডার আছে, job নেই” আর সম্ভব নয়।
- Relay ধরল। ৫টি অপ্রকাশিত সারি lock-সহ। দ্বিতীয় relay থাকলে সে এগুলো এড়িয়ে যাবে।
- Queue-তে দিল। ৫টি job, নাম
outbox-1থেকেoutbox-5. Worker সঙ্গে সঙ্গে সেগুলো চালাতে শুরু করে, তাই handler ৫ বার চলে। - চিহ্নের আগে crash. Transaction rollback, তাই সারিগুলো আবার অপ্রকাশিত। Relay জানে না ৫টি job আগেই চলে গেছে।
- আবার চেষ্টা। একই ৫টি
queue.add, কিন্তু একইjobId. Job-গুলো Redis-এ এখনো আছে (completed অবস্থায়), তাই নতুন job হলো না। Handler মোট ৫ বারই। এবার চিহ্ন বসল। - ফাঁদ। একই পরীক্ষা
removeOnComplete: trueদিয়ে: completed job মুছে গিয়েছিল, তাই একই id-তে নতুন job তৈরি হলো, আর handler ১০ বার চলল। jobId-এর পাহারা শুধু ততক্ষণ, যতক্ষণ job Redis-এ আছে। তাই handler-এর idempotency তবুও লাগে।
সত্যিকারের run · PostgreSQL 18.6, BullMQ 6.5.3 · একটি local কম্পিউটারে একবার · stepper-এর মান এখান থেকে
=== removeOnComplete: false
relay pass 1: relay crashed after add, before marking
relay pass 2: handed 5 events to the queue
handler runs: 5 for 5 orders, unpublished outbox rows: 0
=== removeOnComplete: true
relay pass 1: relay crashed after add, before marking
relay pass 2: handed 5 events to the queue
handler runs: 10 for 5 orders, unpublished outbox rows: 0
ব্যবহারিক মিল এভাবে: completed job কিছুক্ষণ রেখে দিন, যেমন removeOnComplete: { age: 86400 } (এক দিন), যাতে relay-এর সাধারণ পুনরাবৃত্তি ধরা পড়ে। আর handler idempotent রাখুন, যাতে এক দিনের পরের বিরল duplicate-ও ক্ষতি না করে। Outbox-এর তত্ত্ব আর change data capture দেখুন Pub/Sub আর event-driven পর্বে।
৭ · Redis নেই, PostgreSQL আছে: pg-boss
বাজারঘরের মতো ছোট দলে শুধু queue-র জন্য Redis চালানো মানে আরেকটি server, আরেকটি backup, আরেকটি জিনিস যা রাত ২টায় ভাঙতে পারে। PostgreSQL তো আছেই। PostgreSQL 9.5 (২০১৬) থেকে SKIP LOCKED আছে, যা দিয়ে ডেটাবেসের টেবিলকেই নিরাপদ queue বানানো যায়। এর ওপর তৈরি দুটি পরিচিত library: pg-boss (লেখার সময়ে 12.38.0) আর Graphile Worker (0.18.0). আমরা প্রথমটি চালিয়েছি।
সবচেয়ে বড় লাভটি দেখুন: job যোগ করা যায় অর্ডারের একই transaction-এর ভেতরে. তখন outbox লাগেই না, কারণ অর্ডার আর job একই ডেটাবেসে একসাথে commit বা rollback হয়।
import { PgBoss } from 'pg-boss';
const boss = new PgBoss(process.env.DATABASE_URL);
await boss.start();
await boss.createQueue('invoice-dead');
await boss.createQueue('invoice',
{ retryLimit: 2, retryDelay: 1, retryBackoff: true, deadLetter: 'invoice-dead' });
const client = await pool.connect();
const db = { executeSql: (text, values) => client.query(text, values) };
await client.query('BEGIN');
await client.query('INSERT INTO orders (id, total) VALUES ($1, $2)', [id, total]);
await boss.send('invoice', { orderId: id }, { db }); // একই transaction
await client.query('COMMIT'); // বা ROLLBACK: তাহলে job-ও নেই
await boss.work('invoice', async ([job]) => { /* PDF আর email */ });সত্যিকারের run · pg-boss 12.38.0, PostgreSQL 18.6 · একটি local কম্পিউটারে একবার · অর্ডার ৩০৩-এর handler ইচ্ছা করে ব্যর্থ
order 301: committed
order 302: rolled back (payment declined)
order 303: committed
3 ms invoice job for order 301
510 ms invoice job for order 303
2028 ms invoice job for order 303
6068 ms invoice job for order 303
6574 ms dead letter got order 303
┌─────────┬────────────────┬─────────────┬─────────────┬──────────┐
│ (index) │ name │ state │ retry_count │ order_id │
├─────────┼────────────────┼─────────────┼─────────────┼──────────┤
│ 0 │ 'invoice' │ 'completed' │ 0 │ '301' │
│ 1 │ 'invoice' │ 'failed' │ 2 │ '303' │
│ 2 │ 'invoice-dead' │ 'completed' │ 0 │ '303' │
└─────────┴────────────────┴─────────────┴─────────────┴──────────┘
অর্ডার ৩০২ rollback হয়েছে, আর তার কোনো job টেবিলে নেই: যে অর্ডার নেই, তার invoice-ও যাবে না। অর্ডার ৩০৩ একবার চলে, দুবার retry করে (অপেক্ষা প্রায় ১.৫ আর ৪ সেকেন্ড, backoff আর jitter-সহ), তারপর failed হয়ে আলাদা invoice-dead queue-তে গেছে। শেষ টেবিলটি সাধারণ SQL, কারণ job-গুলো একটি সাধারণ PostgreSQL টেবিল।
| বিষয় | BullMQ + Redis | pg-boss + PostgreSQL |
|---|---|---|
| নতুন server | Redis লাগে, noeviction আর persistence-সহ | বিদ্যমান ডেটাবেস |
| অর্ডারের সাথে একই transaction | না, outbox লাগে | হ্যাঁ |
| প্রতি সেকেন্ডে অনেক job | সাধারণত শক্তিশালী | ডেটাবেসের লেখা আর vacuum-এর চাপ বাড়ে |
| Job দেখা আর খোঁজা | Dashboard বা API | সাধারণ SQL |
| Rate limit, progress, flow | ভেতরেই আছে | কিছু আছে, কিছু নিজে বানাতে হয় |
কোনটি ভালো, তা নিয়ে দলগুলোর মত আলাদা। মোটা দাগে: job কম (প্রতি সেকেন্ডে কয়েকটি থেকে কয়েকশো) আর PostgreSQL আছে, তাহলে PostgreSQL queue সরল। Job অনেক বেশি, বা Redis আগেই চলছে (cache, session), তাহলে BullMQ. মাপের জন্য নিজের যন্ত্রে মেপে দেখুন, কারণ কোনো সাধারণ সংখ্যা আপনার অবস্থা বলবে না।
৮ · Cron: নির্ধারিত সময়ের কাজ, lock আর Asia/Dhaka
কিছু কাজ কোনো ঘটনায় নয়, ঘড়িতে চলে: প্রতিদিন সকাল ৯টায় গতকালের বিক্রির রিপোর্ট, প্রতি শুক্রবার newsletter, প্রতি রাতে পুরোনো cart মোছা। একে বলে cron job, আর সময়টি লেখা হয় একটি cron expression দিয়ে: পাঁচটি ঘর, মিনিট, ঘণ্টা, মাসের দিন, মাস আর সপ্তাহের দিন। 0 9 * * * মানে প্রতিদিন ৯টা ০০ মিনিট।
0 9 * * 5মিনিট · ঘণ্টা · মাসের দিন · মাস · সপ্তাহের দিন (০ = রবিবার)
= প্রতি শুক্রবার সকাল ৯টা
Node.js-এ তিনটি সাধারণ পথ আছে, আর তিনটির আচরণ খুব আলাদা।
| পথ | কোথায় চলে | দুই instance হলে | কখন মানানসই |
|---|---|---|---|
node-cron (4.6.0) | App process-এর ভেতরে | প্রতিটি instance চালায় | একটিমাত্র process, বা প্রতিটি process-এর নিজের কাজ |
| BullMQ job scheduler | Redis ঠিক করে, যেকোনো একজন worker চালায় | একবার | Queue আগেই আছে |
| Platform-এর cron | Linux cron বা systemd timer, Kubernetes CronJob, hosting-এর scheduler | Platform একবার ডাকে (নিশ্চয়তা platform-এর ওপর) | App-এর বাইরে, একটি command বা URL ডাকা |
প্রথম সারির “প্রতিটি instance চালায়” কথাটি আসল run-এ দেখুন। একই app-এর দুটি process, প্রতিটিতে প্রতি ২ সেকেন্ডের একটি node-cron. তারপর একই দুটি process, কিন্তু সময়ের নিয়ম BullMQ-র upsertJobScheduler-এ।
// প্রতিটি instance-এ, app চালুর সময়ে
cron.schedule('*/2 * * * * *', stockReport, { timezone: 'Asia/Dhaka' }); // node-cron
// একবার, deploy বা app চালুর সময়ে (upsert তাই বারবার ডাকলেও একটিই থাকে)
await queue.upsertJobScheduler('daily-sales',
{ pattern: '0 9 * * *', tz: 'Asia/Dhaka' },
{ name: 'daily-sales', data: {} });সত্যিকারের run · node-cron 4.6.0, BullMQ 6.5.3 · দুটি আলাদা process · একটি local কম্পিউটারে একবার
=== two instances, each with node-cron every 2 s
631 ms B node-cron: stock report
646 ms A node-cron: stock report
2644 ms B node-cron: stock report
2644 ms A node-cron: stock report
4632 ms A node-cron: stock report
4632 ms B node-cron: stock report
=== two instances, one BullMQ job scheduler every 2 s
254 ms A bullmq: stock-report repeat:stock-report:1793643242458
2031 ms A bullmq: stock-report repeat:stock-report:1793643244458
4082 ms B bullmq: stock-report repeat:stock-report:1793643246458
=== daily 09:00 in Asia/Dhaka
scheduler daily-sales pattern="0 9 * * *" tz=Asia/Dhaka next=2026-11-03T03:00:00.000Z
node-cron next 3: 2026-11-03T03:00:00.000Z 2026-11-04T03:00:00.000Z 2026-11-05T03:00:00.000Z
node-cron-এ ৫ সেকেন্ডে রিপোর্ট ৬ বার, প্রতিটি সময়ে দুবার। রিপোর্ট শুধু পড়লে সমস্যা ছোট। কিন্তু কাজটি যদি “সব বকেয়া অর্ডারের ক্রেতাকে SMS” হয়, প্রতিজন দুটি SMS পাবেন, আর যত instance তত SMS. BullMQ-তে প্রতি সময়ে একটিই job, কারণ job-এর id-তে সময়টি লেখা (repeat:stock-report:…), আর যেকোনো একজন worker সেটি নেয়: কখনো A, কখনো B. শেষ দুই লাইন দেখায় ঢাকার সকাল ৯টা মানে UTC 03:00.
আগের run শেষ না হতেই পরেরটি: overlap আর lock
রিপোর্টে যদি interval-এর চেয়ে বেশি সময় লাগে? node-cron 4-এ noOverlap option আছে। প্রতি ১ সেকেন্ডের একটি task, যা ২.৫ সেকেন্ড নেয়, ৫ সেকেন্ড ধরে:
সত্যিকারের run · node-cron 4.6.0 · একটি local কম্পিউটারে একবার · library-র সতর্কবার্তা ছাঁটা
node-cron every 1 s, job 2.5 s, noOverlap=false: started 5, most at once 3
[NODE-CRON] [WARN] task still running, new execution blocked by overlap prevention!
…
node-cron every 1 s, job 2.5 s, noOverlap=true: started 2, most at once 1
noOverlap ছাড়া একই রিপোর্ট একসাথে তিনটি পর্যন্ত চলেছে, যা ডেটাবেসে তিনগুণ চাপ আর হয়তো ভুল হিসাব। কিন্তু এই option একটি process-এর ভেতরে কাজ করে। কয়েকটি server জুড়ে একবার চালাতে চাই একটি ভাগ করা lock: BullMQ scheduler, PostgreSQL-এর advisory lock (pg_try_advisory_lock), বা পর্ব ৪৪-এর Redis lock. Lock পেলে কাজ চলবে, না পেলে চুপচাপ বাদ যাবে।
Time zone: সকাল ৯টা কার ঘড়িতে?
Server প্রায় সব সময় UTC-তে চলে। tz ছাড়া 0 9 * * * মানে UTC-র ৯টা, যা ঢাকায় বিকেল ৩টা। বাংলাদেশ এখন সারা বছর UTC+6, তাই নিচের ছোট parser একটি স্থির offset নিয়েছে। আসল library এর বদলে IANA time zone database পড়ে, যেখানে ইতিহাসের সব বদল লেখা থাকে।
// শেখানোর মডেল: ছোট cron parser (*, */n, a, a-b, a,b), Asia/Dhaka = UTC+6
function field(spec, lo, hi) {
const ok = new Set();
for (const part of spec.split(',')) {
const [range, step = '1'] = part.split('/');
let [a, b] = range === '*' ? [lo, hi] : range.split('-').map(Number);
if (b === undefined) b = range.includes('/') ? hi : a;
for (let v = a; v <= b; v += Number(step)) ok.add(v);
}
return ok;
}
function nextRuns(expr, fromUtcMs, offsetMin, count) {
const [m, h, dom, mon, dow] = expr.split(' ');
const F = [field(m, 0, 59), field(h, 0, 23), field(dom, 1, 31), field(mon, 1, 12), field(dow, 0, 6)];
const out = [];
let t = Math.floor(fromUtcMs / 60000) * 60000 + 60000; // পরের পুরো মিনিট
while (out.length < count) {
const L = new Date(t + offsetMin * 60000); // ঘড়ির স্থানীয় সময়
if (F[0].has(L.getUTCMinutes()) && F[1].has(L.getUTCHours()) && F[2].has(L.getUTCDate()) &&
F[3].has(L.getUTCMonth() + 1) && F[4].has(L.getUTCDay())) out.push(t);
t += 60000;
}
return out;
}
const fmt = (t, off) => new Date(t + off * 60000).toISOString().slice(0, 16).replace('T', ' ');
const now = Date.UTC(2026, 10, 3, 4, 30); // 2026-11-03 04:30 UTC
for (const [expr, what] of [['0 9 * * *', 'daily sales'], ['*/15 1-2 * * *', 'stock sync'],
['0 10 * * 5', 'Friday newsletter']]) {
console.log(`${expr.padEnd(15)} ${what}`);
for (const t of nextRuns(expr, now, 360, 2)) console.log(` Dhaka ${fmt(t, 360)} = UTC ${fmt(t, 0)}`);
}
const utc = nextRuns('0 9 * * *', now, 0, 1)[0]; // একই লেখা, কিন্তু server UTC-তে
console.log(`server in UTC: '0 9 * * *' fires at Dhaka ${fmt(utc, 360)}`);শুরুর মুহূর্ত UTC 04:30, মানে ঢাকায় সকাল ১০টা ৩০। তাই আজকের ৯টা পেরিয়ে গেছে, আর দৈনিক রিপোর্টের পরের সময় Dhaka 2026-11-04 09:00 = UTC 2026-11-04 03:00. */15 1-2 ঢাকার রাত ১টা থেকে প্রতি ১৫ মিনিট, আর লক্ষ করুন UTC-তে সেটি আগের দিন 2026-11-03 19:00. তারিখ-ভিত্তিক রিপোর্টে “কোন দিনের বিক্রি” প্রশ্নে এই ভুল খুব সাধারণ। শেষ লাইনটি সেই ফাঁদ: time zone ছাড়া একই expression ঢাকায় চলে 15:00-এ। Parser-টি ইচ্ছা করে ছোট: আসল cron-এ মাসের দিন আর সপ্তাহের দিন দুটিই দিলে “যেকোনো একটি মিললে” চলে, এখানে দুটিই মিলতে হয়।
দুটি সতর্কতা। যেসব zone-এ daylight saving আছে, সেখানে বছরে একদিন রাত ২টা দুবার আসে আর একদিন আসেই না, তাই সেই সময়ের cron দুবার চলে বা বাদ যায়। বাংলাদেশে এখন এটি নেই, কিন্তু বিদেশি ক্রেতা বা ব্যবসার জন্য চালানো কাজে আছে। আর ঘড়ির job “ধরা না পড়লে” কী হবে তা ঠিক করুন: server বন্ধ থাকায় সকাল ৯টার রিপোর্ট বাদ গেলে পরে চালাবেন কি না, library-ভেদে আচরণ আলাদা।
৯ · Production-এ: পর্যবেক্ষণ, serverless আর বাজারঘরের পুরো flow
Queue-র তিনটি নাড়ি
Queue চুপচাপ খারাপ হয়। API দ্রুত চলছে, কোনো error নেই, অথচ email দুই ঘণ্টা দেরিতে যাচ্ছে। তাই তিনটি সংখ্যা নিয়মিত দেখুন, আর পর্ব ৩১-এর metric-এর মতো সেগুলো graph-এ রাখুন।
- Queue depth: কয়টি job অপেক্ষায়। শুধু এটি যথেষ্ট নয়, কারণ ৫০০টি ১০ ms-এর job আর ৫০০টি ১০ সেকেন্ডের job এক কথা নয়।
- সবচেয়ে পুরোনো অপেক্ষমাণ job-এর বয়স: ক্রেতার অভিজ্ঞতার সবচেয়ে কাছের সংখ্যা। “invoice ৫ মিনিটের বেশি দেরি” ধরনের alert এর ওপরেই বসান।
- ব্যর্থতার হার: failed ÷ (completed + failed). হঠাৎ লাফ মানে প্রায়ই বাইরের সেবা পড়েছে, বা নতুন deploy-এ ভুল।
const c = await queue.getJobCounts('waiting', 'active', 'delayed', 'completed', 'failed');
const [oldest] = await queue.getWaiting(0, 0); // সবচেয়ে পুরোনো একটি
const ageMs = oldest ? Date.now() - oldest.timestamp : 0;
const failRate = c.failed / Math.max(1, c.completed + c.failed);সত্যিকারের run · ৪০টি email job, প্রতি ১০টির একটি bounce, concurrency 2, ১ সেকেন্ড পরে worker থামিয়ে ছবি তোলা
depth waiting=20 active=0 delayed=0
oldest waiting job 21 age 1.1 s
completed=18 failed=2 failure rate 10.0%
সংখ্যাগুলো Little-এর সূত্র দিয়ে মেলানো যায় (Little’s Law): ২টি একসাথে, প্রতিটি ১০০ ms, মানে সেকেন্ডে প্রায় ২০টি, আর ১ সেকেন্ডে ৪০-এর অর্ধেক শেষ। যদি job আসার হার শেষ হওয়ার হারের চেয়ে বেশি থাকে, depth আর বয়স দুটিই সীমাহীনভাবে বাড়বে, worker যত দ্রুতই হোক। Job দেখার আর হাতে retry করার জন্য একটি dashboard কাজে লাগে। BullMQ-র জন্য open source Bull Board (@bull-board/express, লেখার সময়ে 9.11.0) একটি পরিচিত বিকল্প। এটি admin route-এ বসালে অবশ্যই login আর role-এর পেছনে রাখুন (পর্ব ৪৮), কারণ সেখানে job data আর হাতে job চালানোর বোতাম থাকে।
Serverless আর Next.js: worker কোথায় চলবে
Worker একটি দীর্ঘজীবী process: সে Redis-এর সাথে সংযোগ খুলে বসে থাকে আর job এলে নেয়। Serverless function উল্টো: অনুরোধ এলে জাগে, উত্তর দিয়ে ঘুমায় বা থেমে যায়, আর সময়সীমা থাকে। তাই Next.js-এর route handler বা server action (পর্ব ৫৪, ৫৭) থেকে job যোগ করা যায়, কিন্তু BullMQ worker সেখানে চালানো যায় না। Worker আলাদা জায়গায় চলে: একটি VPS-এ node worker.js (systemd বা অন্য process supervisor-এর অধীনে), একটি আলাদা container, বা platform-এর “background worker” সেবা।
উদাহরণ · চালানো হয়নি · Next.js 16-এর route handler-এ শুধু job যোগ করা
import { after } from 'next/server';
import { queue } from '@/lib/queue';
export async function POST(req: Request) {
const order = await createOrder(await req.json()); // transaction-সহ
await queue.add('order-confirmed', { orderId: order.id }, { jobId: `order-confirmed-${order.id}` });
after(() => trackEvent('order_created', order.id)); // হারালে ক্ষতি নেই এমন কাজ
return Response.json({ id: order.id }, { status: 201 });
}Next.js-এর after() (15.1 থেকে stable) উত্তর পাঠানোর পরে কাজ চালায়, আর serverless platform-এ waitUntil দিয়ে function-কে কিছুক্ষণ বাঁচিয়ে রাখে। কিন্তু এটি queue নয়: retry নেই, আর platform-এর সময়সীমা পেরোলে বা process মরলে কাজ হারায়। তাই এটি analytics আর log-এর জন্য, invoice-এর জন্য নয়। অনেক hosting provider নিজস্ব hosted queue বা scheduled function দেয়, আর কিছু সেবা HTTP দিয়ে আপনার URL ডেকে job চালায়। এগুলোর delivery-র নিশ্চয়তা, retry আর সময়সীমা প্রতিটিতে আলাদা, তাই ব্যবহারের আগে নিজের provider-এর docs-এ এই তিনটি বিষয় পড়ে নিন। Cloud-এর queue সেবার তুলনা আছে Cloud event আর queue পর্বে, আর function-এর সীমা Serverless function পর্বে।
বাজারঘরের order-confirmed flow
এবার সব একসাথে। অর্ডার commit হলে outbox বা একই transaction দিয়ে একটি order-confirmed job. তার handler invoice PDF বানায় (ফাইল থাকলে আবার বানায় না, তাই idempotent), তারপর দুটি নতুন job দেয়: retry-সহ invoice-email, আর প্রতিটি পণ্যের জন্য stock-check. Stock-check-এর jobId-তে শুধু পণ্যের নাম আর ৫০০ ms delay, তাই অল্প সময়ে একই পণ্যের অনেক অর্ডার এলে একটিই পরীক্ষা হয়।
নিচের run-এর কোডের মূল অংশ, সংক্ষেপিত · run-এ অর্ডার ডেটাবেসের বদলে মেমরিতে ছিল
const handlers = {
async 'order-confirmed'(job) {
const { orderId, items } = job.data;
const path = `invoices/${orderId}.pdf`;
if (!fs.existsSync(path)) fs.writeFileSync(path, await invoicePdf(await loadOrder(orderId)));
await queue.add('invoice-email', { orderId, path }, { jobId: `invoice-email-${orderId}`,
attempts: 5, backoff: { type: 'exponential', delay: 1000, jitter: 0.5 } });
for (const sku of items)
await queue.add('stock-check', { sku }, { jobId: `stock-check-${sku}`, delay: 500 });
},
async 'invoice-email'(job) { await sendInvoiceOnce(job.data.orderId); },
async 'stock-check'(job) { /* স্টক ৫-এর নিচে হলে বিক্রেতাকে জানানো */ },
};
new Worker('bazarghar', (job) => handlers[job.name](job), { connection, concurrency: 4 });সত্যিকারের run · দুটি অর্ডার, দুটিতেই chal-5kg · ফাইল রাখা হয়েছে local folder-এ, আসল ব্যবস্থায় object storage (পর্ব ৬৪)
27 ms done order-confirmed-502 {"pdfBytes":1748}
28 ms done order-confirmed-501 {"pdfBytes":1748}
344 ms done invoice-email-501
344 ms done invoice-email-502
568 ms done stock-check-chal-5kg "LOW chal-5kg: 1 left, notify the seller"
569 ms done stock-check-dal-1kg "dal-1kg: 39 ok"
counts {"waiting":0,"active":0,"delayed":0,"completed":6,"failed":0}
emails 2, pdf files 501.pdf 502.pdf
দুটি অর্ডার chal-5kg-এর জন্য দুবার stock-check চেয়েছিল, কিন্তু একই id-তে একটিই হয়েছে, আর সেটি চলেছে দুটি অর্ডারের পরে, তাই সঠিক সংখ্যা দেখেছে: ১টি বাকি। মোট ৬টি job, ০টি ব্যর্থ। Email-এর template, SMS আর push-এর বিস্তারিত পর্ব ৬৩-এ (Email আর notification).
আরও গভীরে: BullMQ-র প্রতিটি অবস্থা-বদল (waiting থেকে active, active থেকে completed) Redis-এ একটি Lua script হিসেবে চলে, তাই মাঝপথে অন্য client ঢুকতে পারে না। Worker নতুন job-এর জন্য blocking command-এ অপেক্ষা করে, polling করে না। PostgreSQL queue-তে একই কাজ করে SELECT … FOR UPDATE SKIP LOCKED, আর pg-boss জাগানোর জন্য LISTEN/NOTIFY ব্যবহার করতে পারে। দুই জগতেই মূল সমস্যা এক: “কে এই job-এর মালিক, কতক্ষণের জন্য”, অর্থাৎ lease. বড় আকারের workflow engine-এর ধারণা আছে Job আর workflow পর্বে।
পরীক্ষা ও ইন্টারভিউতে:
- Background job কেন? ধীর বা অনিশ্চিত কাজ ক্রেতার উত্তর থেকে আলাদা করতে, আর ব্যর্থ হলে retry করতে।
- Exponential backoff-এ jitter কেন? একসাথে ব্যর্থ হওয়া job যাতে একই মুহূর্তে না ফেরে।
- At-least-once মানে কী, আর কী করবেন? Job একাধিকবার আসতে পারে, তাই handler idempotent.
- Commit-এর পরে job হারানো ঠেকাবেন কীভাবে? Outbox, বা একই ডেটাবেসের queue-তে একই transaction-এ job.
- দুই server-এ cron দুবার চলছে, কী করবেন? ভাগ করা lock বা queue-র scheduler, আর
tzনির্দিষ্ট করা।
১০ · AI-এর যুগে: job আর queue-র কোড যাচাই
AI খুব দ্রুত একটি “কাজ করা” queue লিখে দেয়। Demo-তে সব ঠিক থাকে, কারণ এই পর্বের ভুলগুলো ধরা পড়ে শুধু crash-এ, deploy-এ, দুই instance-এ, বা সময়ের সীমানায়। সাধারণ ভুলগুলো:
| AI-এর ভুল | যা ঘটবে | যাচাই |
|---|---|---|
পুরোনো repeat option বা QueueScheduler | BullMQ 6-এ নেই, কোড চলবে না | npm ls bullmq আর সেই version-এর changelog |
jobId-তে : | Custom Id cannot contain : | একবার চালিয়ে দেখা |
Route-এ await ছাড়া promise | Restart-এ কাজ হারায়, log-ও নেই | Process মেরে গুনে দেখা |
| সব error-এ একই retry | ভুল নম্বরে অনর্থক চেষ্টা | স্থায়ী ভুলে UnrecoverableError |
| Handler idempotent নয় | Crash-এর পরে duplicate email বা চার্জ | Worker মাঝপথে মেরে আবার চালানো |
প্রতিটি instance-এ setInterval বা node-cron | N instance মানে N বার | দুটি process চালিয়ে log গোনা |
| Cron-এ time zone নেই | ঢাকার ৯টার কাজ বিকেল ৩টায় | পরের run-এর সময় UTC আর ঢাকায় ছাপানো |
| Job data-য় token বা পুরো ক্রেতার তথ্য | Redis আর dashboard-এ ফাঁস | শুধু id, বাকিটা worker পড়বে |
| Serverless function-এ worker | Job মাঝপথে থেমে যায় | Worker কোথায় চলবে আলাদা করে লেখা |
AI-কে কাজ দেওয়ার সময় ব্যর্থতার অবস্থাগুলো নিজেই বলে দিন। তাহলে সে শুধু সুখের পথ লিখবে না, আর আপনি মেলানোর মতো পরীক্ষাও পাবেন।
AI-এর লেখা queue হাতে পেলে
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।
প্র ০১কোনো queue কেন “ঠিক একবার” delivery-র নিশ্চয়তা দিতে পারে না? Two Generals সমস্যার সাথে এর সম্পর্ক কী?
দুই সেনাপতির একটি পুরোনো ধাঁধা আছে, যা ১৯৭০-এর দশকে কম্পিউটার নেটওয়ার্কের গবেষণায় আসে আর Jim Gray-এর লেখায় “Two Generals” নামে পরিচিত হয়। দুই সেনাপতি একসাথে আক্রমণ করতে চান, কিন্তু তাঁদের মধ্যে বার্তাবাহক শত্রুর এলাকা পেরিয়ে যায় আর ধরা পড়তে পারে। প্রথমজন বার্তা পাঠালেন, কিন্তু জানেন না পৌঁছেছে কি না। দ্বিতীয়জন উত্তর দিলেন, কিন্তু তিনিও জানেন না উত্তর পৌঁছেছে কি না। যত বার্তাই পাঠান, শেষ বার্তাটি নিয়ে সন্দেহ থেকে যায়। প্রমাণ করা যায়, অনির্ভরযোগ্য পথে দুই পক্ষ নিশ্চিতভাবে একমত হতে পারে না।
Worker আর queue-র অবস্থা হুবহু এরকম। Worker email পাঠাল, তারপর queue-কে “শেষ” বলতে গেল। এই বার্তা হারালে, বা worker তার আগেই মরলে, queue জানে না কাজ হয়েছে কি না। তার সামনে দুটি পথ। আবার পাঠালে কাজ দুবার হতে পারে: at-least-once. না পাঠালে কাজ একবারও না হতে পারে: at-most-once. তৃতীয় কোনো পথ নেই, কারণ “কাজটি ঘটেছে” এই তথ্যটি এমন জায়গায় আছে (provider-এর server) যা queue দেখতে পায় না।
তাহলে যেসব সিস্টেম “exactly-once” লেখে, তারা কী বোঝায়? সাধারণত একটি সীমিত জগতের ভেতরের নিশ্চয়তা, যেমন একই সিস্টেমের ভেতরে পড়া আর লেখা একটি transaction-এ বাঁধা। বাইরের পৃথিবীর কাজ, যেমন email বা পেমেন্ট, সেই জগতের বাইরে। তাই শিল্পে প্রচলিত সূত্র হলো at-least-once delivery আর idempotent processing একসাথে। ফলটি একবার হওয়ার মতো দেখায়, যদিও চেষ্টা একাধিকবার হতে পারে।
এই পর্বের run দুটি ঠিক এই কথা বলে। সাধারণ handler-এ provider দুটি email পেয়েছে। Idempotent handler-এ একটি। Queue দুই ক্ষেত্রে একই আচরণ করেছে, পার্থক্য এসেছে handler-এর নকশা থেকে। আর যেখানে চিহ্ন রাখা আর কাজ এক transaction-এ বাঁধা যায় না, সেখানে শিকলের পরের কড়া, অর্থাৎ provider-এর idempotency key, শেষ পাহারা দেয়।
মূল উপলব্ধি: “আমার queue কি duplicate দেবে?” প্রশ্নটি ভুল। সঠিক প্রশ্ন: “duplicate এলে আমার handler কী করবে?” উত্তর লেখা থাকতে হবে কোডে, আশায় নয়।
প্র ০২Cron এসেছে কোথা থেকে, আর এক server থেকে অনেক server-এ গেলে এর কোন অনুমানগুলো ভেঙে যায়?
Cron Unix-এর পুরোনো সঙ্গী। সাধারণভাবে বলা হয়, এটি ১৯৭০-এর দশকের শেষে AT&T-র Unix-এ (Version 7, ১৯৭৯) এসেছিল। পরে Paul Vixie ১৯৮০-এর দশকের শেষে যে সংস্করণ লেখেন, তা “Vixie cron” নামে বহু Linux-এ দীর্ঘদিন ছিল, আর আজকের পাঁচ ঘরের পরিচিত লেখার ধরন সেখান থেকেই ছড়িয়েছে। Cron-এর মূল অনুমান সহজ: একটি যন্ত্র, একটি ঘড়ি, একটি crontab ফাইল।
এই অনুমান তিনভাবে ভাঙে। প্রথম, একাধিক server: একই crontab প্রতিটি যন্ত্রে থাকলে প্রতিটি কাজ N বার চলে, যা আমাদের run-এ দুই process-এ দুবার দেখা গেছে। কেউ একটি “cron server” বানান, কিন্তু সেটি নিজেই একক ব্যর্থতার জায়গা হয়ে যায়। দ্বিতীয়, overlap: cron কাজ শুরু করে, শেষ হলো কি না দেখে না। আগের run শেষ না হলে পরেরটি পাশাপাশি চলে, আর আমাদের run-এ একই রিপোর্ট তিনটি পর্যন্ত একসাথে চলেছে। তৃতীয়, হারানো সময়: যন্ত্র বন্ধ থাকলে সেই সময়ের কাজ সাধারণ cron চুপচাপ বাদ দেয়, আর কেউ জানতে পারে না।
সমাধানগুলো তাই cron-এর বাইরে থেকে আসে। ভাগ করা lock (ডেটাবেসের advisory lock, Redis lock) নিশ্চিত করে একবার চলবে। Queue-র scheduler সময়টিকে job-এর id বানিয়ে duplicate আটকায়, আর কাজটি সাধারণ job হিসেবে retry আর dead letter পায়। Kubernetes CronJob-এর মতো platform scheduler-এ overlap-এর নিয়ম (যেমন নতুনটি চলবে কি না) আর বাদ পড়া run নিয়ে আলাদা setting থাকে, তাই docs পড়ে সেগুলো বেছে নিতে হয়।
আরেকটি নীরব অনুমান হলো কাজটি “একবার চললেই ঠিক”। বাস্তবে নির্ধারিত কাজকেও idempotent রাখতে হয়: “আজকের রিপোর্ট” এমনভাবে লিখুন যাতে দুবার চললে দুটি রিপোর্ট না হয়ে একটিই হালনাগাদ হয়, যেমন তারিখকে key বানিয়ে upsert.
মূল উপলব্ধি: Cron শুধু বলে “কখন”। “কে”, “একবারই”, “আগেরটি শেষ হলে” আর “বাদ পড়লে কী”, এই চারটি প্রশ্নের উত্তর আপনাকে আলাদা করে দিতে হয়।
প্র ০৩ডেটাবেসকে queue হিসেবে ব্যবহার একসময় “anti-pattern” বলা হতো। PostgreSQL-এর SKIP LOCKED কী বদলাল, আর কোন সীমা এখনো আছে?
পুরোনো পরামর্শটির পেছনে বাস্তব কারণ ছিল। একটি টেবিলে job রেখে কয়েকজন worker যদি SELECT … LIMIT 1 দিয়ে পরের job খোঁজে, সবাই একই সারি পায়। Lock নিলে বাকিরা সেই সারির জন্য লাইনে দাঁড়ায়, আর worker বাড়ালে গতি না বেড়ে অপেক্ষা বাড়ে। তাই বলা হতো, queue-র জন্য আলাদা broker ব্যবহার করুন।
PostgreSQL 9.5 (২০১৬ সালের শুরুতে প্রকাশিত) FOR UPDATE SKIP LOCKED যোগ করে। এখন প্রতিটি worker এমন সারি নেয় যা কেউ ধরেনি, আর ধরা সারি এড়িয়ে যায়, অপেক্ষা করে না। দশজন worker দশটি আলাদা job পায়। এর ওপরেই pg-boss, Graphile Worker আর অন্য ভাষার একই ধরনের library তৈরি। আর সবচেয়ে বড় উপহারটি এই পর্বের run-এ দেখেছেন: job যোগ করা আর অর্ডার লেখা একই transaction-এ, তাই rollback হওয়া অর্ডার ৩০২-এর কোনো job তৈরিই হয়নি, outbox ছাড়াই।
সীমাগুলোও বাস্তব। PostgreSQL-এ প্রতিটি update নতুন সারির সংস্করণ তৈরি করে, আর পুরোনোগুলো পরিষ্কার করে vacuum. প্রতি সেকেন্ডে হাজার হাজার job এলে টেবিল আর index ফুলে উঠতে পারে, আর এই চাপ পড়ে সেই ডেটাবেসেই যেখানে আপনার অর্ডার আর ক্রেতার ডেটা। তাই queue-র ভিড় মূল ব্যবসার query-কেও ধীর করতে পারে। pg-boss নিজেই ফুলে ওঠা index খোঁজার একটি সাহায্যকারী function দেয়, যা দেখায় সমস্যাটি বাস্তব।
তাই সিদ্ধান্তটি মাপের। কম থেকে মাঝারি job, ছোট দল, আর transaction-এর নিশ্চয়তা জরুরি হলে PostgreSQL queue প্রায়ই সবচেয়ে সরল আর নিরাপদ। অনেক বেশি job, অল্প সময়ের অনেক delay, বা Redis আগেই চালু থাকলে Redis-ভিত্তিক queue. আর খুব বড় আকারে বিশেষায়িত broker. লক্ষ করার মতো, BullMQ 6-এর changelog নিজেই PostgreSQL backend-এর কথা বলছে, মানে দুই জগৎ কাছাকাছি আসছে।
মূল উপলব্ধি: “ডেটাবেস queue নয়” কথাটি একটি সময়ের সত্য ছিল। এখন প্রশ্নটি হলো আপনার job-এর হার আর transaction-এর প্রয়োজন, কোন সরঞ্জাম নয়।
প্র ০৪বাংলাদেশ এখন সারা বছর UTC+6-এ চলে। তাহলে cron-এ Asia/Dhaka লেখার বদলে শুধু ৬ ঘণ্টা বিয়োগ করলে সমস্যা কী?
আজকের দিনের জন্য হিসাবটি ঠিকই মেলে: ঢাকার ৯টা মানে UTC-র ৩টা, আর এই পর্বের ছোট parser ঠিক এই স্থির offset দিয়েই চলেছে। কিন্তু time zone শুধু একটি সংখ্যা নয়, এটি একটি ইতিহাসসহ নিয়ম। বাংলাদেশ ২০০৯ সালে কয়েক মাসের জন্য daylight saving চালু করেছিল, বিদ্যুৎ সাশ্রয়ের উদ্দেশ্যে, আর বছরের শেষে তা তুলে নেওয়া হয় বলে জানা যায়। সেই সময়ের কোনো পুরোনো ডেটার সময় হিসাব করতে হলে স্থির +6 ভুল ফল দেবে। IANA-র time zone database (যাকে tz বা Olson database বলা হয়, Arthur David Olson-এর শুরু করা) এই ধরনের সব বদল দেশ ধরে লিখে রাখে, আর Asia/Dhaka নামটি সেই নিয়মের সারিকে বোঝায়।
দ্বিতীয় সমস্যা ভবিষ্যৎ। সরকার যেকোনো সময় নিয়ম বদলাতে পারে, আর বিশ্বের নানা দেশ তা করেও। কোডে “৬ বিয়োগ” লেখা থাকলে সেই বদলে আপনার সব cron চুপচাপ এক ঘণ্টা সরে যাবে। Asia/Dhaka লেখা থাকলে runtime-এর tz ডেটা হালনাগাদ হলেই ঠিক হয়ে যাবে।
তৃতীয় সমস্যা মানুষের। কোডে 0 3 * * * দেখে কেউ বুঝবেন না যে এটি আসলে ঢাকার সকাল ৯টা। ছয় মাস পরে কেউ “৩টা কেন?” ভেবে বদলে দিতে পারেন। { pattern: '0 9 * * *', tz: 'Asia/Dhaka' } নিজেই ব্যাখ্যা দেয়।
আর বাজারঘর যদি একদিন বিদেশে থাকা প্রবাসী ক্রেতাদের জন্য রিপোর্ট পাঠায়, যেমন যুক্তরাজ্য বা যুক্তরাষ্ট্রে, সেখানে daylight saving আছে। বছরে একদিন একটি রাতের ঘণ্টা দুবার আসে আর একদিন আসেই না, তাই সেই সময়ের cron দুবার চলে বা বাদ যায়। এর জন্য নিয়ম হলো সংরক্ষণ করুন UTC-তে, দেখান ব্যবহারকারীর zone-এ, আর নির্ধারিত কাজ লিখুন zone-এর নাম দিয়ে। পর্ব ৫৯-এ (বাংলা সাইট) তারিখ দেখানোর দিকটি আছে।
মূল উপলব্ধি: Offset একটি মুহূর্তের উত্তর, zone একটি নিয়ম। Cron-এ নিয়মটি লিখুন, উত্তরটি নয়।
প্র ০৫Redis একটি মেমরির ডেটাবেস। তাহলে তার ওপর বানানো queue-তে job হারানোর ঝুঁকি কোথায়, আর সেটি কীভাবে কমানো যায়?
Redis-ভিত্তিক job queue-র একটি লম্বা ধারা আছে। Ruby জগতে GitHub-এর Resque (২০০৯-এর দিকে) আর পরে Mike Perham-এর Sidekiq (২০১২-এর দিকে) ব্যাপক জনপ্রিয় হয়। Node.js-এ Bull, আর তার পুনর্লিখন BullMQ. এদের জনপ্রিয়তার কারণ গতি আর সরলতা। কিন্তু একটি সত্য মনে রাখতে হয়: Redis ডেটা মেমরিতে রাখে, আর ডিস্কে লেখা আপনার setting-এর ওপর নির্ভর করে।
প্রথম ঝুঁকি persistence. এই পর্বের ল্যাবে Redis চালানো হয়েছে কোনো persistence ছাড়া, কারণ এটি পরীক্ষা। Production-এ এভাবে চললে Redis restart মানে সব waiting আর delayed job শেষ। BullMQ-র production নির্দেশিকা AOF (append only file) চালু করার পরামর্শ দেয়, আর বলে প্রায় প্রতি সেকেন্ডে লেখা সাধারণত যথেষ্ট। এর মানে সবচেয়ে খারাপ অবস্থায় শেষ এক সেকেন্ডের কিছু job হারাতে পারে। এটি মেনে নেওয়ার মতো কি না, তা আপনার ব্যবসার প্রশ্ন।
দ্বিতীয় ঝুঁকি eviction. পর্ব ৪৪-এ দেখেছেন, মেমরি ভরলে Redis key সরাতে পারে। Cache-এর জন্য সেটি ঠিক আছে, queue-র জন্য সর্বনাশ: চুপচাপ একটি job-এর key উধাও। তাই BullMQ-র docs বলে maxmemory-policy হতে হবে noeviction. এর অর্থ, একই Redis-এ cache আর queue রাখলে দুজনের চাহিদা উল্টো। অনেক দল তাই দুটি আলাদা Redis রাখে।
তৃতীয় ঝুঁকি নকশার, Redis-এর নয়: commit-এর পরে queue.add-এর আগে crash. এর উত্তর outbox, বা job-এর সত্যের উৎস ডেটাবেসে রাখা। একটি কার্যকর মিশ্র কৌশল হলো ডেটাবেসে অর্ডারের অবস্থা রাখা (যেমন invoice_sent_at), আর একটি নিয়মিত পরীক্ষা চালানো যা “১ ঘণ্টা আগের অর্ডার, invoice এখনো যায়নি” খুঁজে আবার job দেয়। Queue তখন দ্রুত পথ, আর ডেটাবেস নিরাপত্তা জাল।
মূল উপলব্ধি: Queue-র নির্ভরযোগ্যতা তার সবচেয়ে দুর্বল setting-এর সমান। Persistence, eviction policy আর hand-off, তিনটিই নিজে যাচাই করুন, আর গুরুত্বপূর্ণ কাজের জন্য ডেটাবেসে একটি শেষ পাহারা রাখুন।
অনুশীলন
হিসাব, option লেখা, নকশা বিচার আর ফল আগে থেকে বলা, চার রকম কাজ আছে।
-
Numerical: ঈদের অফারে ১০ মিনিটে ৩,০০০টি
order-confirmedjob এলো, সমান হারে। প্রতিটি job-এ ৮০০ ms লাগে। একটি worker process, concurrency ২। (ক) Worker সেকেন্ডে কয়টি job শেষ করে? (খ) ১০ মিনিট শেষে কয়টি job অপেক্ষায়? (গ) তখন সবচেয়ে পুরোনো অপেক্ষমাণ job-এর বয়স কত? (ঘ) অফার থামার পরে সারি খালি হতে কত সময় লাগবে? (ঙ) সারি না বাড়াতে কমপক্ষে কয়টি একই রকম process লাগবে?(ক) ২ ÷ ০.৮ = ২.৫টি/সেকেন্ড. আসার হার ৩,০০০ ÷ ৬০০ = ৫টি/সেকেন্ড।
(খ) সারি বাড়ে (৫ − ২.৫) = ২.৫টি/সেকেন্ড, ৬০০ সেকেন্ডে ১,৫০০টি.
(গ) ৬০০ সেকেন্ডে শেষ হয়েছে ২.৫ × ৬০০ = ১,৫০০টি, অর্থাৎ যেগুলো প্রথম ৩০০ সেকেন্ডে এসেছিল (ক্রম মেনে)। তাই সবচেয়ে পুরোনো অপেক্ষমাণটি এসেছিল ৩০০ সেকেন্ডে, বয়স ৩০০ সেকেন্ড = ৫ মিনিট. ক্রেতা অর্ডারের ৫ মিনিট পরে invoice পাবেন।
(ঘ) ১,৫০০ ÷ ২.৫ = ৬০০ সেকেন্ড = ১০ মিনিট.
(ঙ) ৫ ÷ ২.৫ = ২টি, কিন্তু তখন ব্যবহার ১০০%, সামান্য ধীরতায় সারি আবার বাড়বে। বাস্তবে ৩টি (ব্যবহার প্রায় ৬৭%). আর মনে রাখুন, email provider-এর নিজের সীমা থাকলে worker বাড়িয়ে লাভ নেই, তখন rate limit-ই সীমা।
-
Option লেখা: একটি BullMQ
queue.addলিখুন: নামcart-reminder, data-তে শুধু cart id ৭৭, ৩০ মিনিট পরে চলবে, একই cart-এর জন্য দ্বিতীয় reminder তৈরি হবে না, মোট ৪ বার চেষ্টা, প্রথম অপেক্ষা ২ সেকেন্ড থেকে দ্বিগুণ হয়ে, ৩০% jitter-সহ। Jitter ছাড়া শেষ চেষ্টা প্রথম চেষ্টার কত পরে হতো?await queue.add('cart-reminder', { cartId: 77 }, { jobId: 'cart-reminder-77', delay: 30 * 60 * 1000, attempts: 4, backoff: { type: 'exponential', delay: 2000, jitter: 0.3 }, });jobId-তে:নেই। Jitter ছাড়া অপেক্ষা ২, ৪ আর ৮ সেকেন্ড, তাই শেষ (চতুর্থ) চেষ্টা প্রথমটির ১৪ সেকেন্ড পরে, চলার সময় বাদ দিয়ে। ৩০% jitter-এ প্রতিটি অপেক্ষা নিজের মানের ৭০% থেকে ১০০%-এর মধ্যে, তাই মোট ৯.৮ থেকে ১৪ সেকেন্ডের মধ্যে। -
নকশা বিচার: AI নিচের কোড দিল, যা বাজারঘরের তিনটি server-এ চলবে। অন্তত পাঁচটি সমস্যা ধরুন।
app.post('/orders', async (req, res) => { const order = await db.order.create({ data: req.body }); sendInvoice(order); // await নেই res.json(order); }); setInterval(async () => { // প্রতি ২৪ ঘণ্টায় রিপোর্ট const sales = await db.order.findMany({ where: { createdAt: { gte: yesterday() } } }); await emailReport('owner@example.com', sales); }, 24 * 60 * 60 * 1000);- Fire-and-forget: restart বা crash-এ invoice হারায়, retry নেই। Queue-তে job দিন।
- Job hand-off নেই transaction-এর সাথে: অর্ডার লেখার পরে crash-এ কোনো চিহ্ন থাকে না। Outbox বা একই transaction-এর queue.
- তিন server, তিন রিপোর্ট: প্রতিটি server-এ
setIntervalচলে। ভাগ করা scheduler বা lock লাগবে। - সময় ভাসমান:
setIntervalprocess চালুর সময় থেকে গোনে, তাই প্রতি deploy-এ রিপোর্টের সময় বদলায়, আর restart-এ গণনা নতুন করে শুরু হয়। Cron expression আরtz: 'Asia/Dhaka'দিন। - “গতকাল” কার ঘড়িতে?
yesterday()server-এর UTC-তে হলে ঢাকার দিনের হিসাব মিলবে না। - বাড়তি:
req.bodyসরাসরি ডেটাবেসে, যাচাই নেই (পর্ব ২৭), আর রিপোর্ট idempotent নয়।
-
ফল আগে বলুন: একটি BullMQ job
attempts: 4আরbackoff: { type: 'exponential', delay: 500 }(jitter নেই) দিয়ে যোগ হলো, আর handler প্রতিবার ব্যর্থ হয়, প্রতিটি চেষ্টায় সময় প্রায় শূন্য। প্রথম চেষ্টা ০ ms-এ হলে বাকি চেষ্টাগুলো কখন হবে? শেষে job-এর state আরattemptsMadeকী? HandlerUnrecoverableErrorছুড়লে কী হতো?অপেক্ষা ৫০০ × 2⁰ = ৫০০, ৫০০ × 2¹ = ১,০০০, ৫০০ × 2² = ২,০০০ ms. তাই চেষ্টা ০, ৫০০, ১,৫০০ আর ৩,৫০০ ms-এ (Redis-এর সামান্য দেরি বাদ দিয়ে)। চতুর্থ ব্যর্থতার পরে আর চেষ্টা নেই: state
failed,attemptsMade৪, আর jobgetFailed()-এর তালিকায় কারণসহ থাকবে।UnrecoverableErrorহলে প্রথম চেষ্টার পরেইfailed,attemptsMade১, এই পর্বের run-এর মতো।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমOutbox ব্যবহার করে অর্ডারের পরে invoice job দেওয়ার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- একই transaction-এ অর্ডার আর outbox সারি লেখা
- Transaction commit করা
- Relay
FOR UPDATE SKIP LOCKEDদিয়ে অপ্রকাশিত সারি ধরে - Outbox সারির id দিয়ে
jobIdবানিয়ে queue-তে দেওয়া - Outbox সারিতে
published_atবসানো - Worker idempotent handler দিয়ে job চালায়
BullMQ-তে একটি job backoff: { type: 'exponential', delay: 1000, jitter: 0.5 } দিয়ে যোগ হলো। দ্বিতীয়বার ব্যর্থ হওয়ার পরে (attemptsMade = 2) পরের চেষ্টার আগে অপেক্ষা কোন পরিসরে পড়বে?
- ৫০০ থেকে ১০০০ ms-এর নিচে
- ২০০০ থেকে ৩০০০ ms
- ১০০০ থেকে ২০০০ ms-এর নিচে
- ঠিক ২০০০ ms
একটি worker invoice email পাঠানোর পরে কিন্তু job-কে completed চিহ্নিত করার আগে SIGKILL-এ মারা গেল। Redis-ভিত্তিক queue সাধারণত কী করবে?
- Job-টিকে completed ধরে নেবে
- Lock-এর মেয়াদ শেষে job আবার চালানো হবে, ফলে email দুবার যেতে পারে
- Job চিরতরে মুছে যাবে
- Job সঙ্গে সঙ্গে failed তালিকায় যাবে
একটি অ্যাপের দুটি instance চলে, আর প্রতিটির ভেতরে node-cron-এ 0 9 * * * দিয়ে “সব বকেয়া ক্রেতাকে SMS” কাজটি আছে, কোনো lock নেই। প্রতিদিন সকালে কী ঘটবে?
- একটি instance নিজে থেকে বাদ পড়বে, SMS একবার যাবে
- কোনো SMS যাবে না
- noOverlap ডিফল্টে চালু থাকায় একবার যাবে
- প্রতিটি ক্রেতা দুটি করে SMS পাবেন
একটি relay outbox সারি থেকে BullMQ-তে jobId: 'outbox-7' দিয়ে job দেয়, আর queue-তে removeOnComplete: true. Job শেষ হয়ে যাওয়ার পরে relay crash-এর কারণে একই সারি আবার একই jobId দিয়ে দিল। কী ঘটবে?
- নতুন job তৈরি হয়ে handler আবার চলবে
- একই id থাকায় নতুন job তৈরিই হবে না
- BullMQ duplicate id-র error ছুড়বে
- আগের job failed অবস্থায় চলে যাবে
একটি Next.js অ্যাপ serverless platform-এ চলে। অর্ডারের পরে invoice PDF আর email নির্ভরযোগ্যভাবে পাঠাতে কোনটি সবচেয়ে উপযুক্ত?
- Route handler-এ
after()-এর ভেতরে PDF আর email - Route handler-এ
awaitছাড়া promise ছেড়ে দেওয়া - Route handler থেকে queue-তে job, আর worker আলাদা দীর্ঘজীবী process-এ
- একই serverless function-এর ভেতরে BullMQ Worker চালু রাখা
একটি server UTC-তে চলে। BullMQ job scheduler-এ { pattern: '0 9 * * *' } দেওয়া হলো, কোনো tz নেই। ঢাকার (UTC+6) ঘড়িতে কাজটি কখন চলবে?
- সকাল ৯টায়
- বিকেল ৩টায়
- রাত ৩টায়
- কখনো চলবে না
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- ধীর কাজ queue-তে: ক্রেতার পরের পর্দায় যা লাগে না, তা job. Fire-and-forget restart-এ হারায়, queue টেকে।
- Retry বুঝে: সাময়িক ভুলে exponential backoff আর jitter, স্থায়ী ভুলে
UnrecoverableError, আর failed তালিকা হলো dead letter. - Duplicate আসবেই: crash-এ stalled job আবার চলে। Handler idempotent, অর্থবহ
jobId, আর completed job কিছুক্ষণ রাখা। - Hand-off নিরাপদে: Redis queue-তে outbox, PostgreSQL queue-তে একই transaction. Worker আলাদা দীর্ঘজীবী process,
worker.close()দিয়ে থামে। - Cron মানে শুধু সময়: এক জায়গা থেকে চালান বা lock নিন, overlap আটকান, আর
Asia/Dhakaলিখুন। Depth, সবচেয়ে পুরোনো job-এর বয়স আর ব্যর্থতার হার দেখুন।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৬৩ · Email আর notification — transactional mail, SMS আর pushপরবর্তী পর্বQueue থেকে email, SMS আর push পাঠানো, বাংলা মেইলসহ।
- ফুল-স্ট্যাক ওয়েব ফ্রেমওয়ার্ক · কোর্স হোম৯৩টি পর্বপুরো কোর্সের রোডম্যাপ, আর পরের পর্ব ৬৩ · Email আর notification.
- পর্ব ৪৩ · Application-এ transactionপেছনের পর্বDual write, outbox আর relay-এর মূল ধারণা।
- পর্ব ৩৩ · Status code আর error formatপেছনের পর্বIdempotency key আর নিরাপদ retry.
- পর্ব ৩১ · Log আর configপেছনের পর্বGraceful shutdown, health check আর metric.
- Job আর workflowগভীরে যানJob-এর জীবনচক্র, distributed cron আর durable workflow engine.
- Message queueগভীরে যানAcknowledgement, ক্রম, poison message আর consumer-এর সংখ্যা হিসাব।
- কেস স্টাডি: notification systemগভীরে যানবড় আকারে email, SMS আর push পাঠানোর নকশা।
- Cloud event আর queueপাশের ট্র্যাকHosted queue আর scheduler-এর ধারণা আর তুলনা।