Background Job: Celery

Email, notification, report পেছনে চালানো

রোডম্যাপের মাস ৬পড়তে প্রায় ১৩ মিনিট

User order দিলো। এখন যা যা করতে হবে: order save, restaurant কে notification, customer কে SMS আর email, invoice এর PDF বানানো, analytics এ event পাঠানো। সব যদি API request এর ভেতরেই করো, user কে ৫-১০ সেকেন্ড "loading" দেখতে হবে, আর SMS gateway ধীর হলে পুরো order ই ব্যর্থ হতে পারে। সমাধান: শুধু জরুরি কাজটা (order save) এখনই করো, বাকিগুলো একটা queue তে রেখে দাও, আলাদা worker পেছনে সেগুলো করবে। এই অধ্যায়ে Python এর সবচেয়ে প্রচলিত background job system Celery শিখবো।

#কেন background job

Flutter app ২০০ ms এ উত্তর FastAPI ১. order save ২. job queue তে দাও Queue Redis/RabbitMQ Worker: SMS পাঠাও Worker: invoice PDF Worker: email Worker ধীর বা ব্যর্থ হলেও user এর order আটকায় না; job আবার চেষ্টা হয়
API এর কাজ ছোট আর দ্রুত। ভারী বা বাইরের service নির্ভর কাজ queue হয়ে worker এর কাছে যায়।

Background job এর ভালো প্রার্থী:

  • ধীর কাজ: PDF/report বানানো, ছবি resize, video process
  • বাইরের service: SMS, email, push notification, payment gateway এর যাচাই, webhook পাঠানো
  • Retry দরকার: ব্যর্থ হলে কিছুক্ষণ পর আবার চেষ্টা
  • নির্দিষ্ট সময়ে: রাত ২টায় দৈনিক report, প্রতি ঘণ্টায় পুরনো OTP মোছা
  • চাপ সমান করা: হঠাৎ ১০,০০০ email পাঠাতে হলে queue তে রেখে worker নিজের গতিতে পাঠায়

#Celery setup

Terminal
uv add "celery[redis]"
Python
# workers/celery_app.py
from celery import Celery
from app.core.config import get_settings

settings = get_settings()

celery = Celery(
    "notes",
    broker=settings.redis_url,             # job কোথায় রাখা হবে
    backend=settings.redis_url,            # ফলাফল কোথায় (দরকার হলে)
    include=["app.workers.tasks"],
)

celery.conf.update(
    task_acks_late=True,                   # কাজ শেষ হলে তবেই "নিলাম" বলবে
    task_reject_on_worker_lost=True,       # worker মরে গেলে job আবার queue তে
    worker_prefetch_multiplier=1,          # একবারে একটার বেশি job ধরে রাখবে না
    task_time_limit=300,                   # ৫ মিনিটের বেশি চললে মেরে ফেলো
    task_soft_time_limit=270,
    timezone="Asia/Dhaka",
)

#Task লেখা

Python
# workers/tasks.py
import logging
import httpx
from app.workers.celery_app import celery

logger = logging.getLogger(__name__)


@celery.task(
    bind=True,
    autoretry_for=(httpx.HTTPError,),       # এই error হলে নিজে থেকে retry
    retry_backoff=True,                      # 1s, 2s, 4s, 8s ...
    retry_backoff_max=600,
    retry_jitter=True,
    max_retries=6,
)
def send_sms(self, phone: str, message: str, dedup_key: str) -> None:
    if already_sent(dedup_key):              # idempotent: দুইবার চললেও একবারই SMS
        return
    with httpx.Client(timeout=10) as client:
        r = client.post(SMS_URL, json={"to": phone, "text": message}, headers=SMS_HEADERS)
        r.raise_for_status()
    mark_sent(dedup_key)
    logger.info("SMS পাঠানো হলো %s", mask(phone))


@celery.task
def build_invoice(order_id: int) -> str:
    pdf = render_invoice_pdf(order_id)
    key = upload_to_storage(pdf, f"invoices/{order_id}.pdf")
    return key

API থেকে job পাঠানো:

Python
@router.post("/orders", status_code=201)
async def create_order(...):
    order = await svc.create(...)                     # database এ save + commit
    send_sms.delay(order.phone, f"Order #{order.id} নেওয়া হয়েছে", dedup_key=f"order-sms-{order.id}")
    build_invoice.delay(order.id)
    return order

Worker চালানো (আলাদা process, আলাদা container):

Terminal
uv run celery -A app.workers.celery_app worker --loglevel=info --concurrency=4

#নির্ভরযোগ্যতার নিয়ম

#১. Job কে idempotent রাখো

