অধ্যায় ৪

Atomic Locks & Concurrency Control

"Only one process can do this right now."

একই সময়ে একাধিক প্রসেস যেন একই কাজ না করে — Cache, Queue, Scheduler, Artisan, Migration আর Session-এ একই lock মেকানিজম।

~১৪ মিনিট১২টি স্লাইড

সমস্যা: Race Condition

একটি scheduled command প্রতিদিন রিপোর্ট বানায়। ৩টি সার্ভারে স্কেল করার পর কমান্ডটি একসাথে ৩ বার চলছে — ইউজাররা একই ইমেইল ৩ কপি পাচ্ছে।

একটি queue job রিফান্ড প্রসেস করে। দুটি worker একই সময়ে সংশ্লিষ্ট job ধরলো — কাস্টমার দুবার রিফান্ড পেলো।

আরেকটি উদাহরণ

ইউজারের ব্যালেন্স ১০০ টাকা। সে দুটি ট্যাবে একসাথে ১০০ টাকার দুটি জিনিসে “Buy” ক্লিক করলো। সিস্টেম দুটো রিকোয়েস্ট একই মিলিসেকেন্ডে প্রসেস করলে, দুটোই ব্যালেন্স ১০০ দেখবে — ইউজার ১০০ টাকায় ২০০ টাকার জিনিস কিনে ফেলবে!

এগুলো concurrency bug — শুধু তখনই দেখা দেয় যখন একাধিক প্রসেস একসাথে একই কাজ করে, আর ডেভেলপমেন্টে প্রায় কখনো ধরা পড়ে না।

লারাভেলের উত্তর: atomic lock — “এই মুহূর্তে শুধু একটি প্রসেসই এটা করতে পারবে।” মজার ব্যাপার হলো, যে মেকানিজম Cache::lock() চালায়, সেটাই overlapping job আটকায়, scheduled task দুবার চলা বন্ধ করে, Artisan command আলাদা রাখে, আর session write সিরিয়ালাইজ করে।

Atomic Lock কী?

Atomic lock হলো একটি নামযুক্ত রিসোর্সের উপর ‘mutually exclusive claim’:

এক সময়ে একজন

শুধু একটি প্রসেস lock ধরে রাখতে পারে।

বাকিরা অপেক্ষা করে

অন্য প্রসেসকে অপেক্ষা করতে হয় বা হাল ছাড়তে হয়।

অটো এক্সপায়ার

Timeout-এর পর lock নিজেই খুলে যায় — ক্র্যাশ করা প্রসেস সবাইকে চিরকাল আটকে রাখতে পারে না।

লারাভেলের lock তৈরি cache layer-এর উপর। Store atomic অপারেশন সাপোর্ট করলে (Redis, Memcached, DynamoDB ইত্যাদি) একাধিক সার্ভার ও প্রসেস জুড়ে আসল distributed lock পাবেন।

$lock = Cache::lock('import-users', 10); // TTL = ১০ সেকেন্ড

if ($lock->get()) {
    try {
        processUsers();
    } finally {
        $lock->release(); // এরর হলেও lock ছেড়ে দেওয়া হবে
    }
}

সব cache store আসল atomic lock সাপোর্ট করে না। যেখানে concurrency সত্যিই গুরুত্বপূর্ণ, সেখানে Redis ব্যবহার করুন।

Docs: Atomic Locks

Lock ব্যবহারের ৩টি ধরন

১. Lock না পেলে ইউজারকে জানানো

$lock = Cache::lock('process-payment-' . $user->id, 10);

if ($lock->get()) {
    try {
        $user->deductBalance(100);
        $order->process();
    } finally {
        $lock->release();
    }
} else {
    // অন্য কোনো প্রসেস এখন কাজ করছে
    return response('Please wait, another transaction is in progress.', 429);
}

২. Closure দিয়ে — অটো release

Cache::lock('foo', 10)->get(function () {
    // lock পেলে চলবে, শেষে লারাভেল নিজেই release করবে
});

৩. Block — lock পাওয়া পর্যন্ত অপেক্ষা

// সর্বোচ্চ ৩ সেকেন্ড অপেক্ষা; না পেলে LockTimeoutException
Cache::lock('foo', 10)->block(3, function () {
    // Business logic
});

এক প্রসেসে lock, আরেক প্রসেসে release? $lock->owner() টোকেন job-এ পাঠান, পরে Cache::restoreLock('key', $owner)->release()।

Overlapping Queue Job আটকানো

একাধিক worker একসাথে job চালাতে পারে। একই ইউজারের দুটি job দুটি worker একসাথে ধরলে — ডুপ্লিকেট ইমেইল, ডাবল বিলিং বা নষ্ট স্টেট। সমাধান WithoutOverlapping middleware:

use Illuminate\Queue\Middleware\WithoutOverlapping;

public function middleware(): array
{
    return [
        (new WithoutOverlapping($this->userId))->expireAfter(60),
    ];
}

$this->userId দিয়ে key তৈরি হয় — এই key-এর একটি job-ই এক সময়ে চলবে। ভেতরে এটা Redis-এ রাখা সেই একই Cache::lock(), শুধু job middleware-এ মোড়ানো।

মেথডকাজ
expireAfter(60)job ক্র্যাশ করলেও ৬০ সেকেন্ড পর lock খুলবে
releaseAfter(10)lock না পেলে ১০ সেকেন্ড পর job আবার queue-তে
dontRelease()lock না পেলে overlapping job বাদ দাও
Docs: Preventing Job Overlaps

Unique Jobs: Dispatch-এর সময় Lock

WithoutOverlapping একসাথে চলা আটকায়। Unique job আরও এক ধাপ এগিয়ে — ডুপ্লিকেট job queue-তে ঢুকতেই দেয় না।

use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;

class ProcessPodcast implements ShouldQueue, ShouldBeUnique
{
    public $uniqueFor = 3600; // (ঐচ্ছিক) lock কতক্ষণ থাকবে

    public function __construct(public Podcast $podcast) {}

    public function uniqueId(): string
    {
        return $this->podcast->id; // কোন key-তে unique
    }
}
  1. Dispatch-এর আগে লারাভেল lock নেয়।
  2. Lock আগে থেকেই থাকলে job নীরবে বাদ।
  3. Lock ফ্রি থাকলে job queue-তে যায় এবং lock ধরে রাখে।

কখন দরকার? যে webhook বারবার retry হয়, যে বাটনে ইউজার ৩ বার ক্লিক করে, যে cron worker-এর চেয়ে দ্রুত dispatch করে।

Processing শুরু হলেই lock খুলতে চাইলে ShouldBeUniqueUntilProcessing ব্যবহার করুন।

Docs: Unique Jobs

Task Scheduling

Scheduled task প্রায়ই ক্লাস্টারের প্রতিটি সার্ভারে চলে। সমন্বয় না থাকলে ৩ সার্ভারে summary:generate ৩ বার চলবে।

Schedule::command('summary:generate')
    ->everyMinute()
    ->withoutOverlapping();

Command চালানোর আগে লারাভেল lock নেয়। আগের রান শেষ না হলে নতুনটা স্কিপ। একই মেকানিজম, ভিন্ন প্রেক্ষাপট।

মেথডকী আটকায়
withoutOverlapping()আগের রান শেষ না হলে নতুন রান
onOneServer()একই মিনিটে একাধিক সার্ভারে চলা (Redis/Memcached/DB cache লাগবে)

মাল্টি-সার্ভার ডিপ্লয়ে দুটো একসাথে দিন: ->withoutOverlapping()->onOneServer()

Docs: Preventing Task Overlaps

Isolatable Commands ও Migrations

Isolatable Artisan Commands

Command-এ Isolatable interface যোগ করলে, এক সময়ে শুধু একটি instance চলবে:

use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Isolatable;

class ImportUsers extends Command implements Isolatable
{
    protected $signature = 'users:import';
    // ...
}
php artisan users:import --isolated

লারাভেল command-এর নাম দিয়ে lock তৈরি করে। এতে প্যারালাল import একে অপরকে নষ্ট করে না, cleanup task overlap হয় না, ডুপ্লিকেট রিপোর্ট হয় না — লোকালি (দুটি টার্মিনালে) এবং ডিস্ট্রিবিউটেড পরিবেশে দুই জায়গাতেই।

আর্গুমেন্ট অনুযায়ী আলাদা lock চাইলে isolatableId() মেথড ডিফাইন করুন।

Docs: Isolatable Commands

Isolated Migrations

একাধিক সার্ভারে ডিপ্লয়ের সময় দুটি সার্ভার একসাথে migration চালাতে পারে:

php artisan migrate --isolated

একটি সার্ভার migration চালায়, বাকিরা অপেক্ষা বা স্কিপ করে। Schema স্টেট সামঞ্জস্যপূর্ণ থাকে।

Docs: Running Migrations

Session Blocking

একজন ইউজার ৩টি ব্রাউজার ট্যাব থেকে একসাথে ফর্ম সাবমিট করলো। প্রতিটি রিকোয়েস্ট session পড়ে, বদলায়, আবার লেখে। শেষ write জেতে — বাকি দুটি আপডেট নীরবে হারিয়ে যায়।

Session blocking lock দিয়ে প্রতি session-এ রিকোয়েস্টগুলো সিরিয়ালাইজ করে:

Route::post('/order', function () {
    // ...
})->block($lockSeconds = 10, $waitSeconds = 10);
ট্যাব ১ ট্যাব ২ ট্যাব ৩ Session Lock একটা একটা করে

এক session, এক lock, এক সময়ে এক রিকোয়েস্ট। Cart আপডেট বাগ আর মাল্টি-ট্যাব কনফ্লিক্ট দূর হয়।

Atomic lock সাপোর্ট করে এমন cache driver লাগবে (redis, memcached, dynamodb, database, file, array)। আর session driver cookie হলে এটা কাজ করবে না।

Docs: Session Blocking

Lock ব্যবহারের আগে ভাবুন

Lock coordination overhead আনে — উদ্দেশ্যমূলকভাবে ব্যবহার করুন।

TTL সাবধানে বাছুন

প্রসেস ক্র্যাশ করলে lock কতক্ষণ থাকবে, তা TTL ঠিক করে। খুব ছোট হলে দুই প্রসেস overlap করতে পারে; খুব বড় হলে ক্র্যাশ করা প্রসেস কয়েক মিনিট আটকে রাখে। সুরক্ষিত কোডের worst-case সময়ের চেয়ে একটু বেশি রাখুন।

সব সমস্যায় mutual exclusion লাগে না

Lock latency বাড়ায়, হাই concurrency-তে throughput কমায়। অপারেশন যদি idempotent হয় (দুবার চালালেও একই ফল), হয়তো lock-ই লাগবে না। মাঝে মাঝে দুবার চলা idempotent job প্রায়ই distributed lock-এর চেয়ে সহজ ও স্কেলেবল।

Distributed lock নিখুঁত নয়

নেটওয়ার্ক দেরি, worker ক্র্যাশ, ব্যাকএন্ড আউটেজ — lock holder release ছাড়াই ক্র্যাশ করতে পারে, block() প্রত্যাশার চেয়ে বেশি অপেক্ষা করতে পারে, কাজ শেষের আগেই TTL ফুরিয়ে যেতে পারে। এগুলো distributed system-এর বাস্তবতা, লারাভেলের বাগ নয়।

Business invariant যাচাই করুন (যেমন DB-তে unique constraint, ব্যালেন্স চেক)। শুধু lock-এর উপর correctness-এর পুরো ভরসা রাখবেন না।

পুরো লারাভেলে Lock

কোথায়APIকী আটকায়
CacheCache::lock()যেকোনো ক্রিটিক্যাল সেকশন
QueueWithoutOverlappingএকই key-এর job একসাথে চলা
QueueShouldBeUniqueডুপ্লিকেট job queue-তে ঢোকা
SchedulerwithoutOverlapping()task দুবার চলা
ArtisanIsolatable / --isolatedএকই command-এর একাধিক instance
Migrationmigrate --isolatedএকসাথে migration
Session->block()একই session-এ concurrent write

সবার আকার এক

নামযুক্ত lock নাও কাজ করো lock ছাড়ো

লারাভেল concurrency coordination একবার সমাধান করেছে, তারপর যেখানে দরকার সেখানে একই সমাধান প্রয়োগ করেছে।

সারসংক্ষেপ ও যাচাই

  • Lock মানে: এক সময়ে এক প্রসেস, বাকিরা অপেক্ষা করে, TTL-এ অটো এক্সপায়ার।
  • সবসময় try/finally-তে release করুন, অথবা closure ফর্ম ব্যবহার করুন।
  • WithoutOverlapping চলা আটকায়; ShouldBeUnique queue-তে ঢোকা আটকায়।
  • মাল্টি-সার্ভারে Redis-এর মতো atomic store লাগবে।
  • Idempotent হলে lock নাও লাগতে পারে; lock থাকলেও invariant যাচাই করুন।

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

Webhook বারবার retry হয়ে একই job ১০ বার dispatch করছে। কোনটা ব্যবহার করবেন?

ShouldBeUnique — ডুপ্লিকেট job queue-তেই ঢুকবে না।

Lock-এর TTL ৫ সেকেন্ড, কিন্তু কাজ লাগে ২০ সেকেন্ড। কী হবে?

৫ সেকেন্ড পর lock খুলে যাবে, আর অন্য প্রসেস lock নিয়ে একই কাজ শুরু করতে পারবে — overlap। TTL worst-case সময়ের চেয়ে বেশি রাখুন।

File cache driver দিয়ে ৩ সার্ভারে lock কাজ করবে?

না। প্রতিটি সার্ভারের ফাইল আলাদা — lock সার্ভারগুলোর মধ্যে শেয়ার হয় না। Redis/Memcached-এর মতো কেন্দ্রীয় store লাগবে।