Raft — বোঝার জন্য ডিজাইন করা consensus
Raft: Understandable Consensus
Raft consensus-কে তিন sub-problem-এ ভাগ করে — leader election (আগেই কভার হয়েছে), log replication (AppendEntries-এর consistency check-ই Log Matching Property-র উৎস), আর safety (election restriction + current-term-only commit rule, যা Figure 8-এর ফাঁদ এড়ায়)।
আগে এটা বুঝি
গত লেসনে Paxos-এর ভেতরে ঢুকেছিলাম — proposer/acceptor/learner-দের নাচ, prepare-promise-accept-এর দুই-দফা protocol, majority-intersection-এর প্রমাণ। উপসংহারটা মিষ্টি ছিল না: single-decree Paxos প্রমাণিতভাবে সঠিক, কিন্তু বাস্তব একটা replicated log বানাতে দরকার multi-decree Paxos — আর সেখানেই কুখ্যাতি শুরু। প্রতিটা log-slot-এর জন্য আলাদা Paxos instance, তার ওপর leader-election optimization, log compaction, আরও হাজারটা practical প্যাঁচ — কাগজে যা এক প্যারাগ্রাফ, ইঞ্জিনিয়ারিং-এ তা মাসের পর মাস বাগ-হান্ট। Google-এর নিজেদের Chubby টিম লিখেছিল: “the fully general Paxos-based algorithm… is not itself sufficient” — গ্যাপ পূরণ করতে তাদের বাড়তি ইঞ্জিনিয়ারিং লাগত।
Raft (Ongaro ও Ousterhout, 2014) ঠিক এই ফাঁকেই জন্মায়। লক্ষ্য explicit ছিল paper-এর প্রথম বাক্যেই: Paxos-এর সমান safety আর fault-tolerance power, কিন্তু design-এর প্রধান লক্ষ্য পারফরম্যান্স নয়, বোঝার সহজতা। কৌশল একটাই — decomposition। পুরো consensus সমস্যাকে তিনটা প্রায়-স্বাধীন sub-problem-এ ভাগ করা:
1. Leader Election : কে দায়িত্ব নেবে? (আগের লেসনেই কভার হয়েছে!)
2. Log Replication : leader কীভাবে বাকিদের log সমান বানায়?
3. Safety : কোন invariant কখনো ভাঙা যাবে না?প্রথমটা — randomized timeout, term number, majority vote, split-vote probability — আমরা আগের লেসনে etcd-র প্রসঙ্গে বিস্তারিতভাবে দেখেছি। আজ সেই vocabulary-টা এক প্যারায় মনে করিয়ে দিয়েই এগিয়ে যাব, কারণ আসল নতুন গল্প বাকি দুইটাতে: leader পাওয়ার পর log-টা replicate হয় ঠিক কোন mechanism-এ, আর কোন invariant না মানলে পুরো protocol নিঃশব্দে data হারাতে পারে।
মূল ধারণা
Leader election — এক প্যারায় recap
leader-election লেসনে যা দেখেছি তারই Raft-vocabulary: প্রতিটা server-এর একটা monotonically বাড়া term number (ওই লেসনের ভাষায় epoch-এরই Raft-নাম); election timeout শেষে follower নিজে candidate হয়ে সবাইকে RequestVote RPC পাঠায়; ভোটার নিজের term-এর চেয়ে বড় term দেখলে নিজেকে follower বানায় আর term-প্রতি সর্বোচ্চ একবার ভোট দেয়; candidate strict majority ভোট পেলেই leader ঘোষিত। Timeout randomized window থেকে আসে split-vote probability কমাতে — এই গণিত, churn pathology, দুইলিডার থেকে fencing পর্যন্ত পুরো গল্প leader-election লেসনেই বিস্তারিত আছে; আজ সেটা আর re-derive করছি না।
একটা জিনিস অবশ্য নতুন যোগ হবে আজ: election restriction — candidate-এর log কতটা up-to-date তার ওপর ভোট নির্ভর করবে কি না। এইটুকু নতুন নিয়ম election-এর মেকানিক্স না, log-replication-এর সাথে জড়ানো একটা safety invariant — তাই সেটা নিচে safety অংশে আসবে, এখানে না।
Log Replication — AppendEntries-এর ভেতরে
Leader নির্বাচিত হওয়ার সাথে সাথেই তার একমাত্র কাজ: client-এর প্রতিটা command নিজের log-এ append করা, তারপর প্রতিটা follower-কে ঠিক একই ক্রমে সেই log পাঠানো। যন্ত্রটার নাম AppendEntries RPC — heartbeat আর replication দুটোরই বাহক (entries খালি থাকলেই সেটা নিছক heartbeat)।
AppendEntries(term, leaderId,
prevLogIndex, prevLogTerm, <- consistency check-এর চাবি
entries[], <- নতুন যা পাঠানো হচ্ছে (খালি = heartbeat)
leaderCommit) <- leader-এর commitIndex, follower-কে জানানোLeader প্রতিটা follower-এর জন্য একটা nextIndex মনে রাখে — পরের যে
entry পাঠানো হবে তার index। প্রথম attempt-এ prevLogIndex/prevLogTerm
বসে leader-এর log-এ ওই entry-র ঠিক আগের entry-র (index, term)।
Consistency check — ঠিক এইখানেই Log Matching Property জন্মায়
Follower AppendEntries পেলে একটাই প্রশ্ন করে: আমার log-এ
prevLogIndex position-এ কি ঠিক prevLogTerm term-এর entry আছে?
না থাকলে — position খালি হোক বা ভিন্ন term-এর entry থাকুক — follower
সরাসরি reject করে, কিছুই লেখা হয় না। এই একটা শর্তই
Log Matching Property-কে
সম্ভব করে তোলে:
Induction দিয়ে প্রমাণ:
- Base case: log-এর প্রথম entry-তে (index 1) leader সবসময়
prevLogIndex=0দিয়ে পাঠায় — কোনো prev-check-ই লাগে না। আর একটা leader তার নিজের term-এ index 1-এ একটাই command পাঠাতে পারে (single-writer, append-only log)। তাই দুই log-এ index 1-এ একই term থাকলে সেটা আসলে একই entry। - Inductive step: ধরুন index
i-তে match প্রমাণিত (দুই logiপর্যন্ত অভিন্ন)। এখন indexi+1-এ দুই log-এই যদি একই term থাকে, তার মানে সেই entry পাঠানো হয়েছিল এমন এক AppendEntries-এ যারprevLogIndex=i,prevLogTerm= leader-এর log-এ indexi-এর term। আর follower ওই RPC accept করেছিল only if তার নিজের log-এও indexi-তে ঠিক ওই একই term ছিল। অর্থাৎ indexi-তে match থাকা ছিল indexi+1-এ match হওয়ার পূর্বশর্ত — recursively পুরো prefix টেনে আনে।
QED — একটামাত্র accept/reject rule-ই সম্পূর্ণ প্রমাণের ভিত্তি। কোথাও আলাদা করে prefix compare করতে হয় না; consistency check নিজেই প্রতিবার এই invariant বজায় রাখে।
Conflict resolution — decrement, retry, overwrite
Follower reject করলে leader তার nextIndex এক ঘর কমিয়ে আবার
পাঠায় — prevLogIndex আরেকটু পেছনে সরে। এই decrement-and-retry
চলতেই থাকে যতক্ষণ না prevLogIndex/prevLogTerm মিলে যায় (worst
case index 0 পর্যন্ত, যেখানে prev-check-ই নেই — সবসময় সফল)। match
পাওয়ার পর leader সেই position-এর পর থেকে follower-এর যা-ই থাকুক
তা কেটে ফেলে (truncate) আর তারপর নিজের entries append করে —
conflicting suffix সম্পূর্ণ overwrite।
এই overwrite নিরাপদ কেন? উত্তরটা commitment-এর সংজ্ঞায় লুকানো (নিচের safety অংশে exact rule): কোনো entry committed বলা যায় শুধু তখনই যখন সেটা majority-তে replicate হয়ে গেছে। Follower-এর log-এ যে conflicting entry পড়ে ছিল, সেটা মানেই কোনো এক আগের leader-এর অসম্পূর্ণ কাজ — majority পায়নি (নইলে পরের section-এর election-restriction অনুযায়ী নতুন leader সেটাকে ওভাররাইট করতেই পারত না)। তাই সেটা হারানো মানে কোনো committed decision হারানো নয় — এই guarantee-র ওপর ভরসা করেই Raft কোনো extra যাচাই ছাড়া সরাসরি suffix ফেলে দেয়।
- term checkRPC-এর term < currentTerm হলে সরাসরি reject
- consistency checkprevLogIndex/prevLogTerm-এ match আছে?
- conflict resolvemismatch পেলে সেই position থেকে suffix truncate
- appendনতুন entries যোগ (idempotent — আগে থেকে থাকলে ছোঁয় না)
- commitIndex updatemin(leaderCommit, নতুন last entry-র index)
ভেতরে কী ঘটছে
Safety Pillar ১ — Election Restriction / Leader Completeness
Log replication-কে সত্যিই নিরাপদ রাখতে leader-election-এ একটা শর্ত জোড়া লাগে: ভোটার শুধু তখনই ভোট দেবে যদি candidate-এর log অন্তত ততটাই up-to-date হয় যতটা তার নিজের।
up-to-date তুলনা (candidate C বনাম voter V):
১. C-এর last entry-র term বড় হলে C জেতে
২. term সমান হলে যার log লম্বা (বড় last-index) সে জেতে
৩. দুটোই সমান হলে tie -> vote দেওয়া যায়এই একটা শর্তের ফল বিশাল — নাম Leader Completeness: একবার কোনো entry committed হলে, ভবিষ্যতের প্রতিটা leader-এর log-এই সেটা থাকবে। যুক্তি: committed মানেই majority-তে আছে; নতুন candidate জিততে হলে সেই একই majority-র সাথে overlap করা আরেকটা majority-র ভোট লাগবে (pigeonhole — যেকোনো দুই majority সবসময় ছেদ করে)। ছেদের কমপক্ষে একজন ভোটারের log-এ ওই committed entry আছে; up-to-date-rule অনুযায়ী candidate-এর log সেই ভোটারের চেয়ে পিছিয়ে থাকতে পারবে না — তাই committed entry-টা candidate-এর log-এও থাকতেই হবে, নইলে সে ভোটই পেত না।
Paxos-এর সাথে ঠিক এইখানেই সবচেয়ে সুন্দর তুলনা করা যায়। Paxos-এ নতুন proposer prepare-phase-এ প্রতিটা acceptor-কে জিজ্ঞেস করে “তুমি ইতিমধ্যে কী accept করেছ?”, তারপর সর্বোচ্চ-numbered accepted value নিজে re-propose করে — এক ধরনের discover-then-repropose প্রতি round-এ। Raft-এ এই আবিষ্কার-ধাপটাই নেই: election-restriction গ্যারান্টি করে দেয় যে-ই জেতে তার log-এ আগের সব committed entry এমনিতেই আছে — কারণ ভোট পাওয়ার শর্তটাই সেটা নিশ্চিত করে দিয়েছে আগে থেকে। এক ধাপ কম RPC, একই নিরাপত্তা।
| Paxos (multi-decree) | Raft | |
|---|---|---|
| নতুন leader safety | prepare-phase-এ accepted value discover করে re-propose | election-restriction guarantee করে — committed entry এমনিতেই log-এ থাকে |
| discover step লাগে? | হ্যাঁ, প্রতি round-এ | না — vote দেওয়ার শর্তেই বিল্ট-ইন |
| decomposition | election, replication, slot-management প্রায়ই একসাথে জড়ানো describe হয় | তিনটাই explicit, আলাদা sub-problem হিসেবে ডিজাইন করা |
Safety Pillar ২ — Commit rule আর কুখ্যাত Figure 8 ফাঁদ
কখন leader কোনো entry-কে committed ঘোষণা করবে? সরল উত্তর মনে হয়: “majority-তে replicate হয়ে গেলে”। এইটুকুই যথেষ্ট না — আর এই ভুলটাই paper-এর বিখ্যাত Figure 8 trap-এর বিষয়। সঠিক নিয়ম:
কেন? কারণ election-restriction শুধু committed entry-কে রক্ষা করে — majority-তে-থাকা-কিন্তু-এখনও-uncommitted entry-কে না। পুরনো term-এর একটা entry majority-তে replicate হয়ে যাওয়া মানেই এই না যে ভবিষ্যতের কোনো candidate সেটাকে ওভাররাইট করতে পারবে না; সেই candidate-এর log-ই যদি পরে বেশি up-to-date দেখায় (বেশি বড় term-এর entry-সহ), তাহলে সে জিতেও যেতে পারে আর পুরনো entry মুছে দিতে পারে। Paper-এর আসল scenario (পাঁচ সার্ভার S1–S5) দিয়ে concrete করি:
(a) S1 leader, term=2 : index2=(term2) লেখে, শুধু S2-তে replicate করে, তারপর crash
S1:[t1,t2] S2:[t1,t2] S3:[t1] S4:[t1] S5:[t1]
(b) S5 leader, term=3 (ভোট S3,S4,S5 — সবার last-term=1, tie legal)
নিজের log-এ index2=(term3) লেখে (শুধু নিজের কাছেই), তারপর crash
S5:[t1,t3]
(c) S1 আবার leader, term=4 (ভোট S1,S2,S3 — S1-এর log সবচেয়ে up-to-date)
পুরনো index2=(term2) entry এবার S3-তেও replicate করে — এখন majority
(S1,S2,S3) index2 ধরে আছে! কিন্তু index2.term(2) != currentTerm(4)
-> এখনো commit ঘোষণা করা যাবে না
(d) এই মুহূর্তে S1 crash করলে: S5 আবার candidate হয়; S5-এর last entry
term=3, যা S2/S3/S4-এর last entry term=2-এর চেয়ে বড় — up-to-date
rule অনুযায়ী S2,S3,S4 বাধ্য S5-কেই ভোট দিতে! S5 leader (term=5)
হয়ে নিজের (term3) entry দিয়ে S2,S3-এর index2 OVERWRITE করে দেয় —
(c)-তে majority-held entry-টা মুছে গেল!
(e) বিকল্প — নিরাপদ পথ: যদি S1 (term=4) crash করার আগে নিজের
current-term-এর একটা নতুন entry (index3, term4) S2,S3-তে
replicate করে ফেলত — commit rule অনুযায়ী index3 তখনই committed
(majority + current-term), আর Log Matching Property-র
prefix-guarantee অনুযায়ী index1,index2-ও তখন retroactively
নিরাপদ। আর S5 আর জিততেই পারবে না — কারণ S2/S3-এর log এখন
(last-term=4) S5-এর (last-term=3) চেয়ে বেশি up-to-dateমূল কথা: leader-এর নিজের term-এর একটা entry-ই commit-প্রমাণ হিসেবে কাজ করে — শুধু তখনই পুরো prefix নিরাপদ। তার আগ পর্যন্ত পুরনো-term entry “majority-তে আছে” স্রেফ একটা সম্ভাবনা, guarantee না। Build section-এ আমরা ঠিক এই scenario-টা code দিয়ে reproduce করব — প্রতিটা ধাপ hand-traced, সংখ্যায়।
উদাহরণ
Full scenario — N=3, একটা network hiccup সহ
Safety pillar দুটো বিমূর্ত মনে হতে পারে; তার আগে consistency-check মেকানিক্সটা ছোট এক scenario-তে দেখে নিই — কোনো danger নেই, শুধু network drop আর তার থেকে recovery:
cluster: S1(leader,term=1), S2, S3 — সবার log শুরুতে [t1:init]
t1 client -> S1: SET x=1
t2 S1.log = [t1:init, t1:x=1]; AppendEntries(prev=1/t1, entries=[t1:x=1]) -> S2,S3
t3 S2 accept (prev match) -> log=[t1:init,t1:x=1], ack
t4 S3-এর কাছে message হারায় (network drop!) -> কোনো ack নেই
t5 matchIndex: S2=2, S3=1(পুরনো) -> majority(S1,S2)>=2, আর t1==currentTerm(1)
-> commitIndex=2 (COMMITTED — দুইজনই যথেষ্ট, তিনজনের দরকার নেই)
t6 client -> S1: SET x=2
t7 S1.log=[..,t1:x=2]; AppendEntries(prev=2/t1, entries=[t1:x=2]) -> S2 ok
-> S3: prev=2/t1, কিন্তু S3-এর log length মাত্র 1 -> REJECT (mismatch!)
t8 leader S3-এর nextIndex 3->2 কমায়, retry: prev=1/t1 -> S3-এর log[1]=t1:init
match! -> accept, entries=[t1:x=1, t1:x=2] append
t9 S3.log এখন = [t1:init, t1:x=1, t1:x=2] — S1,S2-র সাথে অভিন্ন
t10 matchIndex সব 3 -> commitIndex=3লক্ষ্য করুন t4–t9: একটামাত্র network drop-ই S3-কে পিছিয়ে দিল, কিন্তু consistency-check-driven decrement (t8) নিজে থেকে ঠিক জায়গা খুঁজে নিল — কোনো manual intervention লাগেনি। আর t5-এ commit S3-এর অপেক্ষা না করেই হয়ে গেছে, কারণ N=3-এ majority মানে ২ যথেষ্ট।
ছবিতে
┌──────────────────────────────────────────────────────┐
│ term : leader-এর currentTerm │
│ leaderId : follower client-redirect-এ কাজে লাগায় │
│ prevLogIndex ┐ │
│ prevLogTerm ┴──► consistency check + Log Matching │
│ entries[] ──► খালি হলেই এটা নিছক heartbeat │
│ leaderCommit ──► follower নিজের commitIndex আপডেট করে │
└──────────────────────────────────────────────────────┘
follower-এর সিদ্ধান্ত:
log[prevLogIndex].term == prevLogTerm ?
হ্যাঁ -> accept, entries[] append (conflict থাকলে suffix truncate আগে)
না -> REJECT, leader nextIndex কমিয়ে retry করবে(a) S1:[t1,t2] S2:[t1,t2] S3:[t1] S4:[t1] S5:[t1] leader=S1,term=2
(b) S1:[t1,t2] S2:[t1,t2] S3:[t1] S4:[t1] S5:[t1,t3] leader=S5,term=3 (crash)
(c) S1:[t1,t2] S2:[t1,t2] S3:[t1,t2] S4:[t1] S5:[t1,t3] leader=S1,term=4
^^^^^^^^^^^^^^^^^^^^^^^^^^^^ majority holds t2 — কিন্তু commit নয়!
(d) S1 crash -> S5 up-to-date-জয়ে leader,term=5 -> S2,S3-এর index2 OVERWRITE
S2:[t1,t3] S3:[t1,t3] <- (c)-তে majority-held t2 এন্ট্রি GONE
(e) বদলে: S1 নিজের term4-entry (index3) S2,S3-তে দিয়ে দিলে ->
S1:[t1,t2,t4] S2:[t1,t2,t4] S3:[t1,t2,t4] commitIndex=3, prefix-safe
এখন S5 (last-term=3) আর জিততেই পারবে না — S2,S3 (last-term=4) বেশি up-to-dateনিজে চালিয়ে দেখুন
AppendEntries consistency check + conflict truncation + Log Matching Property যাচাই
# raft_replicate.py -- AppendEntries consistency check + conflict truncation + Log Matching Property check
def append_entries(follower, prev_index, prev_term, entries, leader_commit):
"""prev_index is 1-based Raft log index (0 means 'no predecessor').
Returns True/False -- mirrors the real AppendEntries RPC reply."""
if prev_index > 0:
if len(follower) < prev_index or follower[prev_index - 1][0] != prev_term:
return False # reject: no matching prev entry
# accept: truncate any conflicting suffix, then append what's missing
for offset, entry in enumerate(entries):
idx = prev_index + 1 + offset # 1-based index this entry lands on
if len(follower) >= idx and follower[idx - 1][0] != entry[0]:
del follower[idx - 1:] # conflict -> cut the tail
if len(follower) < idx:
follower.append(entry)
return True
def replicate_to(leader_log, follower, next_index):
"""Simulates the leader's decrement-and-retry loop for one follower."""
trace = []
while True:
prev_index = next_index - 1
prev_term = leader_log[prev_index - 1][0] if prev_index > 0 else 0
entries = leader_log[next_index - 1:]
ok = append_entries(follower, prev_index, prev_term, entries, len(leader_log))
trace.append((next_index, ok))
if ok:
return trace
next_index -= 1 # rejected -> back off one slot
def fmt(log):
return "[" + " ".join(f"({t},{c})" for t, c in log) + "]"
LEADER = [(1,'a'), (1,'b'), (1,'c'), (4,'d'), (4,'e'), (5,'f'), (5,'g')]
# three followers with different divergence patterns (à la Raft paper Fig.7)
followers = {
"F-behind": [(1,'a'), (1,'b'), (1,'c'), (4,'d')],
"F-conflict": [(1,'a'), (1,'b'), (1,'c'), (2,'x'), (2,'y')], # stale leader wrote 2 extra entries
"F-extra": [(1,'a'), (1,'b'), (1,'c'), (4,'d'), (4,'e'), (5,'f'), (5,'g'), (5,'h')],
}
print(f"leader log: {fmt(LEADER)}\n")
for name, log in followers.items():
before = fmt(log)
trace = replicate_to(LEADER, log, next_index=len(LEADER) + 1)
steps = " -> ".join(f"try@{ni}:{'OK' if ok else 'reject'}" for ni, ok in trace)
print(f"{name:<11} before={before}")
print(f"{'':<11} {steps}")
print(f"{'':<11} after ={fmt(log)}\n")
# Log Matching Property check: for every pair of (now-converged) logs, wherever
# they match at (index, term), every earlier entry must also be identical.
all_logs = [LEADER] + list(followers.values())
def check_log_matching(a, b):
for i in range(min(len(a), len(b))):
if a[i][0] == b[i][0]: # same term at same index i
if a[:i] != b[:i]:
return False, i
return True, None
print("Log Matching Property check (pairwise, post-convergence):")
ok_all = True
for i in range(len(all_logs)):
for j in range(i + 1, len(all_logs)):
ok, bad = check_log_matching(all_logs[i], all_logs[j])
ok_all &= ok
tag = "OK" if ok else f"VIOLATED at {bad}"
print(f" log{i} vs log{j}: {tag}")
print(f"all pairs satisfy Log Matching Property: {ok_all}")প্রত্যাশিত output (deterministic):
leader log: [(1,a) (1,b) (1,c) (4,d) (4,e) (5,f) (5,g)]
F-behind before=[(1,a) (1,b) (1,c) (4,d)]
try@8:reject -> try@7:reject -> try@6:reject -> try@5:OK
after =[(1,a) (1,b) (1,c) (4,d) (4,e) (5,f) (5,g)]
F-conflict before=[(1,a) (1,b) (1,c) (2,x) (2,y)]
try@8:reject -> try@7:reject -> try@6:reject -> try@5:reject -> try@4:OK
after =[(1,a) (1,b) (1,c) (4,d) (4,e) (5,f) (5,g)]
F-extra before=[(1,a) (1,b) (1,c) (4,d) (4,e) (5,f) (5,g) (5,h)]
try@8:OK
after =[(1,a) (1,b) (1,c) (4,d) (4,e) (5,f) (5,g) (5,h)]
Log Matching Property check (pairwise, post-convergence):
log0 vs log1: OK
log0 vs log2: OK
log0 vs log3: OK
log1 vs log2: OK
log1 vs log3: OK
log2 vs log3: OK
all pairs satisfy Log Matching Property: Trueপড়ার সময় তিনটা লক্ষ্য: (১) F-conflict-এ leader পাঁচবার decrement
করেছে — S3-জাতীয় follower-এর মিথ্যা (2,x),(2,y) entries (কোনো
আগের leader-এর অসম্পূর্ণ কাজ) সরাসরি cut হয়ে গেছে, কোনো manual
diff লাগেনি; (২) F-extra-তে try@8 প্রথম চেষ্টাতেই match পেয়ে
গেছে, কিন্তু তার নিজের অতিরিক্ত (5,h) entry (যা leader-এর কাছেই
নেই) স্পর্শ করা হয়নি — heartbeat-এর empty entries suffix-কে proactively
মোছে না, শুধু conflict দেখলে মোছে; (৩) শেষ block-এ প্রতিটা log-pair
Log Matching Property মেনেছে — এটা কোনো আলাদা repair routine না,
consistency-check-এর স্বাভাবিক ফলাফল।
নানা রকম divergent follower log-এ leader-এর decrement-and-retry ঠিক conflict point খুঁজে নেয়, conflicting suffix truncate-overwrite হয়, আর convergence-এর পরে প্রতিটা log-pair Log Matching Property মেনে চলে — যেখানেই দুই log-এ একই (index,term) মেলে, তার আগের পুরো prefix হুবহু অভিন্ন।
নিজে বানান
Figure8Sim — Raft paper-এর Figure 8 danger scenario হুবহু reproduce করা
- append_entries() — prevLogIndex/prevLogTerm check + conflict truncate + append
- is_up_to_date() — election restriction: (last-term, তারপর length) তুলনা
- advance_commit_index() — majority match AND log[N].term==currentTerm — দুই শর্তই লাগবে
- অবস্থা (a)-(c) বানান: leader পুরনো-term entry majority-তে replicate করেছে, কিন্তু commit করেনি
- (d) danger path আর (e) safe path — দুইটাই চালিয়ে পার্থক্যটা নিজের চোখে দেখুন
লক্ষ্য: শুধু “commit rule-এ current-term শর্ত লাগে” মুখস্থ না করে, সেই শর্ত না থাকলে ঠিক কী ভেঙে যায় সেটা code-এ ঘটিয়ে দেখা — majority-held পুরনো-term entry একটা নতুন election-এ কীভাবে মুছে যেতে পারে, আর current-term-entry-র পর কেন সেটা আর সম্ভব না।
# raft_figure8.py -- reproducing the Raft paper's "Figure 8" danger scenario
def append_entries(follower, prev_index, prev_term, entries):
if prev_index > 0:
if len(follower) < prev_index or follower[prev_index - 1][0] != prev_term:
return False
for offset, entry in enumerate(entries):
idx = prev_index + 1 + offset
if len(follower) >= idx and follower[idx - 1][0] != entry[0]:
del follower[idx - 1:]
if len(follower) < idx:
follower.append(entry)
return True
def is_up_to_date(candidate_log, voter_log):
"""Raft election restriction: candidate must be >= voter by
(last-term, then last-index)."""
c_term = candidate_log[-1][0] if candidate_log else 0
v_term = voter_log[-1][0] if voter_log else 0
if c_term != v_term:
return c_term > v_term
return len(candidate_log) >= len(voter_log)
def advance_commit_index(leader_log, match_indices, current_term, cluster_size, commit_index):
"""match_indices: dict follower -> highest index known replicated
(leader counts itself as matching its own full log)."""
majority = cluster_size // 2 + 1
for n in range(len(leader_log), commit_index, -1):
count = sum(1 for mi in match_indices.values() if mi >= n)
if count >= majority and leader_log[n - 1][0] == current_term:
return n
return commit_index
def fmt(log):
return "[" + " ".join(f"({t},{c})" for t, c in log) + "]"
S1, S2, S3, S4, S5 = ([(1, 'x1')] for _ in range(5))
CLUSTER = 5
print("=== (a) S1 leader, term=2 — partially replicates index2=(2,x2) ===")
append_entries(S1, 1, 1, [(2, 'x2')])
append_entries(S2, 1, 1, [(2, 'x2')]) # only S2 gets it before S1 crashes
for n, log in (("S1", S1), ("S2", S2), ("S3", S3), ("S4", S4), ("S5", S5)):
print(f" {n}: {fmt(log)}")
print("\n=== (b) S1 crashes. S5 wins election term=3 (votes: S3,S4,S5) ===")
for name, log in (("S3", S3), ("S4", S4)):
assert is_up_to_date(S5, log), f"{name} should vote for S5"
print(" election-restriction check: S3, S4 both vote YES for S5 (tie on last-term=1, S5 log not shorter)")
append_entries(S5, 1, 1, [(3, 'x99')]) # S5 appends its own entry, only to itself, then crashes
print(f" S5 (leader,term3): {fmt(S5)}")
print("\n=== (c) S5 crashes. S1 restarts, wins election term=4 (votes: S1,S2,S3) ===")
assert is_up_to_date(S1, S2) and is_up_to_date(S1, S3)
print(" election-restriction check: S2, S3 vote YES for S1 (S1's last term=2 >= theirs)")
ok = append_entries(S3, 1, 1, [(2, 'x2')]) # S1 (term4) replicates the old index2 entry to S3
print(f" AppendEntries(S1->S3, prevIndex=1, prevTerm=1) accepted={ok}")
match = {"S1": 2, "S2": 2, "S3": 2} # S1,S2,S3 now all hold index2
committed_wrong = advance_commit_index(S1, match, current_term=4, cluster_size=CLUSTER, commit_index=0)
print(f" majority (S1,S2,S3) now hold index2 -- but S1.log[2].term=2 != currentTerm=4")
print(f" advance_commit_index() -> commitIndex stays at {committed_wrong} (NOT advanced to 2)")
print("\n=== (d) DANGER PATH: if S1 crashes right here, and S5 restarts ===")
S2_d, S3_d, S4_d = list(S2), list(S3), list(S4)
assert is_up_to_date(S5, S2_d) and is_up_to_date(S5, S3_d) and is_up_to_date(S5, S4_d)
print(" election-restriction check: S2,S3,S4 vote YES for S5 (S5 last-term=3 > their last-term=2)")
print(" -> S5 wins election term=5 with votes {S2,S3,S4}. S5 now overwrites index2 cluster-wide:")
for name, log in (("S2", S2_d), ("S3", S3_d), ("S4", S4_d)):
append_entries(log, 1, 1, [(3, 'x99')])
print(f" {name}: {fmt(log)}")
print(" the (2,x2) entry a majority held in (c) is GONE from S2 and S3.")
print(" had commitIndex been advanced by count-alone in (c), a committed entry would have vanished!")
print("\n=== (e) SAFE PATH: instead, S1 (term4) replicates its OWN entry before crashing ===")
S1e, S2e, S3e, S4e, S5e = list(S1), list(S2), list(S3), list(S4), list(S5)
append_entries(S1e, 2, 2, [(4, 'x7')]) # leader appends its own term-4 entry
ok2 = append_entries(S2e, 2, 2, [(4, 'x7')])
ok3 = append_entries(S3e, 2, 2, [(4, 'x7')])
print(f" AppendEntries(index3=(4,x7)) -> S2 accepted={ok2}, S3 accepted={ok3}")
match_e = {"S1": 3, "S2": 3, "S3": 3}
committed_e = advance_commit_index(S1e, match_e, current_term=4, cluster_size=CLUSTER, commit_index=0)
print(f" advance_commit_index() -> commitIndex={committed_e} "
f"(index3.term={S1e[2][0]} == currentTerm=4, majority reached)")
print(f" Log Matching Property: S1,S2,S3 all match at (index=3,term=4)")
print(f" -> prefixes identical too: S1[:3]==S2[:3]==S3[:3] : {S1e[:3] == S2e[:3] == S3e[:3]}")
print(f" -> so index1 AND index2 are retroactively safe as well, as part of the committed prefix")
print("\n now can S5 still steal the leadership for term=6?")
can_S2 = is_up_to_date(S5e, S2e)
can_S3 = is_up_to_date(S5e, S3e)
can_S4 = is_up_to_date(S5e, S4)
votes = 1 + sum([can_S2, can_S3, can_S4]) # S5 self-vote + whoever agrees
print(f" S2 votes S5? {can_S2} S3 votes S5? {can_S3} S4 votes S5? {can_S4}")
print(f" S5's best-case vote count = {votes} (needs {CLUSTER//2+1}) -> "
f"{'could still win!' if votes >= CLUSTER//2+1 else 'cannot win — safe'}")প্রত্যাশিত output (deterministic, hand-traced):
=== (a) S1 leader, term=2 — partially replicates index2=(2,x2) ===
S1: [(1,x1) (2,x2)]
S2: [(1,x1) (2,x2)]
S3: [(1,x1)]
S4: [(1,x1)]
S5: [(1,x1)]
=== (b) S1 crashes. S5 wins election term=3 (votes: S3,S4,S5) ===
election-restriction check: S3, S4 both vote YES for S5 (tie on last-term=1, S5 log not shorter)
S5 (leader,term3): [(1,x1) (3,x99)]
=== (c) S5 crashes. S1 restarts, wins election term=4 (votes: S1,S2,S3) ===
election-restriction check: S2, S3 vote YES for S1 (S1's last term=2 >= theirs)
AppendEntries(S1->S3, prevIndex=1, prevTerm=1) accepted=True
majority (S1,S2,S3) now hold index2 -- but S1.log[2].term=2 != currentTerm=4
advance_commit_index() -> commitIndex stays at 0 (NOT advanced to 2)
=== (d) DANGER PATH: if S1 crashes right here, and S5 restarts ===
election-restriction check: S2,S3,S4 vote YES for S5 (S5 last-term=3 > their last-term=2)
-> S5 wins election term=5 with votes {S2,S3,S4}. S5 now overwrites index2 cluster-wide:
S2: [(1,x1) (3,x99)]
S3: [(1,x1) (3,x99)]
S4: [(1,x1) (3,x99)]
the (2,x2) entry a majority held in (c) is GONE from S2 and S3.
had commitIndex been advanced by count-alone in (c), a committed entry would have vanished!
=== (e) SAFE PATH: instead, S1 (term4) replicates its OWN entry before crashing ===
AppendEntries(index3=(4,x7)) -> S2 accepted=True, S3 accepted=True
advance_commit_index() -> commitIndex=3 (index3.term=4 == currentTerm=4, majority reached)
Log Matching Property: S1,S2,S3 all match at (index=3,term=4)
-> prefixes identical too: S1[:3]==S2[:3]==S3[:3] : True
-> so index1 AND index2 are retroactively safe as well, as part of the committed prefix
now can S5 still steal the leadership for term=6?
S2 votes S5? False S3 votes S5? False S4 votes S5? True
S5's best-case vote count = 2 (needs 3) -> cannot win — safeতিনটা মুহূর্ত মন দিয়ে পড়ুন: (১) (c)-তে commitIndex stays at 0 —
majority থাকা সত্ত্বেও, কারণ term-check ব্যর্থ; (২) (d)-তে ঠিক ওই
entry পরে সত্যিই মুছে যায় — যদি (c)-তে commit ঘোষণা করা হতো, এই
মুহূর্তে সেটা একটা committed-data-loss হতো; (৩) (e)-তে নিজের
current-term entry replicate হওয়ার পরেই commitIndex=3 হয়, আর সেই
একই মুহূর্তে S5-এর re-election সম্ভাবনা False/False/True — দুইজন
ভোটার (S2, S3) এখন S5-কে প্রত্যাখ্যান করে, কারণ তাদের log এখন S5-এর
চেয়ে বেশি up-to-date।
নিজে বাড়ান:
advance_commit_index-এ current-term শর্তটা মুছে ফেলুন, (c)-তেই commitIndex=2 হয়ে যায় দেখান — তারপর (d) চালিয়ে সেই “committed” entry-টা কীভাবে মুছে গেল সেটা assert দিয়ে ধরুন।- ৬ষ্ঠ server S6 যোগ করে cluster-size=6 করুন — majority=4; (e)-তে S1 কয়টা follower-কে replicate করলে commit হবে তা হিসাব করুন।
is_up_to_date-এ term-tie-এর ক্ষেত্রে length-tie-ও হলে (দুই log পুরোপুরি সমান) — voter কি vote দিতে বাধ্য, নাকি স্বাধীন? Raft-এর real semantics (once-per-term ভোট) দিয়ে যুক্তি দিন।
বাস্তব সিস্টেমে
etcd। Kubernetes-এর প্রতিটা write etcd-এর Raft log-এই যায়। leader-election লেসনে pre-vote/check-quorum দেখেছি; log-replication দিকে etcd batching আর pipelining করে — একাধিক AppendEntries in-flight রাখে, প্রতিটা entry-র জন্য আলাদা round-trip না করে throughput বাড়ায়। Snapshot + log-compaction অনন্তকাল-বাড়া log সামলায় — আজকের লেসনের বাইরে, কিন্তু production-এ বাধ্যতামূলক।
Consul, TiKV, CockroachDB — multi-raft। একটাই cluster-এ raft group একাধিক (per-shard/per-range) — প্রতিটা shard নিজের leader, log, commitIndex নিয়ে independent। এই প্যাটার্নটাই পরের লেসনের hook: প্রতিটা group নিজের ভেতরে নিরাপদ, কিন্তু একটা transaction যদি দুই group-কে একসাথে ছোঁয় — সেই সমস্যা একা Raft সমাধান করে না।
Kafka KRaft। Metadata topic-এর জন্য quorum-controller Raft চালায় — leader-election লেসনে churn-প্রসঙ্গে দেখেছি; আজকের ভাষায় সেটাই AppendEntries দিয়ে metadata-record replicate করে, commit-rule একই।
raftscope / thesecretlivesofdata.com। Ongaro নিজে বানানো interactive visualization — Figure 8-জাতীয় scenario click করে ধাপে ধাপে দেখা যায়; বোঝার জন্য classic reference।
যে ভুলগুলো সবাই করে
“Raft আসলে Paxos-এর simplified/দুর্বল সংস্করণ — কম শক্তিশালী।”
Power-এ কোনো compromise নেই — একই crash-recovery model, একই majority-quorum
guarantee, একই fault-tolerance bound ((N-1)/2 failure সহ্য)। Raft
trade-off করেছে decomposability আর understandability নিয়ে, correctness-power
নিয়ে না — paper-এর নিজের দাবিই equivalence, weakening না।
“AppendEntries reject হলে leader পুরো log আবার পাঠায়।”
Leader শুধু nextIndex এক ঘর কমিয়ে ঠিক conflict-point খুঁজে বের করে —
শুধু missing/conflicting suffix-টুকু পাঠানো হয়, পুরো history না।
(Production implementation-এ conflict-term hint দিয়ে এক লাফে বেশি
পিছাতেও পারে, কিন্তু মূল mechanic per-entry probe, full resend না।)
“Majority-তে replicate হলেই entry committed — leader তখনই ঘোষণা দিতে পারে।”
Figure 8 trap ঠিক এই ধারণাটাই ভাঙে। পুরনো-term entry majority-তে থাকলেও শুধু count দেখে commit ঘোষণা করা যাবে না — leader-এর নিজের current-term-এর একটা entry majority-তে না যাওয়া পর্যন্ত পুরনো entry এখনও একটা ভবিষ্যৎ leader দিয়ে overwrite হওয়ার ঝুঁকিতে থাকে।
“Conflicting suffix delete করা risky — committed data হারানোর সম্ভাবনা আছে।”
উল্টো — এটাই Raft-এর সবচেয়ে নিরাপদ অংশ। Commitment-এর সংজ্ঞা (majority + current-term) নিশ্চিত করে conflicting/uncommitted suffix কখনোই majority পায়নি (Leader Completeness এই guarantee দেয়)। তাই truncation কখনোই committed data ছোঁয় না — এটা প্রমাণিত invariant, রক্ষণশীল অনুমান না।
বুঝেছেন কি না দেখুন
1AppendEntries RPC-এর কোন দুই field consistency check-এর ভিত্তি, আর
Log Matching Property ঠিক কী বলে?
স্মরণ
prevLogIndex আর prevLogTerm। Property: দুই log-এ কোনো index-এ
একই term-এর entry থাকলে, সেই index পর্যন্ত পুরো prefix দুই log-এ
হুবহু অভিন্ন।
2Log Matching Property-র induction proof-এ inductive step-টা ঠিক কোন
mechanism-এর ওপর ভরসা করে? এক বাক্যে বলুন।
যুক্তি
Follower কোনো entry accept করে only if তার log-এ ঠিক আগের
position-এ leader-এর বলা term ছিল — তাই index i+1-এ match থাকা
মানেই index i-তে match ছিল এটা একটা পূর্বশর্ত হিসেবে already
enforced হয়ে গেছে।
3Example scenario-তে t7-এ S3 কেন reject করল, আর t8-এ কীভাবে ঠিক হলো?
প্রয়োগ
t7-এ leader পাঠিয়েছিল prev=2/t1, কিন্তু S3-এর log length ছিল মাত্র
1 — prevLogIndex=2 position-ই নেই, reject। t8-এ leader nextIndex
এক ঘর কমিয়ে prev=1/t1 দিয়ে retry করল, যা S3-এর index1-এর সাথে
মিলে গেল — accept, বাকি দুই entry append হলো।
4Conflicting suffix overwrite করা “সবসময় নিরাপদ” — এই দাবিটার পেছনের
যুক্তিটা এক বা দুই বাক্যে reconstruct করুন।
যুক্তি
Commitment মানে majority-replication + current-term; conflicting entry follower-এর log-এ থাকা মানেই সেটা কোনো leader-এর অসম্পূর্ণ কাজ যা কখনো majority পায়নি (নইলে Leader Completeness পরের leader-কে সেটা overwrite করতেই দিত না) — তাই মোছা মানে committed কিছু হারানো নয়।
5Figure 8-এর (c) অবস্থায় আপনি যদি commit rule থেকে current-term
শর্তটা সরিয়ে দিতেন, ঠিক কোন পরের ঘটনায় সেই ভুলটা ধরা পড়ত, আর data
কীভাবে হারাত?
ডিজাইন
(d)-তে — S1 crash করার পর S5 (last-term=3, S2/S3/S4-এর last-term=2-এর চেয়ে বড়) election জিতে যেত up-to-date-rule অনুযায়ী, তারপর নিজের (term3) entry দিয়ে S2,S3-এর index2 overwrite করত। যে entry (c)-তে “committed” ঘোষণা করা হয়েছিল, সেটা চিরতরে হারিয়ে যেত — client-কে আগেই ack দেওয়া হয়ে থাকলে এটা একটা সরাসরি data-loss bug।
6Election restriction (Raft) বনাম prepare-phase discover-then-repropose
(Paxos) — leader-completeness পাওয়ার পথে দুটোর পার্থক্য এক লাইনে
বলুন।
প্রয়োগ
Paxos প্রতি round-এ acceptor-দের জিজ্ঞেস করে কী accept করেছ এবং সর্বোচ্চ-numbered value re-propose করে — runtime discovery। Raft-এ ভোট দেওয়ার শর্তেই (log up-to-date কি না) সেই guarantee বিল্ট-ইন — কোনো আলাদা discovery-round লাগে না।
এরপর কী
এরপর কী
Raft আমাদের একটা জিনিস দিল: একটা replicated group-এর ভেতরে সবাই একমত একটা single, totally-ordered log নিয়ে। কিন্তু বাস্তব সিস্টেমে একটা transaction প্রায়ই একটা group-এর সীমানা পেরিয়ে যায় — একাধিক independent shard, বা সম্পূর্ণ ভিন্ন service (প্রতিটার হয়তো নিজস্ব Raft group) — সবাইকে একসাথে commit বা একসাথে abort করতে হবে। এটা consensus-এর সমস্যা না; এটা atomic commitment-এর সমস্যা: সব participant রাজি, নাকি একজনও না। পরের লেসনে সেই ক্লাসিক উত্তর নিয়ে বসব — Two-Phase Commit: prepare-phase-এ সবাইকে জিজ্ঞেস করা, commit-phase-এ সবাইকে একসাথে কাজ শেষ করতে বলা — আর কেন একটা coordinator-crash এই protocol-এর সবচেয়ে বড় দুর্বলতা।
আরও পড়ুন
- In Search of an Understandable Consensus Algorithm (Raft) — Diego Ongaro, John Ousterhout — USENIX ATC 2014 · মূল paper — AppendEntries, Log Matching Property, Figure 8-র প্রমাণ সরাসরি এখান থেকেই
- Consensus: Bridging Theory and Practice — Diego Ongaro — Stanford PhD dissertation, 2014 · পূর্ণাঙ্গ formal proof — TLA+ specification-সহ Leader Completeness-এর বিস্তারিত
- etcd-io/raft — etcd project (CoreOS / CNCF) · Production-grade Go implementation — batching, pipelining, snapshot-সহ বাস্তব ইঞ্জিনিয়ারিং
- The Secret Lives of Data — Raft — Diego Ongaro · Interactive visualization — Figure 8-জাতীয় scenario click করে দেখার classic reference