Foundationপ্রথম নীতি থেকে
LEVEL 9লেসন ১১/১৯অ্যাডভান্সড৫২ মিনিট

Two-Phase Commit — সবাই একসাথে, নয়তো কেউই না

Two-Phase Commit

2PC সব participant-কে prepare-vote-এ একমত করিয়ে decide করে — কিন্তু vote yes-এর পরে participant lock ধরে in-doubt বসে থাকে, coordinator crash করলে আটকে যায়; 3PC-র PreCommit ফিক্স নিজেই partition-এ ভাঙে।

এই লেসন শেষে আপনি পারবেন

  • Two-phase commit-এর দুই ধাপ — prepare (vote সংগ্রহ) আর commit/abort (decision broadcast) — নিখুঁতভাবে trace করে participant কখন, কেন commit-এ বাধ্য হয় ব্যাখ্যা করতে পারবেন
  • Vote 'yes'-এর মুহূর্তে participant কীভাবে অপরিবর্তনীয়ভাবে in-doubt state-এ ঢোকে, lock কেন ছাড়া যায় না — blocking-এর যান্ত্রিক কারণ ব্যাখ্যা করতে পারবেন
  • Coordinator decision-এর ঠিক আগে বা মাঝপথে crash করলে participant-রা কীভাবে আটকে যায়, আর persisted decision log থেকে recovery কীভাবে deterministic-ভাবে সবাইকে মুক্ত করে তা trace করতে পারবেন
  • `n` participant আর round-trip `r` নিয়ে lock-holding সময়ের নিম্নসীমা হিসাব করে দেখাতে পারবেন কেন participant-সংখ্যা বাড়লে throughput ভাঙে
  • Three-phase commit-এর CanCommit–PreCommit–DoCommit কাঠামো আর network partition-এ তার split-brain ব্যর্থতা ব্যাখ্যা করে 2PC বনাম 3PC-র practical trade-off যুক্তি দিয়ে বলতে পারবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

কল্পনা করুন checkout-এর মুহূর্ত: Order-service নিজের database-এ order সারি রাখছে, Inventory-service নিজের database-এ stock কমাচ্ছে, Payment-service নিজের database-এ card charge লিখছে। প্রতিটা database independent — নিজের commit, নিজের crash, নিজের network। কিন্তু ব্যবসা-নিয়ম একটাই: হয় তিনটাই commit হবে, নয়তো একটাও না। Order বসে থাকল, inventory কমল না — বা উল্টো — দুটোই অসহনীয়।

সমস্যাটা নতুন নয়। ১৯৭৮ সালে Jim Gray যখন distributed database-এর জন্য প্রথম commit protocol লিখলেন, প্রশ্নটা ছিল ঠিক এই: participant-রা কেউ কারো commit control করে না, তবু সবাইকে align করাবে কে? উত্তর: একজন coordinator, আর দুই ধাপের কথোপকথন — আগে জিজ্ঞেস, পরে আদেশ। আজকের লেসন সেই protocol-এর প্রতিটা কোণ চিরে দেখবে — বিশেষ করে সেই কোণটা যেখানে protocol নিজেই ভেঙে পড়ে: coordinator মরলে কী হয়।

মূল ধারণা

দুই ধাপ, নিখুঁতভাবে

Two-phase commit নামটাই protocol — দুই ধাপ, দুইবার network round:

ধাপCoordinator করেParticipant করেParticipant-এর অবস্থা
১. Prepareসব participant-কে PREPARE পাঠায়change durable log-এ লেখে, row-lock নেয়, vote YES/NO ফেরত পাঠায়YES → IN-DOUBT (lock ধরে); NO → নিজেই সাথে সাথে ABORTED
২. Decideসব vote YES হলে decision COMMIT, একটাও NO/timeout হলে ABORT — নিজের log-এ durably লেখে, তারপর broadcast করেনিজের log-এ decision লেখে, lock ছাড়ে, ack পাঠায়terminal — COMMITTED অথবা ABORTED

মূল সূক্ষ্মতা YES-এর ভেতরে লুকানো। Vote YES দেওয়ার মুহূর্তে participant একটা প্রতিশ্রুতি দেয় যেটা আর ফেরানো যায় না: coordinator যা বলবে আমি তা-ই করব — চাই commit, চাই abort। এই প্রতিশ্রুতি রাখতে হলে ভোটের আগেই তার change durable + locked থাকতে হয় — না হলে পরে commit করতে বললে data হারিয়ে গেছে বা অন্য transaction lock নিয়ে নিয়েছে, প্রতিশ্রুতি রাখা অসম্ভব হয়ে যেত। তাই YES মানে: redo/undo record fsync হয়ে গেছে, প্রয়োজনীয় row-lock ধরা আছে, আর participant এখন থেকে in-doubt — নিজে থেকে আর কিছু করতে পারবে না, খালি coordinator-এর decision-এর অপেক্ষা।

ঠিক এই বাধ্যতামূলক অপেক্ষার নামই blocking — participant lock ছাড়তে পারবে না যতক্ষণ না decision আসে, আর decision কবে আসবে তার কোনো নিশ্চয়তা নেই যদি coordinator ধীর হয় বা মরে যায়।

Lock-stretch — গণিতটা বেরিয়ে আসুক

n participant, network round-trip r। Best-case হিসাব সহজ: prepare broadcast + vote সংগ্রহ = এক r; decide broadcast + ack = আরেক r — তাই lock ধরা থাকে অন্তত 2r সময়ের জন্য, ধরেই নিলাম সবাই instant respond করে আর দুই phase parallel fan-out-এ চলে।