Celery (আর প্রায় সব queue) "at least once" delivery দেয়: একটা job অন্তত একবার চলবে, কিন্তু কখনো কখনো দুইবারও চলতে পারে (worker কাজ শেষ করে "শেষ" বলার আগেই মরে গেলে)। তাই job এমনভাবে লেখো যাতে দুইবার চললেও ক্ষতি না হয়:

  • SMS: dedup_key দিয়ে আগে পাঠানো হয়েছে কিনা দেখো
  • টাকা যোগ: "balance এ ৫০০ যোগ করো" না, "transaction #TX91 প্রয়োগ করো" (যেটা আগে প্রয়োগ হয়ে থাকলে কিছু করবে না)
  • File বানানো: একই নামে বানাও, আবার বানালে শুধু overwrite

#২. Job এ id পাঠাও, পুরো object না

Python
build_invoice.delay(order.id)          # ✓ ছোট, worker নিজে সর্বশেষ data আনবে
build_invoice.delay(order.dict())      # ✗ বড়, আর queue তে থাকার সময় data পুরনো হয়ে যেতে পারে

#৩. Transaction commit এর পরে job পাঠাও

#৪. সময়ের সীমা আর ব্যর্থতার জায়গা

  • প্রতিটা task এ time limit, যাতে আটকে থাকা job worker দখল করে না রাখে
  • বারবার ব্যর্থ হওয়া job কে আলাদা করে রাখো (dead letter queue) আর alert দাও, চুপচাপ হারিয়ে যেতে দিও না

#নির্দিষ্ট সময়ের কাজ: Celery Beat

Python
from celery.schedules import crontab

celery.conf.beat_schedule = {
    "daily-sales-report": {
        "task": "app.workers.tasks.daily_report",
        "schedule": crontab(hour=2, minute=0),          # প্রতিদিন রাত ২টা (ঢাকা)
    },
    "cleanup-expired-tokens": {
        "task": "app.workers.tasks.cleanup_tokens",
        "schedule": crontab(minute=0),                  # প্রতি ঘণ্টায়
    },
    "reconcile-payments": {
        "task": "app.workers.tasks.reconcile_payments",
        "schedule": 300.0,                               # প্রতি ৫ মিনিটে
    },
}
Terminal
uv run celery -A app.workers.celery_app beat --loglevel=info

Beat শুধু একটাই চালাবে। দুইটা চালালে প্রতিটা কাজ দুইবার হবে।

#Docker compose এ

YAML
services:
  api:
    build: .
    command: uvicorn app.main:app --host 0.0.0.0 --port 8000
  worker:
    build: .                               # একই image
    command: celery -A app.workers.celery_app worker --concurrency=4 --loglevel=info
  beat:
    build: .
    command: celery -A app.workers.celery_app beat --loglevel=info
  redis:
    image: redis:7-alpine

একই image থেকে তিন রকম process, শুধু command আলাদা। চাপ বাড়লে শুধু worker এর সংখ্যা বাড়াও।

#বিকল্প

Tool কখন
Celery পরিণত, অনেক feature, সবচেয়ে প্রচলিত; একটু ভারী
ARQ, Dramatiq, Taskiq হালকা; ARQ/Taskiq async-native, FastAPI এর সাথে মানানসই
FastAPI BackgroundTasks খুব ছোট কাজ, response এর পর একই process এ। Retry নেই, process restart হলে হারায়। শুধু log বা ছোট cleanup এর জন্য।
RabbitMQ/Kafka + নিজের consumer বড় system, অনেক service (Queue অধ্যায়)

#অনুশীলন

#নিজেকে যাচাই করো

কোন ধরনের কাজ API request এর ভেতরে না করে background job এ পাঠাবে?

ধীর কাজ (PDF, ছবি process), বাইরের service নির্ভর কাজ (SMS, email, push), retry দরকার এমন কাজ, নির্দিষ্ট সময়ের কাজ, আর অনেকগুলো একসাথে করতে হবে এমন কাজ।

"At least once" delivery মানে কী, আর এর জন্য task কেমন হওয়া উচিত?

একটা job অন্তত একবার চলবে, কিন্তু কখনো কখনো একাধিকবার চলতে পারে। তাই task idempotent হতে হবে: দুইবার চললেও ফল একবার চলার মতোই।

Order এর transaction commit এর আগে SMS job পাঠালে কী সমস্যা হতে পারে?

Worker দ্রুত চালু হয়ে database এ order খুঁজে না পেতে পারে। আর transaction rollback হলে এমন order এর SMS চলে যাবে যেটা আসলে তৈরি হয়নি।

Celery Beat দুইটা চালালে কী হবে?

প্রতিটা নির্দিষ্ট সময়ের কাজ দুইবার করে চলবে, যেমন দৈনিক report দুইবার, payment reconcile দুইবার।

চিহ্ন দিলে sidebar আর রোডম্যাপে এই অধ্যায়ের station ভরে যাবে।