Two-Phase Commit — সবাই একসাথে, নয়তো কেউই না
Two-Phase Commit
2PC সব participant-কে prepare-vote-এ একমত করিয়ে decide করে — কিন্তু vote yes-এর পরে participant lock ধরে in-doubt বসে থাকে, coordinator crash করলে আটকে যায়; 3PC-র PreCommit ফিক্স নিজেই partition-এ ভাঙে।
আগে এটা বুঝি
কল্পনা করুন 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-এর maximumnবাড়ার সাথে বাড়েই। তাই কার্যত প্রতিটা নতুন 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 আসলে পাঁচটা ধাপ:
- vote সংগ্রহসব participant-এর prepare-response জমা হলো
- decision গণনাসব YES → COMMIT, নাহলে ABORT
- durable log writefsync — এই মুহূর্তটাই আসল commit point
- broadcastসব participant-কে decision পাঠানো
- 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 শেষ 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 (এই গ্রুপে আছে)
│
┌───────────┴───────────┐
partition ✂ ✂
│ │
গ্রুপ A (PreCommit পেয়েছিল) গ্রুপ B (এখনো CanCommit-এ)
timeout → নিজে থেকে COMMIT timeout → নিরাপদ default ABORT
│ │
committed aborted
└──── একই transaction, দুই ভাগ্য ────┘নিজে চালিয়ে দেখুন
2PC simulator — happy path, vote-NO abort, আর coordinator-crash blocking + recovery
"""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-তে ফিরে এসে সবাইকে হুবহু ঠিক জায়গায় নিয়ে যায়।
নিজে বানান
TxnCoordinator — ফাইলে durable decision log, crash-simulation, cold-restart recovery
- Participant — prepare()-এ lock+vote, apply()-এ decision গ্রহণ করে lock ছাড়ে
- Coordinator.run() — prepare, decide, journal-এ decision fsync, তারপর broadcast
- crash_after_log flag — journal লেখার ঠিক পরে exception ছুঁড়ে process-death simulate করা
- Coordinator.recover() — নতুন instance, একই journal file থেকে decision পড়ে, participant-দের unblock করা
- 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 appliedJournal-ফাইলের নমুনা 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 ধরে বসে থাকে, শুধু কে তাদের জাগাবে সেই প্রশ্ন।
নিজে বাড়ান:
- একটা participant
NOvote দিলে পুরোrun()flow ঠিক কী করে verify করুন — crash simulate না করেই। - Crash-timing বদলে দেখুন: journal-write-এর আগে crash simulate
করুন (নতুন flag
crash_before_log) —recover()কি সত্যিই safe-defaultABORT-এ যায়? - একই 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-র দিকে যায়।
বুঝেছেন কি না দেখুন
1Two-phase commit-এর দুই ধাপ কী কী, আর participant YES ভোট দেওয়ার
আগে কী কী করে ফেলতে হয়?
স্মরণ
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 করে।
2YES ভোট দেওয়ার পরে participant কেন নিজে থেকে abort করতে পারে না —
blocking ঠিক কোন মুহূর্তে জন্মায়?
যুক্তি
YES ভোট দেওয়ার পরে participant কেন নিজে থেকে abort করতে পারে না —
blocking ঠিক কোন মুহূর্তে জন্মায়?YES মানেই প্রতিশ্রুতি: coordinator যা বলবে তা-ই করব। এই প্রতিশ্রুতি
রক্ষা করতে হলে change আগে থেকেই durable+locked রাখতে হয়েছে — তাই
এখন নিজে থেকে অন্য কিছু ধরে নিলে অন্য participant যদি ইতিমধ্যে
বিপরীত decision পেয়ে গেছে, তার সাথে সরাসরি বিরোধ হয়। তাই participant
শুধু coordinator-এর decision-এর জন্য lock ধরে অপেক্ষা করতে বাধ্য —
এটাই blocking, আর এটা জন্মায় YES বলার ঠিক মুহূর্তেই, decision
আসার আগে নয়।
3Coordinator সব YES ভোট পেয়ে decision=COMMIT নিজের log-এ fsync
করল, কিন্তু ঠিক broadcast করার আগেই crash করল। Participant-দের কী
অবস্থা, আর recovery কীভাবে তাদের মুক্ত করে?
প্রয়োগ
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 ছাড়ে।
4n participant, round-trip r — lock-holding সময়ের ন্যূনতম আর
বাস্তব হিসাব কীভাবে আলাদা, আর n বাড়লে কেন throughput কমে?
যুক্তি
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-এ পৌঁছে গেছে, দুইজন
এখনো 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-এ প্রমাণযোগ্য সঠিক — বাস্তব নেটওয়ার্কে নয়।
63PC 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