বাস্তবে দুইটা জিনিস 2r-কে বাড়িয়ে দেয়:

  • Slowest-wins নিয়ম: coordinator phase ১-এ অপেক্ষা করে সবার vote না আসা পর্যন্ত — completion time নির্ধারণ করে সবচেয়ে ধীর participant, average নয়। n বাড়লে straggler পাওয়ার সম্ভাবনা বাড়ে — n-টা independent round-trip-এর maximum n বাড়ার সাথে বাড়েই। তাই কার্যত প্রতিটা নতুন participant একটা বাড়তি worst-case round-trip-এর মতো আচরণ করে।
  • Contention আর retry: timeout/retry হলে পুরো window দ্বিগুণ-তিনগুণ; আর lock ধরে-থাকা অন্য transaction-কেও block করে — cascading wait।

n বাড়ার সাথে lock-holding window বাড়ে, আর সেই window জুড়ে ওই row-এ আর কেউ ছুঁতে পারে না। Throughput lock-hold সময়ের উল্টো অনুপাতে ভাঙে — microservice checkout-এ ৫-৭টা participant common, প্রতিটাই lock-window আরেকটু বাড়ায়।

Fix-এর প্রথম চেষ্টা — Three-Phase Commit

Blocking-এর সমাধানে ১৯৮১ সালে Dale Skeen প্রস্তাব করলেন three-phase commit — মাঝে একটা নতুন state ঢুকিয়ে:

1. CanCommit  — coordinator জিজ্ঞেস করে "পারবে?"          → yes/no  (2PC-র prepare-ই)
2. PreCommit  — সবাই yes দিলে coordinator বলে "প্রস্তুত থাকো"  → ack
3. DoCommit   — coordinator বলে "করো!"                     → committed

মূল ধারণা: PreCommit state-এ পৌঁছানো মানেই participant জানে যে সবাই ইতিমধ্যে commit-এর দিকে একমত হয়ে গেছে — তাই coordinator যদি এই পর্যায়ে মরে যায়, participant নিজে থেকে timeout-এ commit করে ফেলতে পারে, অনির্দিষ্টকাল অপেক্ষা না করে। In-doubt state আর ‘অসীম blocking’ নয় — একটা সময়সীমা-বাঁধা fallback। এটা কেন তবু বাস্তবে কাজ করে না, সেটাই পরের অংশের বিষয়।

ভেতরে কী ঘটছে

Coordinator crash — দুই ঘড়ির মধ্যে ফাঁক

Coordinator-এর decision-pipeline আসলে পাঁচটা ধাপ:

Coordinator decision pipeline — commit-point ঠিক কোথায়
  1. vote সংগ্রহসব participant-এর prepare-response জমা হলো
  2. decision গণনাসব YES → COMMIT, নাহলে ABORT
  3. durable log writefsync — এই মুহূর্তটাই আসল commit point
  4. broadcastসব participant-কে decision পাঠানো
  5. ack + garbage-collectসব ack এলে log entry মোছা যায়

যদি coordinator ধাপ ৩-এর আগে মরে — কোনো decision কোথাও লেখা হয়নি, তাই কোনো participant-ই কখনো decision পায়নি। Recovery নিরাপদে ধরে নিতে পারে: ‘কেউ commit শোনেনি’, তাই default ABORT পাঠানো নিরাপদ — কেউ ইতিমধ্যে committed অবস্থায় নেই।

কিন্তু ধাপ ৩-এর পরে (log লেখা শেষ, broadcast এখনো হয়নি বা আংশিক হয়েছে) মরলে ছবিটা উল্টো: হতে পারে এক participant ইতিমধ্যে COMMIT পেয়ে গেছে, বাকিরা পায়নি। এখন default ABORT পাঠালে এক participant commit করে বসে থাকবে, বাকিরা abort করবে — সরাসরি inconsistency। তাই recovery-র একমাত্র নিরাপদ পথ: নিজের durable log পড়ে, ঠিক যে decision লেখা ছিল সেটাই আবার broadcast করা — নতুন কোনো সিদ্ধান্ত না নিয়ে পুরনোটাই replay।

এই জন্যই নিয়মটা inviolable: decision durably log করো, তারপরই কাউকে বলো — কখনো উল্টো নয়। Log-write-ই সেই মুহূর্ত যেখানে decision ‘সত্য’ হয়ে যায়; তার আগে বলা মানে এমন কিছু ঘোষণা করা যা পরে ভুলে যেতে পারে, আর সেই ভুলে-যাওয়াই participant-দের মধ্যে বিরোধ তৈরি করে।

Participant-এর দিক থেকে: in-doubt অবস্থায় timeout হলে তার হাতে দুইটা খারাপ বিকল্প — নিজে থেকে ABORT ধরে নেওয়া, বা নিজে থেকে COMMIT ধরে নেওয়া। দুটোই বিপজ্জনক: guess ভুল হলে ওই এক participant বাকিদের থেকে আলাদা সিদ্ধান্তে গিয়ে বসবে, কারণ অন্য participant হয়তো ইতিমধ্যে প্রকৃত decision পেয়ে গেছে। তাই participant-এর একমাত্র নিরাপদ পথ literally wait — এটাই blocking-এর সংজ্ঞাগত রূপ। (Cooperative termination protocol নামে একটা আংশিক প্রতিকার আছে — in-doubt participant-রা একে অপরকে জিজ্ঞেস করে কেউ decision জানে কিনা — কিন্তু ঠিক এই crash-timing-এই সব participant একসাথে in-doubt থাকে, তাই সেটাও ব্যর্থ হয়; শেষ ভরসা coordinator recovery অথবা মানুষের হস্তক্ষেপ।)

3PC-র মারাত্মক ফাঁক — partition

