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

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-এর ফাঁদ এড়ায়)।

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

  • Raft-কে কেন Paxos-এর equivalent-power কিন্তু decomposable বিকল্প হিসেবে ডিজাইন করা হয়েছিল তা leader election, log replication, safety — তিন sub-problem-এ ভেঙে ব্যাখ্যা করতে পারবেন
  • AppendEntries RPC-এর prevLogIndex/prevLogTerm consistency check কীভাবে Log Matching Property-কে জন্ম দেয় তা induction argument দিয়ে প্রমাণ করে বলতে পারবেন
  • Leader conflict পেলে কেন follower-এর suffix truncate-and-overwrite করা সবসময় নিরাপদ তা commitment-এর সংজ্ঞা থেকে যুক্তি দিয়ে বলতে পারবেন
  • Election restriction (log-up-to-date vote rule) কীভাবে Leader Completeness guarantee দেয় তা ব্যাখ্যা করে Paxos-এর acceptor-discover-then-repropose ধাপের সাথে তুলনা করতে পারবেন
  • Figure 8 trap থেকে current-term-only commit rule derive করে concrete counterexample trace করে বলতে পারবেন কেন পুরনো-term entry কখনো count-alone-এ committed ঘোষণা করা যায় না

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

আগে এটা বুঝি

গত লেসনে 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 প্রমাণিত (দুই log i পর্যন্ত অভিন্ন)। এখন index i+1-এ দুই log-এই যদি একই term থাকে, তার মানে সেই entry পাঠানো হয়েছিল এমন এক AppendEntries-এ যার prevLogIndex=i, prevLogTerm = leader-এর log-এ index i-এর term। আর follower ওই RPC accept করেছিল only if তার নিজের log-এও index i-তে ঠিক ওই একই term ছিল। অর্থাৎ index i-তে match থাকা ছিল index i+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 ফেলে দেয়।

follower-এ AppendEntries processing — এক নজরে
  1. term checkRPC-এর term < currentTerm হলে সরাসরি reject
  2. consistency checkprevLogIndex/prevLogTerm-এ match আছে?
  3. conflict resolvemismatch পেলে সেই position থেকে suffix truncate
  4. appendনতুন entries যোগ (idempotent — আগে থেকে থাকলে ছোঁয় না)
  5. 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 safetyprepare-phase-এ accepted value discover করে re-proposeelection-restriction guarantee করে — committed entry এমনিতেই log-এ থাকে
discover step লাগে?হ্যাঁ, প্রতি round-এনা — vote দেওয়ার শর্তেই বিল্ট-ইন
decompositionelection, 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 করবে
AppendEntries RPC-এর anatomy — prevLogIndex/prevLogTerm-ই consistency check আর Log Matching Property-র উৎস।
(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
Figure 8 trap — (c)-তে majority থাকা সত্ত্বেও পুরনো-term entry committed নয়; (d) সেটা প্রমাণ করে দেয় কেন, (e) নিরাপদ পথ দেখায়।

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

EXPERIMENT

AppendEntries consistency check + conflict truncation + Log Matching Property যাচাই

macOS / Linux / Windows (Python 3)· ১৫ মিনিট
# 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 হুবহু অভিন্ন।

নিজে বানান

BUILD IT

Figure8Sim — Raft paper-এর Figure 8 danger scenario হুবহু reproduce করা

Python · ●●●●○
  1. append_entries() — prevLogIndex/prevLogTerm check + conflict truncate + append
  2. is_up_to_date() — election restriction: (last-term, তারপর length) তুলনা
  3. advance_commit_index() — majority match AND log[N].term==currentTerm — দুই শর্তই লাগবে
  4. অবস্থা (a)-(c) বানান: leader পুরনো-term entry majority-তে replicate করেছে, কিন্তু commit করেনি
  5. (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।

নিজে বাড়ান:

  1. advance_commit_index-এ current-term শর্তটা মুছে ফেলুন, (c)-তেই commitIndex=2 হয়ে যায় দেখান — তারপর (d) চালিয়ে সেই “committed” entry-টা কীভাবে মুছে গেল সেটা assert দিয়ে ধরুন।
  2. ৬ষ্ঠ server S6 যোগ করে cluster-size=6 করুন — majority=4; (e)-তে S1 কয়টা follower-কে replicate করলে commit হবে তা হিসাব করুন।
  3. 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, রক্ষণশীল অনুমান না।

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

1

AppendEntries RPC-এর কোন দুই field consistency check-এর ভিত্তি, আর Log Matching Property ঠিক কী বলে?

স্মরণ

prevLogIndex আর prevLogTerm। Property: দুই log-এ কোনো index-এ একই term-এর entry থাকলে, সেই index পর্যন্ত পুরো prefix দুই log-এ হুবহু অভিন্ন।

2

Log 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 হয়ে গেছে।

3

Example 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 হলো।

4

Conflicting suffix overwrite করা “সবসময় নিরাপদ” — এই দাবিটার পেছনের যুক্তিটা এক বা দুই বাক্যে reconstruct করুন।

যুক্তি

Commitment মানে majority-replication + current-term; conflicting entry follower-এর log-এ থাকা মানেই সেটা কোনো leader-এর অসম্পূর্ণ কাজ যা কখনো majority পায়নি (নইলে Leader Completeness পরের leader-কে সেটা overwrite করতেই দিত না) — তাই মোছা মানে committed কিছু হারানো নয়।

5

Figure 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।

6

Election 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