PreCommit state participant-কে timeout-এ commit করার সাহস দেয় কারণ protocol একটা fail-stop model ধরে নেয় — মেশিন থামে, কিন্তু message হারায় না, network দুই ভাগে ভাগ হয় না। বাস্তব network এই ধরে নেওয়াকে সরাসরি ভাঙে।

কল্পনা করুন পাঁচ participant — তিনজন PreCommit-এ পৌঁছে গেছে (coordinator থেকে PreCommit message পেয়েছে), বাকি দুইজন এখনো CanCommit-এর vote দিয়েছে মাত্র, PreCommit শোনেনি — ঠিক তখনই network partition করল, coordinator-সহ এক গ্রুপ অন্য গ্রুপ থেকে বিচ্ছিন্ন হয়ে গেল।

  • গ্রুপ ১ (PreCommit-পাওয়া তিনজন): timeout হলে নিয়ম অনুযায়ী নিজে থেকে commit করে ফেলে।
  • গ্রুপ ২ (এখনো CanCommit-এই আটকে থাকা দুইজন): timeout হলে — কিন্তু PreCommit শোনেইনি, তাই নিরাপদ default হিসেবে নিজে থেকে abort করে ফেলে।

Partition সারলে দেখা যায়: তিনজন committed, দুইজন aborted — একই transaction-এর দুই ভাগ্য, একসাথে সত্য। এটাই three-phase commit-এর fatal flaw — blocking কাটাতে গিয়ে বিনিময়ে split-brain inconsistency কিনে ফেলল। Fail-stop model-এ protocol প্রমাণযোগ্যভাবে সঠিক; network-partition যোগ হতেই ভুল।

বাস্তবতার রায়: production system তাই তিনটা রাস্তার একটা নেয় — (১) 2PC-র blocking মেনে নেয়, কিন্তু coordinator-কে একা রাখে না — decision log নিজেই Paxos/Raft-এর মতো consensus protocol দিয়ে replicate করে (আগের দুই লেসনের বিষয়) — তাহলে coordinator-প্রক্রিয়া মরলেও log বাঁচে, নতুন coordinator তাৎক্ষণিক replace হয়; SPOF কাটে, কিন্তু participant-এর blocking-window পুরো বিলুপ্ত হয় না — শুধু ছোট হয়ে আসে; (২) single tight cluster-এর ভেতরেই থেকে যায়, cross-service ছড়ায় না; অথবা (৩) atomicity-র দাবিই ছেড়ে দেয় — Saga (পরের লেসন)।

উদাহরণ

Checkout — তিন database, তিন নিয়তি

Setup: checkout transaction তিনটা independent database ছোঁয় — OrderDB, InventoryDB, PaymentDB — এক coordinator (checkout-service) চালায়।

পথ ১ — সবাই রাজি:

t0  coordinator: PREPARE → OrderDB, InventoryDB, PaymentDB
t1  OrderDB     vote YES  (order-row locked, durable)
t2  InventoryDB vote YES  (stock-row locked, durable)
t3  PaymentDB   vote YES  (payment-row locked, durable)
t4  coordinator: decision=COMMIT, নিজের log-এ fsync
t5  coordinator: COMMIT → সবাইকে; সবাই lock ছাড়ে, commit করে
    verdict: সব committed, কোনো ব্যতিক্রম নেই

পথ ২ — PaymentDB না বলল (card declined):

t0  coordinator: PREPARE → সবাইকে
t1  OrderDB     vote YES  (locked, অপেক্ষায়)
t2  InventoryDB vote YES  (locked, অপেক্ষায়)
t3  PaymentDB   vote NO   (card declined — নিজেই abort, lock নেয়নি)
t4  coordinator: decision=ABORT (একটা NO-ই যথেষ্ট), log-এ fsync
t5  coordinator: ABORT → সবাইকে; OrderDB, InventoryDB lock ছেড়ে rollback
    verdict: কেউ commit করেনি — কিন্তু OrderDB, InventoryDB t1-t4
    জুড়ে অকারণে lock ধরে বসেছিল অন্য কারো ভোটের অপেক্ষায়

পথ ৩ — coordinator ঠিক log-এর পরে মরে গেল:

t0-t3  আগের মতো — সবাই YES, decision=COMMIT, log-এ fsync হলো
t4     coordinator process CRASH — broadcast পাঠানোর আগেই
t5     OrderDB, InventoryDB, PaymentDB — তিনজনই IN-DOUBT, lock ধরে,
       জানেই না decision কী; retry করেও কেউ উত্তর দিতে পারবে না,
       কারণ ওরা নিজেরাই জানে না
t6     মিনিট, ঘণ্টা যায় — নতুন coordinator process ওঠে, একই durable
       log খোলে, দেখে decision=COMMIT লেখা আছে
t7     নতুন coordinator: COMMIT → সবাইকে; lock ছাড়ে, commit করে
       verdict: শেষমেশ সঠিক decision-ই কার্যকর হলো — কিন্তু t4-t6
       জানালায় তিনটা database-এর row অন্য কারো নাগালের বাইরে ছিল

তিন পথের একটাই শিক্ষা: participant-এর ভাগ্য সবসময় coordinator-এর log-এর হাতে — সে যত দ্রুত বা ধীর decide করুক, participant শুধু অপেক্ষা করতে পারে।

ছবিতে

              Coordinator                 OrderDB     InventoryDB    PaymentDB
Phase 1:           │──── PREPARE ────────────▶│              │              │
                   │──── PREPARE ─────────────────────────────▶│              │
                   │──── PREPARE ──────────────────────────────────────────────▶│
                   │◀─── YES ──────────────────│              │              │
                   │◀─── YES ───────────────────────────────────│              │
                   │◀─── YES ────────────────────────────────────────────────────│
                   │   (সবাই YES → decision=COMMIT, নিজের log-এ fsync — commit point)
Phase 2:           │──── COMMIT ──────────────▶│              │              │
                   │──── COMMIT ───────────────────────────────▶│              │
                   │──── COMMIT ────────────────────────────────────────────────▶│
                   lock ছাড়া, commit — protocol শেষ
Phase 1 (prepare/vote) আর Phase 2 (decide/broadcast) — দুই round, লেখা-পরে-বলা নিয়মটা মাঝখানে।
  t0          t3              t4                              t6           t7
  │ prepare   │ decision       │ CRASH                          │ recovery    │ unblocked
  │──────────▶│ logged(COMMIT) │◀──────── BLOCKING WINDOW ─────▶│────────────▶│

  OrderDB      IN-DOUBT [lock] ───────────────────────────────────────────▶ COMMITTED
  InventoryDB  IN-DOUBT [lock] ───────────────────────────────────────────▶ COMMITTED
  PaymentDB    IN-DOUBT [lock] ───────────────────────────────────────────▶ COMMITTED

  t3-তে decision "সত্য" হয়ে গেছে (log-এ fsync) — কিন্তু কেউ জানে না,
  t6-এ নতুন coordinator log পড়েই সেই একই সত্য আবার বলে।
Coordinator log-এর পরে, broadcast-এর আগে crash করলে — blocking window, তারপর recovery।
                       coordinator (এই গ্রুপে আছে)
                             │
                 ┌───────────┴───────────┐
           partition ✂                  ✂
                 │                        │
   গ্রুপ A (PreCommit পেয়েছিল)      গ্রুপ B (এখনো CanCommit-এ)
   timeout → নিজে থেকে COMMIT        timeout → নিরাপদ default ABORT
                 │                        │
             committed                 aborted
                 └──── একই transaction, দুই ভাগ্য ────┘
3PC-র fatal flaw — partition দুই গ্রুপকে দুই নিয়তিতে পাঠায়।

নিজে চালিয়ে দেখুন

EXPERIMENT

2PC simulator — happy path, vote-NO abort, আর coordinator-crash blocking + recovery

macOS / Linux / Windows (Python 3)· ১৫ মিনিট
"""two_phase_commit.py — coordinator + N participants, hand-traceable, deterministic"""

class Participant:
    def __init__(self, name, vote):
        self.name = name
        self.vote = vote              # pre-decided for this sim: 'YES' or 'NO'
        self.state = 'INIT'
        self.locked = False

    def prepare(self):
        if self.vote == 'YES':
            self.locked = True
            self.state = 'IN-DOUBT'   # committed to whatever coordinator decides
        else:
            self.state = 'ABORTED'    # NO-voter can abort unilaterally, right now
        return self.vote

    def receive_decision(self, decision):
        self.state = decision
        self.locked = False

    def status(self):
        return f"{self.name}:{self.state}{' [locked]' if self.locked else ''}"


class Coordinator:
    def __init__(self, participants):
        self.participants = participants
        self.log = None               # durable decision log; None = undecided

    def prepare_phase(self):
        print("  PREPARE ->", ', '.join(p.name for p in self.participants))
        votes = {}
        for p in self.participants:
            v = p.prepare()
            votes[p.name] = v
            print(f"    {p.name} votes {v:<3} -> {p.status()}")
        return votes

    def decide(self, votes):
        decision = 'COMMIT' if all(v == 'YES' for v in votes.values()) else 'ABORT'
        print(f"  decision computed: {decision}")
        return decision

    def log_decision(self, decision):
        self.log = decision           # fsync BEFORE telling anyone — the whole trick
        print(f"  decision durably logged: {self.log}")

    def broadcast(self, decision):
        for p in self.participants:
            p.receive_decision('COMMITTED' if decision == 'COMMIT' else 'ABORTED')
            print(f"    {p.name} <- {decision:<6} -> {p.status()}")

    def recover(self):
        print("  RECOVERY: fresh coordinator process reads the durable decision log")
        if self.log is None:
            print("    log empty -> crashed before deciding -> safe default ABORT")
            decision = 'ABORT'
        else:
            print(f"    log has decision={self.log} -> rebroadcast, no new decision needed")
            decision = self.log
        self.broadcast(decision)
        return decision


print("=== Case A: happy path — সব participant YES ===")
psA = [Participant('OrderDB', 'YES'), Participant('InventoryDB', 'YES'), Participant('PaymentDB', 'YES')]
cA = Coordinator(psA)
votesA = cA.prepare_phase()
dA = cA.decide(votesA)
cA.log_decision(dA)
cA.broadcast(dA)

print("\n=== Case B: PaymentDB vote দেয় NO ===")
psB = [Participant('OrderDB', 'YES'), Participant('InventoryDB', 'YES'), Participant('PaymentDB', 'NO')]
cB = Coordinator(psB)
votesB = cB.prepare_phase()
dB = cB.decide(votesB)
cB.log_decision(dB)
cB.broadcast(dB)

print("\n=== Case C: coordinator log-এর পরে, broadcast-এর আগে crash করে ===")
psC = [Participant('OrderDB', 'YES'), Participant('InventoryDB', 'YES'), Participant('PaymentDB', 'YES')]
cC = Coordinator(psC)
votesC = cC.prepare_phase()
dC = cC.decide(votesC)
cC.log_decision(dC)
print("  !! coordinator process dies এখানে — logged, কিন্তু broadcast হয়নি !!")
for p in psC:
    print(f"    still blocked: {p.status()}")
print("  --- সময় গেল; নতুন coordinator process ওঠে, একই durable log খোলে ---")
cC.recover()
assert all(p.state == 'COMMITTED' and not p.locked for p in psC)
print("  PASS — recovery-ই একমাত্র মুক্তি, আর ঠিক ওই decision-ই ফিরল যা log-এ ছিল")

প্রত্যাশিত output (deterministic — হুবহু এটাই আসবে):

=== Case A: happy path — সব participant YES ===
  PREPARE -> OrderDB, InventoryDB, PaymentDB
    OrderDB votes YES -> OrderDB:IN-DOUBT [locked]
    InventoryDB votes YES -> InventoryDB:IN-DOUBT [locked]
    PaymentDB votes YES -> PaymentDB:IN-DOUBT [locked]
  decision computed: COMMIT
  decision durably logged: COMMIT
    OrderDB <- COMMIT -> OrderDB:COMMITTED
    InventoryDB <- COMMIT -> InventoryDB:COMMITTED
    PaymentDB <- COMMIT -> PaymentDB:COMMITTED

=== Case B: PaymentDB vote দেয় NO ===
  PREPARE -> OrderDB, InventoryDB, PaymentDB
    OrderDB votes YES -> OrderDB:IN-DOUBT [locked]
    InventoryDB votes YES -> InventoryDB:IN-DOUBT [locked]
    PaymentDB votes NO  -> PaymentDB:ABORTED
  decision computed: ABORT
  decision durably logged: ABORT
    OrderDB <- ABORT  -> OrderDB:ABORTED
    InventoryDB <- ABORT  -> InventoryDB:ABORTED
    PaymentDB <- ABORT  -> PaymentDB:ABORTED

=== Case C: coordinator log-এর পরে, broadcast-এর আগে crash করে ===
  PREPARE -> OrderDB, InventoryDB, PaymentDB
    OrderDB votes YES -> OrderDB:IN-DOUBT [locked]
    InventoryDB votes YES -> InventoryDB:IN-DOUBT [locked]
    PaymentDB votes YES -> PaymentDB:IN-DOUBT [locked]
  decision computed: COMMIT
  decision durably logged: COMMIT
  !! coordinator process dies এখানে — logged, কিন্তু broadcast হয়নি !!
    still blocked: OrderDB:IN-DOUBT [locked]
    still blocked: InventoryDB:IN-DOUBT [locked]
    still blocked: PaymentDB:IN-DOUBT [locked]
  --- সময় গেল; নতুন coordinator process ওঠে, একই durable log খোলে ---
  RECOVERY: fresh coordinator process reads the durable decision log
    log has decision=COMMIT -> rebroadcast, no new decision needed
    OrderDB <- COMMIT -> OrderDB:COMMITTED
    InventoryDB <- COMMIT -> InventoryDB:COMMITTED
    PaymentDB <- COMMIT -> PaymentDB:COMMITTED
  PASS — recovery-ই একমাত্র মুক্তি, আর ঠিক ওই decision-ই ফিরল যা log-এ ছিল

তিনটা পর্যবেক্ষণ: (১) Case A-তে vote দেওয়ার সাথে সাথেই সবাই [locked] হয়ে গেল, commit না হওয়া পর্যন্ত সেটা যায়নি — lock-window খালি চোখে; (২) Case B-তে PaymentDB নিজে থেকেই ABORTED (কখনো lock নেয়নি), কিন্তু OrderDB/InventoryDB আগে থেকেই lock নিয়ে বসেছিল — তাদের lock ছাড়তে পুরো broadcast round লাগল, blocking-এর দাম এক NO-vote-এও এড়ানো যায় না; (৩) Case C-তে still blocked লাইনগুলোই blocking-এর সংজ্ঞা, আর RECOVERY অংশটাই দেখায় কেন log-write-ই commit-এর আসল মুহূর্ত।

এটা কী প্রমাণ করে

Vote YES দেওয়ার পরে participant lock ধরে IN-DOUBT-এ আটকে থাকে; একটা NO-ই সবাইকে ABORT করে দেয়; আর coordinator log লেখার পরে crash করলেও, সেই একই durable decision recovery-তে ফিরে এসে সবাইকে হুবহু ঠিক জায়গায় নিয়ে যায়।

নিজে বানান

BUILD IT

TxnCoordinator — ফাইলে durable decision log, crash-simulation, cold-restart recovery

Python · ●●●●○
  1. Participant — prepare()-এ lock+vote, apply()-এ decision গ্রহণ করে lock ছাড়ে
  2. Coordinator.run() — prepare, decide, journal-এ decision fsync, তারপর broadcast
  3. crash_after_log flag — journal লেখার ঠিক পরে exception ছুঁড়ে process-death simulate করা
  4. Coordinator.recover() — নতুন instance, একই journal file থেকে decision পড়ে, participant-দের unblock করা
  5. assert দিয়ে verify — recovery-র পরে সবাই ঠিক একই decision-এ পৌঁছেছে কিনা

লক্ষ্য: coordinator-process মরলেও journal-file বেঁচে থাকবে; নতুন process journal পড়ে ঠিক সেই এক-ও-অদ্বিতীয় decision-ই আবার broadcast করবে — কোনো নতুন সিদ্ধান্ত নয়, শুধু replay।

# two_phase_commit_engine.py — durable journal + crash-resume coordinator
import json, os, tempfile

class Participant:
    def __init__(self, name, vote):
        self.name, self.vote = name, vote
        self.state, self.locked = 'INIT', False

    def prepare(self):
        if self.vote == 'YES':
            self.locked, self.state = True, 'IN-DOUBT'
        else:
            self.state = 'ABORTED'
        return self.vote

    def apply(self, decision):
        self.state, self.locked = decision, False

    def status(self):
        return f"{self.name}:{self.state}{' [locked]' if self.locked else ''}"


class TxnCrashed(Exception):
    pass


class Coordinator:
    def __init__(self, txn_id, participants, journal_path):
        self.txn_id, self.participants, self.journal_path = txn_id, participants, journal_path

    def _write_journal(self, entry):
        with open(self.journal_path, 'a') as fh:
            fh.write(json.dumps(entry) + '\n')

    def _read_decision(self):
        decision = None
        try:
            with open(self.journal_path) as fh:
                for line in fh:
                    e = json.loads(line)
                    if e.get('txn') == self.txn_id and e.get('phase') == 'decided':
                        decision = e['decision']
        except FileNotFoundError:
            pass
        return decision

    def run(self, crash_after_log=False):
        print(f"  [{self.txn_id}] PREPARE -> {[p.name for p in self.participants]}")
        votes = {p.name: p.prepare() for p in self.participants}
        for p in self.participants:
            print(f"    {p.status()}")
        decision = 'COMMIT' if all(v == 'YES' for v in votes.values()) else 'ABORT'
        self._write_journal({'txn': self.txn_id, 'phase': 'decided', 'decision': decision})
        print(f"  decision '{decision}' fsync-হয়ে journal-এ গেল")
        if crash_after_log:
            raise TxnCrashed(f'{self.txn_id}: crash after log, before broadcast')
        self._broadcast(decision)
        return decision

    def _broadcast(self, decision):
        outcome = 'COMMITTED' if decision == 'COMMIT' else 'ABORTED'
        for p in self.participants:
            p.apply(outcome)
            print(f"    {p.status()}")

    def recover(self):
        decision = self._read_decision()
        print(f"  RECOVERY [{self.txn_id}]: journal decision = {decision}")
        if decision is None:
            decision = 'ABORT'
            print("    কোনো decision লগ হয়নি -> নিরাপদ default ABORT")
        self._broadcast(decision)
        return decision


tmp = os.path.join(tempfile.mkdtemp(), 'txn_journal.ndjson')
ps = [Participant('OrderDB', 'YES'), Participant('InventoryDB', 'YES'), Participant('PaymentDB', 'YES')]
c1 = Coordinator('txn-77', ps, tmp)

print('=== run 1: coordinator crash ঠিক log-এর পরে ===')
try:
    c1.run(crash_after_log=True)
except TxnCrashed as err:
    print(f'  died: {err}')
    for p in ps:
        print(f'    still blocked: {p.status()}')

print('=== run 2: নতুন process, একই journal file, একই participants ===')
c2 = Coordinator('txn-77', ps, tmp)
verdict = c2.recover()
assert all(p.state == 'COMMITTED' and not p.locked for p in ps)
print(f'  verdict={verdict}; সবাই unblocked, exactly one true decision applied')

প্রত্যাশিত output:

=== run 1: coordinator crash ঠিক log-এর পরে ===
  [txn-77] PREPARE -> ['OrderDB', 'InventoryDB', 'PaymentDB']
    OrderDB:IN-DOUBT [locked]
    InventoryDB:IN-DOUBT [locked]
    PaymentDB:IN-DOUBT [locked]
  decision 'COMMIT' fsync-হয়ে journal-এ গেল
  died: txn-77: crash after log, before broadcast
    still blocked: OrderDB:IN-DOUBT [locked]
    still blocked: InventoryDB:IN-DOUBT [locked]
    still blocked: PaymentDB:IN-DOUBT [locked]
=== run 2: নতুন process, একই journal file, একই participants ===
  RECOVERY [txn-77]: journal decision = COMMIT
    OrderDB:COMMITTED
    InventoryDB:COMMITTED
    PaymentDB:COMMITTED
  verdict=COMMIT; সবাই unblocked, exactly one true decision applied

Journal-ফাইলের নমুনা content — পুরো transaction-এর একটাই লাইন, কারণ crash log-write-এর ঠিক পরেই ঘটল:

{"txn": "txn-77", "phase": "decided", "decision": "COMMIT"}

তিনটা পাঠ: প্রথমত, run()-এর ভেতরে journal-write সবসময় _broadcast()-এর আগে ঘটে — কোড-অর্ডারই invariant প্রয়োগ করছে, আলাদা ভেরিয়েবল লাগে না। দ্বিতীয়ত, recover() নতুন কোনো decision তৈরি করে না — শুধু journal-এ যা লেখা ছিল তা পড়ে replay করে; এই ‘নতুন সিদ্ধান্ত নয়’ নিয়মটাই সব participant-কে একমত রাখে। তৃতীয়ত, ps list-টাই run1 আর run2-এ common — বাস্তবে এটা participant-database-এর ভূমিকা: coordinator-process মরে-বাঁচে, কিন্তু participant-রা নিজেদের disk-এ lock+state ধরে বসে থাকে, শুধু কে তাদের জাগাবে সেই প্রশ্ন।

নিজে বাড়ান:

  1. একটা participant NO vote দিলে পুরো run() flow ঠিক কী করে verify করুন — crash simulate না করেই।
  2. Crash-timing বদলে দেখুন: journal-write-এর আগে crash simulate করুন (নতুন flag crash_before_log) — recover() কি সত্যিই safe-default ABORT-এ যায়?
  3. একই journal file-এ একাধিক txn_id চালিয়ে দেখুন recovery প্রতিটাকে আলাদাভাবে ঠিকঠাক পুনর্গঠন করে কিনা।

বাস্তব সিস্টেমে

2PC বাস্তবে

XA / JTA — classic enterprise standard। Oracle, PostgreSQL (PREPARE TRANSACTION), MySQL — সবগুলোই XA protocol সমর্থন করে; application server (JTA) কোওর্ডিনেটর হয়ে একাধিক resource manager-এর মধ্যে commit align করে। ব্যাংকিং middleware-এ এখনো common — কিন্তু সাবধানে: হারানো coordinator মানে stuck in-doubt transaction, DBA-কে হাতে দেখতে হয় (XA RECOVER)।

Google Spanner। Cross-shard transaction 2PC দিয়েই চলে — কিন্তু coordinator একা নয়: প্রতিটা shard নিজেই একটা Paxos group, decision log Paxos-এ replicate হয়। তাই কোনো একটা মেশিন মরলেও group বেঁচে থাকে — 2PC-র blocking window থেকে যায় (participant তখনো lock ধরে অপেক্ষা করে), কিন্তু coordinator SPOF কাটে।

CockroachDB, YugabyteDB — distributed SQL। একই কৌশল: transaction record নিজেই Raft-replicated, তাই “coordinator crash” মানে এক নোড হারানো, পুরো decision হারানো নয়।

Percolator (Google BigTable-এর উপর)। Lock-column আর write-column দিয়ে 2PC-র শিল্প-রূপ — snapshot-isolation-সহ বড় batch transaction চালায়।

Microservice-এ কেন কম দেখা যায়। XA-driver, cross-service lock, সবাইকে synchronous রাখা — 2PC-র দাম microservice-এর independent-deploy দর্শনের বিপরীত। এখানেই পরের লেসনের প্রশ্ন শুরু।

যে ভুলগুলো সবাই করে

“2PC মানেই distributed system-এর জন্য magic atomic commit — সবখানে ব্যবহার করা উচিত।”

Blocking, coordinator SPOF, আর lock-stretch structural — কোনো optimization এগুলো মোছে না। Tight cluster-এর ভেতরে (এক datacenter, একই owner) দাম সহনীয়; independent microservice-জুড়ে cross-service lock ধরে রাখা ব্যবসায়িকভাবে অসহনীয় হয়ে ওঠে — সেখানে সমাধান 2PC optimize করা নয়, পুরো model বদলানো (Saga)।

“Participant vote 'NO' দিলে শুধু ওই একটা participant-ই ক্ষতিগ্রস্ত হয়।”

NO সাথে সাথে decision ABORT-এ পরিণত হয়ে পুরো transaction ভাঙে — কিন্তু যে participant-রা আগেই YES দিয়ে lock নিয়ে বসেছিল, তারা broadcast আসা পর্যন্ত lock ধরে থাকে, শুধু পরে জানতে পারে সব বৃথা গেছে। এক participant-এর NO সবার lock-window-এই ছোঁয়।

“Coordinator crash মানেই transaction চিরতরে হারিয়ে গেল।”

Data হারায় না — participant-রা নিজেদের durable state আর lock ধরে বসে থাকে, শুধু liveness আটকায়। Decision log সত্যিটা রক্ষা করে; যত দেরিতেই হোক (নতুন coordinator process, বা মানুষের হস্তক্ষেপ) recovery ঠিক সেই এক-ও-অদ্বিতীয় decision-ই ফিরিয়ে আনে — এটা safety রক্ষা করে, শুধু availability-তে দাম দেয়।

“3PC সবসময় 2PC-র চেয়ে ভালো, তাই এটাই ব্যবহার করা উচিত।”

3PC শুধু fail-stop, no-partition model-এ non-blocking — বাস্তব network-এ partition ঘটলে split-brain inconsistency তৈরি করে, যেটা 2PC কখনো করে না (2PC শুধু availability হারায়, correctness নয়)। তাই production system 3PC এড়িয়ে হয় 2PC-র blocking মেনে নেয় consensus-backed coordinator দিয়ে, নয়তো Saga-র দিকে যায়।

বুঝেছেন কি না দেখুন

1

Two-phase commit-এর দুই ধাপ কী কী, আর participant YES ভোট দেওয়ার আগে কী কী করে ফেলতে হয়?

স্মরণ

Phase 1 (prepare): coordinator PREPARE পাঠায়, participant vote YES/NO ফেরত দেয়; YES দেওয়ার আগে নিজের change durable log-এ লিখে ফেলে আর প্রয়োজনীয় row-lock নিয়ে ফেলে। Phase 2 (commit/abort): সব YES হলে coordinator নিজের log-এ decision COMMIT durably লিখে broadcast করে; একটাও NO/timeout হলে ABORT broadcast করে।

2

YES ভোট দেওয়ার পরে participant কেন নিজে থেকে abort করতে পারে না — blocking ঠিক কোন মুহূর্তে জন্মায়?

যুক্তি

YES মানেই প্রতিশ্রুতি: coordinator যা বলবে তা-ই করব। এই প্রতিশ্রুতি রক্ষা করতে হলে change আগে থেকেই durable+locked রাখতে হয়েছে — তাই এখন নিজে থেকে অন্য কিছু ধরে নিলে অন্য participant যদি ইতিমধ্যে বিপরীত decision পেয়ে গেছে, তার সাথে সরাসরি বিরোধ হয়। তাই participant শুধু coordinator-এর decision-এর জন্য lock ধরে অপেক্ষা করতে বাধ্য — এটাই blocking, আর এটা জন্মায় YES বলার ঠিক মুহূর্তেই, decision আসার আগে নয়।

3

Coordinator সব YES ভোট পেয়ে decision=COMMIT নিজের log-এ fsync করল, কিন্তু ঠিক broadcast করার আগেই crash করল। Participant-দের কী অবস্থা, আর recovery কীভাবে তাদের মুক্ত করে?

প্রয়োগ

সব participant IN-DOUBT অবস্থায় lock ধরে আটকে থাকবে — নিজে থেকে কিছু অনুমান করতে পারবে না, কারণ ভুল অনুমান অন্য কোনো participant-এর সাথে বিরোধ তৈরি করতে পারে। নতুন coordinator process উঠলে সে নিজের durable log পড়ে দেখে decision=COMMIT লেখা আছে — নতুন কোনো সিদ্ধান্ত না নিয়ে ঠিক সেই decision-ই আবার broadcast করে; সব participant COMMIT পেয়ে lock ছাড়ে।

4

n participant, round-trip r — lock-holding সময়ের ন্যূনতম আর বাস্তব হিসাব কীভাবে আলাদা, আর n বাড়লে কেন throughput কমে?

যুক্তি

ন্যূনতম হিসাব: prepare-round এক r, decide-round আরেক r — মোট 2r, ধরে নিয়ে সবাই সমান দ্রুত। বাস্তবে coordinator সবার vote না আসা পর্যন্ত অপেক্ষা করে — completion time নির্ধারণ করে সবচেয়ে ধীর participant; n বাড়লে straggler থাকার সম্ভাবনা বাড়ে, তাই worst-case wait বাড়ে। যোগ হয় retry/contention। Lock-window বাড়লে ওই row অন্য transaction-এর জন্য তত বেশি সময় বন্ধ থাকে — throughput সরাসরি কমে।

5

পাঁচ participant-এর মধ্যে তিনজন PreCommit-এ পৌঁছে গেছে, দুইজন এখনো CanCommit-এ — ঠিক তখন network partition হলো coordinator-সহ। প্রতিটা গ্রুপ timeout-এ কী করবে, আর কেন এটাকে ‘3PC-র fatal flaw’ বলা হয়?

যুক্তি

PreCommit-পাওয়া তিনজন timeout-এ নিজে থেকে commit করে ফেলবে — নিয়ম তাদের সেই অধিকার দেয়। বাকি দুইজন PreCommit কখনো শোনেইনি, তাই নিরাপদ default হিসেবে abort করবে। ফলাফল: একই transaction-এর একাংশ committed, একাংশ aborted — সত্যিকারের split-brain inconsistency, শুধু blocking নয়। এই কারণেই 3PC শুধু fail-stop, no-partition model-এ প্রমাণযোগ্য সঠিক — বাস্তব নেটওয়ার্কে নয়।

6

3PC partition-এ ভেঙে পড়ে জেনেও production system কীভাবে 2PC-র coordinator-SPOF সমস্যা মোকাবিলা করে — আর কখন সেই সমাধানও যথেষ্ট না?

ডিজাইন

Coordinator-এর decision log-কে single machine না রেখে Paxos/Raft-এর মতো consensus group-এ replicate করা হয় — এক মেশিন মরলে group বেঁচে থাকে, নতুন coordinator log পড়ে চালিয়ে যায় (Spanner, CockroachDB এই পথে)। কিন্তু এতেও participant-এর blocking-window পুরোপুরি যায় না, আর participant-রা যদি independent microservice/datacenter হয় — cross-service lock ধরে রাখা ব্যবসায়িকভাবে অসহনীয় — তখন সমাধান protocol optimize করা না, পুরো atomicity-model ছেড়ে Saga-য় যাওয়া (পরের লেসন)।

এরপর কী

এরপর কী

আজকের protocol নিজের সীমা নিজেই দেখিয়ে দিল — blocking, coordinator SPOF, lock-stretch — তিনটাই কাঠামোগত, প্যাচ দিয়ে সারানো যায় না (consensus দিয়ে coordinator-কে শক্ত করা যায়, কিন্তু participant-এর lock-window পুরো মোছা যায় না)। যতক্ষণ participant-রা এক tight cluster-এর ভেতরে, ব্যাপারটা সহনীয়। কিন্তু বাস্তব microservice checkout-এ participant মানে আলাদা owner, আলাদা database, হয়তো আলাদা datacenter — prepare থেকে decide পর্যন্ত ওদের row-lock ধরে রাখা ব্যবসায়িকভাবে অসম্ভব হয়ে ওঠে।

পরের লেসনে প্রশ্নটা উল্টে যাবে: transaction-কে ছড়াতে না পেরে, ভাঙলে কী হয়? উত্তর ১৯৮৭-র Saga — লম্বা transaction-কে local transaction-সিরিজে ভাঙা, প্রতিটা নিজেই commit করা, ব্যর্থ হলে আগের ধাপগুলোর business-level compensation চালানো। Isolation-এর দামে হলেও atomicity-র একটা ব্যবহারযোগ্য বিকল্প — ঠিক সেখান থেকেই শুরু হবে পরের পাঠ।

আরও পড়ুন

  • Notes on Data Base Operating Systems — Jim Gray — in Operating Systems: An Advanced Course (Lecture Notes in Computer Science 60), 1978 · Two-phase commit-এর জন্মপত্র — distributed database-এ atomic commitment-এর প্রথম আনুষ্ঠানিক বর্ণনা
  • Nonblocking Commit Protocols — Dale Skeen — ACM SIGMOD, 1981 · Three-phase commit-এর মূল paper — 2PC-র blocking কাটার প্রথম গুরুত্বপূর্ণ চেষ্টা
  • Consensus on Transaction Commit — Jim Gray, Leslie Lamport — ACM Transactions on Database Systems, 2006 · 2PC-র coordinator-কে Paxos-এর মতো consensus দিয়ে replicate করার আনুষ্ঠানিক ভিত্তি
  • Concurrency Control and Recovery in Database Systems — Chapter 7 — Philip A. Bernstein, Vassos Hadzilacos, Nathan Goodman · 2PC আর 3PC-র blocking/non-blocking property-র formal proof-treatment