ข้าม​ไป​ยัง​เนื้อหา

Capstone — cluster kaen-kvstore หลาย replica บน std::net: quorum + CRDT + consistent-hash

เจ็ด​บท​ที่​ผ่าน​มา​เรา​สร้าง​ชิ้น​ส่วน​ไว้​ครบ​แล้ว: vector clockvector clockเวกเตอร์​ตัว​นับ​ต่อ node (ที่​หาย​ไป = 0) จับ causality ครบถ้วน: a→b ก็​ต่อ​เมื่อ V(a)<V(b) (บท 1) ไว้​ตรวจ​ว่า write สอง​อัน​เป็น​ลำดับ​กัน​หรือ concurrent; consistent hashingconsistent hashingวาง node กับ key บน​วงแหวน hash เดียวกัน เพิ่ม node ที่ N+1 ย้าย key แค่ ~1/(N+1) ไม่ใช่​ทั้งหมด (บท 2) ไว้​ตอบ​ว่า key ควร​อยู่ replica ไหน; quorum W+R>N + read-repair (บท 3) ไว้​การันตี​ว่า​อ่าน​ทับ​เขียน​ล่าสุด​เสมอ; CRDTCRDTชนิด​ข้อมูล​ที่ replica merge กัน​แล้ว​ลู่​เข้า​เอง​โดย​ไม่​ต้อง coordinate — หัวใจ​คือ​กฎ merge กับ​กฎ merge แบบ semilattice (บท 4–5) ไว้​ให้ replica ลู่​เข้าหา​ค่า​เดียวกัน​โดย​ไม่​ต้อง coordinate; Merkle + gossip anti-entropy (บท 6) ไว้ sync ส่วน​ที่​ต่าง; และ harness ฉีด partitionpartitionเครือข่าย​ขาด: ข้าม​กลุ่ม​ส่ง​ข้อความ​ไม่​ถึงกัน — ต้นเหตุ​ของ split-brain ที่ harness ฉีด​เข้าไป แบบดี​เท​อร์มิ​นิสติก (บท 7) ไว้​ยืนยัน​สมบัติ​ที่ compiler มอง​ไม่​เห็น

บท​นี้​คือ การ​ประกอบ​ร่าง — เอา​ทุก​ชิ้น​มา​ต่อ​กัน​เป็น replicated cluster เดียว​ที่​รัน​ได้​จริง: N replica ของ kaen-kvstore คุย​กัน​บน std::net แต่ละ key เก็บ​สำเนา​ไว้​หลาย​ที่​แล้ว​ยัง​ลู่​เข้าหา​ค่า​เดียวกัน​ได้​แม้​เครือข่าย​จะ​ขาด นี่​คือ​นิยาม​ของ​สอง​คำ​ที่​เป็น​หัวใจ​ของ​บท: replicationreplicationเก็บ​สำเนา​ของ key เดียวกัน​ไว้​หลาย replica แล้ว​ทำให้​ทุก​สำเนา​เห็น​ตรง​กัน (เก็บ​สำเนา​ของ key เดียวกัน​ไว้​หลาย replica แล้ว​ทำให้​ทุก​สำเนา​เห็น​ตรง​กัน) และ eventual consistencyeventual consistencyถ้า​หยุด​เขียน​สัก​พัก ทุก replica จะ​ลู่​เข้า​เป็น​ค่า​เดียวกัน​ใน​ที่สุด (ไม่​รับประกัน 'ตอน​นี้') (ถ้า​หยุด​เขียน​สัก​พัก ทุก replica จะ​ลู่​เข้า​เป็น​ค่า​เดียวกัน ใน​ที่สุด — ไม่​รับประกัน “ตอน​นี้”)

📦 kaen-kvstore

บท​นี้ ต่อยอด repo kaen-kvstore จาก #22 (code ตัวอย่าง​กำลัง​จัด​ทำ) เป็น​บท​ปิด — เรา​ประกอบ​ทุก​ชั้น​ที่​สร้าง​มา​ตลอด 8 บท​เข้า​เป็น cluster เดียว: N replica ของ store เดิม (log + write→fsync→ack + hash index + immutable segment ของ #22) คุย​กัน​ผ่าน wire แบบ length-prefixed ของ #22 (write_frame/read_frame, ไม่มี serde) #23 แขวน replication/quorum/version-vector/anti-entropy ทับ store เดิม — ไม่​รื้อ​เขียน​ใหม่ Rust std ล้วน สุ่ม​ด้วย splitmix64 + std SipHash (DefaultHasher) ไม่มี tokio/proptest THE SCOPE LINE (ปิด​คอร์ส​ย้ำ): เรา​ลง code รัน​จริง เฉพาะ​ส่วน​ที่ enumerate/property-test ได้ (quorum, version vector, CRDT merge, read-repair) — ส่วน consensus/Raft ที่​ตรวจ​ครบ​ไม่​ได้ คง​ไว้​เป็น กล่อง​ไดอะแกรม เท่านั้น (ดู​หัวข้อ “กล่อง​ไดอะแกรม” ด้าน​ล่าง)

toolchain ที่ pin ไว้ + std ที่​บท​นี้​ใช้

ทุก snippet pin ที่ Rust stable 1.97.1 (ออก 2026-07-16) และ edition = “2024” [dependencies] ว่างเปล่า — ZERO external crate code ใน​บท​นี้​แตะ std เพียง std::collections (BTreeMap) เท่านั้น ทุก​โปรแกรม compile แบบ zero-warnings (-D warnings) และ รัน​จริง บน musl + rust-lld — เลข​ทุก​ตัว​ด้าน​ล่าง​คือ output จริง​ที่ seed ล็อก​ไว้ รัน​ซ้ำ​ได้ byte-identical

สิ่ง​ที่​เรา​กำลัง​จะ​สร้าง​ไม่ใช่​ของ​กุ​ขึ้น​เพื่อ​สอน — มัน​คือ stack จริง​ของ Amazon Dynamo (DeCandia et al. 2007): consistent-hash placement + quorum W/R + version-vector conflict detection + application/CRDT resolution + read-repair + Merkle anti-entropy เรา​แค่​ย่อ​มัน​ลง​มา​บน kaen-kvstore ที่ scale ของเล่น หัวใจ​อยู่​ที่ ชนิด​ของ value ที่​เก็บ: แต่ละ key ไม่​ได้​เก็บ byte ดิบๆ แต่​เก็บ​เป็น Versioned ที่​ประกอบ​จาก​สอง​ส่วน

  • value: Lww — payload ที่​เป็น CRDT (LWW-Register จาก​บท 4–5) ทำ​หน้าที่ แก้ (resolve) conflict เมื่อ write 2 concurrent มา​ชน​กัน
  • vv: VersionVector — causal metadata (vector clock จาก​บท 1 ที่ key ด้วย​ผู้​เขียน) ทำ​หน้าที่ ตรวจ (detect) ว่า2 write เป็น​ลำดับ​กัน (อัน1 dominate อีก​อัน) หรือ concurrent

แยก​หน้าที่​ให้​ชัด: version vector ตรวจ, CRDT แก้ ถ้า write ใหม่ dominate ของ​เก่า (เห็น​ทุก​อย่าง​ที่​ของ​เก่า​เห็น) ก็ทับได้ตรงๆ ไม่มี conflict; แต่​ถ้า​สอง​อัน concurrent — vv เทียบ​กัน​ไม่​ได้ — นั่น​คือ conflict จริง ต้อง​ให้ CRDT merge ตัดสิน (LWW เลือก​อัน​ที่ (ts, origin) สูง​กว่า) ไม่ใช่​แอบ​เลือก “ค่า max” มั่วๆ

use std::collections::BTreeMap;
#[derive(Clone, Debug, Default, PartialEq)]
struct VersionVector {
entries: BTreeMap<u64, u64>,
}
impl VersionVector {
fn get(&self, node: u64) -> u64 {
*self.entries.get(&node).unwrap_or(&0)
}
fn increment(&mut self, node: u64) {
*self.entries.entry(node).or_insert(0) += 1;
}
// element-wise max over the union = the semilattice join (LUB)
fn merge(&self, other: &Self) -> Self {
let mut out = self.clone();
for (&n, &c) in &other.entries {
let e = out.entries.entry(n).or_insert(0);
if c > *e {
*e = c;
}
}
out
}
// self is strictly causally newer than other: >= on every component AND != .
fn dominates(&self, other: &Self) -> bool {
let mut keys: Vec<u64> = self.entries.keys().copied().collect();
for k in other.entries.keys() {
if !self.entries.contains_key(k) {
keys.push(*k);
}
}
let mut strictly = false;
for k in keys {
let (a, b) = (self.get(k), other.get(k));
if a < b {
return false;
}
if a > b {
strictly = true;
}
}
strictly
}
}

Lww คือ payload ที่ resolve conflict ด้วย total order (ts, origin, val) — tie ทุก​กรณี​ถูก​ตัดสิน​แบบดี​เท​อร์มิ​นิสติก merge จึง commutative:

#[derive(Clone, Debug)]
struct Lww {
ts: u64,
origin: u64,
val: Vec<u8>,
}
impl Lww {
fn merge(&self, other: &Self) -> Lww {
if (other.ts, other.origin, &other.val) > (self.ts, self.origin, &self.val) {
other.clone()
} else {
self.clone()
}
}
}

แล้ว Versioned::reconcile คือ​หัวใจ​ที่​รวม​สอง​อย่าง​เข้า​ด้วย​กัน — vv ตรวจ​ก่อน, ถ้า concurrent ค่อย​ให้ CRDT แก้:

#[derive(Clone, Debug)]
struct Versioned {
value: Lww,
vv: VersionVector,
}
impl Versioned {
// version vector DETECTS the relationship; the CRDT RESOLVES a genuine conflict.
fn reconcile(&self, other: &Versioned) -> Versioned {
if self.vv.dominates(&other.vv) {
self.clone() // self causally newer -> it wins outright
} else if other.vv.dominates(&self.vv) {
other.clone()
} else {
// equal or CONCURRENT: join the vv, let the CRDT pick the value
Versioned {
value: self.value.merge(&other.value),
vv: self.vv.merge(&other.vv),
}
}
}
}

ใน code สาธิต​ด้าน​ล่าง แต่ละ replica คือ BTreeMap<Vec<u8>, Versioned> หนึ่ง​ตัว — มัน​คือ​ตัวแทน​ของ kaen-kvstore instance หนึ่ง​เครื่อง ที่​ใน​ระบบ​จริง​จะ​เข้าถึง​ผ่าน std::net ด้วย wire แบบ length-prefixed ของ #22 (write_frame/read_frame) โดย​ไม่​แตะ serde เลย ส่วน partition mask up[i] (node ไหน​ติดต่อ​ได้​ตอน​นี้) คือ​สิ่ง​ที่ harness ของ​บท 7 พลิก​เข้า-ออก #23 จึง​เป็น​แค่​ชั้น​บางๆ ที่ แขวน replication + quorum + version-vector + anti-entropy ทับ​บน log / write→fsync→ack / hash-index / immutable-segment ของ #22 ไม่​ได้​เขียน store ใหม่ และ การ​เลือก replica set ของ​แต่ละ key ใช้ ring ของ​บท 2 — consistent-hash วาง​แต่ละ key ลง​ชุด node ที่​รับผิดชอบ แล้ว coordinator ก็ทำ quorum กับ​ชุด​นั้น

Cluster รวม replica ทั้งหมด + parameter n/w/r แล้ว​ให้ quorum_write, read (พร้อม read-repair) กับ​ตัว​แยก prepare/commit (ที่​ทำให้​ทดลอง write ที่ concurrent ได้):

struct Cluster {
replicas: Vec<BTreeMap<Vec<u8>, Versioned>>,
n: usize,
w: usize,
r: usize,
}
impl Cluster {
fn new(n: usize, w: usize, r: usize) -> Cluster {
Cluster {
replicas: (0..n).map(|_| BTreeMap::new()).collect(),
n,
w,
r,
}
}
// read reachable base, build the NEW Versioned (bump coord, fresh Lww) WITHOUT committing.
// Splitting prepare/commit lets two coordinators branch from the SAME base = concurrent writes.
fn prepare(&self, coord: u64, key: &[u8], val: &[u8], ts: u64, up: &[bool]) -> Versioned {
let mut base = VersionVector::default();
for i in 0..self.n {
if up[i] {
if let Some(v) = self.replicas[i].get(key) {
base = base.merge(&v.vv);
}
}
}
base.increment(coord);
Versioned {
value: Lww { ts, origin: coord, val: val.to_vec() },
vv: base,
}
}
// commit to every reachable replica by RECONCILE (merge-on-write, never blind overwrite).
// ack = reachable replicas; the write is durable iff ack >= W.
fn commit(&mut self, key: &[u8], v: &Versioned, up: &[bool]) -> Result<usize, usize> {
let mut ack = 0;
for i in 0..self.n {
if up[i] {
let merged = match self.replicas[i].get(key) {
Some(cur) => cur.reconcile(v),
None => v.clone(),
};
self.replicas[i].insert(key.to_vec(), merged);
ack += 1;
}
}
if ack >= self.w { Ok(ack) } else { Err(ack) }
}
fn quorum_write(&mut self, coord: u64, key: &[u8], val: &[u8], ts: u64, up: &[bool]) -> Result<usize, usize> {
let v = self.prepare(coord, key, val, ts, up);
self.commit(key, &v, up)
}
// R-quorum read: gather reachable replicas, reconcile to the LUB, then READ-REPAIR
// every reachable replica whose stored version differs.
fn read(&mut self, key: &[u8], up: &[bool]) -> Result<Option<Vec<u8>>, usize> {
let reachable: Vec<usize> = (0..self.n).filter(|&i| up[i]).collect();
if reachable.len() < self.r {
return Err(reachable.len()); // cannot assemble a read quorum
}
let mut lub: Option<Versioned> = None;
for &i in &reachable {
if let Some(v) = self.replicas[i].get(key) {
lub = Some(match lub {
Some(acc) => acc.reconcile(v),
None => v.clone(),
});
}
}
if let Some(ref win) = lub {
for &i in &reachable {
let repaired = match self.replicas[i].get(key) {
Some(cur) => cur.reconcile(win),
None => win.clone(),
};
self.replicas[i].insert(key.to_vec(), repaired);
}
}
Ok(lub.map(|v| v.value.val))
}
fn replica_value(&self, i: usize, key: &[u8]) -> Option<Vec<u8>> {
self.replicas[i].get(key).map(|v| v.value.val.clone())
}
}

สังเกต​ว่า commit และ read ไม่​เคย​เขียน​ทับ​ดิบๆ — มัน reconcile เข้า​กับ​ค่า​เดิม​เสมอ นี่​คือ CRDT-as-value จาก​บท 5: write กลาย​เป็น merge ทำให้ idempotent (ส่ง​ซ้ำ​ไม่มี​ผล) และ commutative (ลำดับ​ส่ง​ไม่มี​ผล) — เงื่อนไข​เดียว​กับ​ที่​ทำให้ eventual consistency เป็น​จริง

happy-path demo บอก​อะไร​เรา​ไม่​ได้​เลย สิ่ง​ที่​ต้อง​พิสูจน์​คือ​สอง​สมบัติ​ที่ เห็น​ได้​เฉพาะ​เมื่อ​ฉีด partition เข้าไป — และ​มัน​คือ​สิ่ง​ที่ harness ของ​บท 7 ยืนยัน (ที่​นี่​เรา​สาธิต​ด้วย​ฉาก​ที่​ล็อก​ไว้​ให้​อ่าน​ง่าย ตัว harness เต็ม​คือ​ของ​บท 7 ที่​รัน 500 seed)

สมบัติ 1 — “write ที่ ack แล้ว​ต้อง​ไม่​หาย ถ้า W+R>N”: เขียน x=a ตอน replica 2 ถูก​ตัดขาด — ack จาก​ฝั่ง​ที่​ติดต่อ​ได้ {0,1} (2 ≥ W=2) ถือว่า​สำเร็จ replica 2 พลาด write นี้​ไป แต่​พอ heal แล้ว​อ่าน​ครั้ง​เดียว read-repair จะพา a ไป​ถึง replica 2 เพราะ read quorum ทับ write quorum เสมอ (W+R=4>3)

สมบัติ 2 — “replica ลู่​เข้า​หลัง heal”: 2 write ที่ concurrent (coordinator คนละ​ตัว branch จาก base เดียวกัน) reconcile กัน​ด้วย version vector + CRDT merge จน​ทุก replica เห็น​ค่า​เดียวกัน

fn s(o: &Option<Vec<u8>>) -> String {
match o {
Some(v) => String::from_utf8_lossy(v).into_owned(),
None => "None".to_string(),
}
}
fn main() {
// N=3, W=2, R=2 => W+R = 4 > 3 => every read quorum meets the newest write quorum.
let mut c = Cluster::new(3, 2, 2);
let all = [true, true, true];
// ---- Property 1: NO LOST ACKED WRITE under W+R>N (durability across a partition) ----
let masked = [true, true, false]; // replica 2 partitioned away
let ack = c.quorum_write(0, b"x", b"a", 1, &masked).expect("W-quorum {0,1} must ack");
println!("write x=a while replica 2 partitioned -> acked by {ack} replicas (W=2) OK");
assert_eq!(c.replica_value(2, b"x"), None, "replica 2 missed the write during the partition");
let got = c.read(b"x", &all).expect("R-quorum available after heal");
println!("after heal, read x -> {} ; read-repair fires", s(&got));
for i in 0..3 {
assert_eq!(c.replica_value(i, b"x").as_deref(), Some(&b"a"[..]), "replica {i} not repaired");
}
println!("Property 1 holds: acked write x=a present on ALL 3 replicas (no lost acked write)");
// ---- Property 2: REPLICAS CONVERGE AFTER HEAL (concurrent writes reconcile) ----
let wa = c.prepare(1, b"y", b"aa", 5, &all); // vv {1:1}, ts=5
let wb = c.prepare(2, b"y", b"bb", 7, &all); // vv {2:1}, ts=7
assert!(!wa.vv.dominates(&wb.vv) && !wb.vv.dominates(&wa.vv), "the two writes must be concurrent");
c.commit(b"y", &wa, &all).expect("W ok");
c.commit(b"y", &wb, &all).expect("W ok");
let y = c.read(b"y", &all).expect("R ok");
println!("concurrent y: aa@ts5 (node1) || bb@ts7 (node2) -> converges to {}", s(&y));
for i in 0..3 {
assert_eq!(c.replica_value(i, b"y").as_deref(), Some(&b"bb"[..]), "replica {i} did not converge");
}
println!("Property 2 holds: all 3 replicas converged to bb (aa was concurrent, LWW-dropped)");
println!("PART A: quorum + version-vector + CRDT composition -- both safety properties hold.");
}

รัน​จริง​บน musl ได้​ผล​ตาม​นี้:

write x=a while replica 2 partitioned -> acked by 2 replicas (W=2) OK
after heal, read x -> a ; read-repair fires
Property 1 holds: acked write x=a present on ALL 3 replicas (no lost acked write)
concurrent y: aa@ts5 (node1) || bb@ts7 (node2) -> converges to bb
Property 2 holds: all 3 replicas converged to bb (aa was concurrent, LWW-dropped)
PART A: quorum + version-vector + CRDT composition -- both safety properties hold.

x=a ที่ ack ไป​ตอน replica 2 ล่ม กลับ​มา​อยู่​ครบ​ทั้ง 3 replica หลัง read-repair — ไม่มี write ที่ ack แล้ว​หาย ส่วน y ที่​มี2 write concurrent (aa@ts5 กับ bb@ts7) ลู่​เข้า​เป็น bb เหมือน​กัน​ทุก replica เพราะ LWW เลือก ts สูง​กว่า (aa ถูก​ทิ้ง​อย่าง​เงียบๆ — lossy โดย design ถ้า​รับ​ไม่​ได้​ต้อง​ใช้ OR-Set/PN-Counter จาก​บท 5 แทน)

flowchart TB
    subgraph cluster["cluster kaen-kvstore (N=3, W=2, R=2)"]
        R0["replica 0<br/>x=a"]
        R1["replica 1<br/>x=a"]
        R2["replica 2<br/>(partitioned)"]
    end
    C["client เขียน x=a"] -->|ack จาก W=2| R0
    C -->|ack จาก W=2| R1
    C -.->|ข้อความไม่ถึง| R2
    R0 -->|heal + read-repair| R2
    R1 -->|heal + read-repair| R2

    subgraph box["📦 กล่องไดอะแกรมเท่านั้น — ไม่ลง code (FLP 1985 / Raft S16)"]
        L["leader"] -->|log replication| F1["follower"]
        L -->|log replication| F2["follower"]
        L -.->|commit index / leader election| L
    end

คำ​บรรยาย​ภาพ: capstone — quorum + read-repair + CRDT ลู่​เข้า​ได้ พิสูจน์​ด้วย harness ที่​รัน​จริง (client เขียน​ได้ ack จาก​ฝั่ง majority, พอ heal แล้ว read-repair พา x=a ไป​ถึง replica ที่​เคย​ขาด); ส่วน consensus (leader election, log replication, commit index) อยู่​ใน กล่อง​ไดอะแกรม​เท่านั้น — Rust พิสูจน์​ให้​ไม่​ได้

ทำไม​สมบัติ 1 ถึง “ลง code รัน​จริง” ได้​อย่าง​มั่นใจ ใน​ขณะ​ที่ consensus ต้อง​อยู่​ใน​กล่อง​ไดอะแกรม คำ​ตอบ​คือ quorum intersection เป็น​ข้อเท็จจริง​เชิง​การ​จัด​หมู่​ที่​สถิต — สำหรับ N ที่​กำหนด เรา เอ็น​นิว​เมอเรต​ทุก W-set กับ​ทุก R-set ได้​ครบ แล้ว​ยืนยัน​ว่า​ทุก​คู่​ทับ​กัน​ก็​ต่อ​เมื่อ W+R>N ไม่ใช่​การ​สุ่ม​ตัวอย่าง แต่​คือ​การ​ตรวจ​ครบ​ทั้ง space:

fn every_wr_pair_intersects(n: usize, w: usize, r: usize) -> bool {
let total: u32 = 1u32 << n;
for a in 0..total {
if (a.count_ones() as usize) != w {
continue;
}
for b in 0..total {
if (b.count_ones() as usize) != r {
continue;
}
if (a & b) == 0 {
return false; // a disjoint W/R pair exists -> a read can miss the write
}
}
}
true
}
fn main() {
let n = 5;
let (mut checked, mut disagreements) = (0u32, 0u32);
for w in 1..=n {
for r in 1..=n {
let all_intersect = every_wr_pair_intersects(n, w, r);
let predicted = w + r > n;
if all_intersect != predicted { disagreements += 1; }
assert_eq!(all_intersect, predicted);
checked += 1;
}
}
println!("N={n}: verified {checked} (W,R) pairs -- exhaustive intersection == (W+R>N) both directions");
println!("disagreements = {disagreements} (PASS)");
let corner_ok = every_wr_pair_intersects(n, 3, 3);
let corner_bad = every_wr_pair_intersects(n, 3, 2);
assert!(corner_ok && !corner_bad);
println!("boundary: (W=3,R=3) always intersects; (W=3,R=2) has a disjoint pair -- as predicted");
}
N=5: verified 25 (W,R) pairs -- exhaustive intersection == (W+R>N) both directions
disagreements = 0 (PASS)
boundary: (W=3,R=3) always intersects; (W=3,R=2) has a disjoint pair -- as predicted

ทั้ง 25 คู่ (W,R) ที่ N=5 ตรง​กับ​สูตร W+R>N เป๊ะ ทั้ง​สอง​ทาง — คู่​ที่ W+R>N ทับ​กัน​เสมอ​จริง และ​คู่​ที่ W+R≤N มี​คู่ disjoint จริง นี่​คือ​สิ่ง​ที่ Rust พิสูจน์​ให้​ได้ บน​เครื่อง​เดียว เพราะ​มัน​เป็น combinatorics ที่​ปิด ไม่ใช่​พฤติกรรม runtime ที่​ต้อง​ภาวนา

สมบัติ 2 (ลู่​เข้า​หลัง heal) วาง​อยู่​บน​กฎ​เดียว: merge ต้อง​เป็น join ของ semilattice (commutative + associative + idempotent) กฎ​นี้​เอง​ที่​ทำให้ replica เห็น​ชุด write เดียวกัน (ไม่​ว่า​ลำดับ​หรือ​ส่ง​ซ้ำ​แค่​ไหน) ลู่​เข้าหา​สถานะ​เดียวกัน​เสมอ เรา​ปิด​คอร์ส​ด้วย​การ​พิสูจน์​มัน​อีก​ครั้ง​บน G-Counter — ซึ่ง​มี​โครง​เหมือน version vector ของ​บท 1 เป๊ะ (merge = element-wise max) จึง​ลู่​เข้า​ด้วย เหตุผล​เดียว​กับ CRDT payload ของ capstone ใช้ splitmix64splitmix64PRNG ตัว​เล็ก​เขียน​เอง seed ได้ ผล​ซ้ำ​ได้ 100% — แทน proptest/quickcheck ใน​แซนด์บ็อกซ์ std ล้วน seed คงที่ 0xDEADBEEF, 3000 triple:

#[derive(Clone, PartialEq)]
struct GCounter {
slots: BTreeMap<u64, u64>,
}
impl GCounter {
fn new() -> Self { Self { slots: BTreeMap::new() } }
fn inc(&mut self, node: u64, by: u64) {
*self.slots.entry(node).or_insert(0) += by;
}
fn merge(&self, other: &Self) -> Self {
let mut out = self.clone();
for (&n, &c) in &other.slots {
let e = out.slots.entry(n).or_insert(0);
if c > *e { *e = c; }
}
out
}
fn value(&self) -> u64 { self.slots.values().sum() }
}

ตัว property test สุ่ม triple x, y, z แล้ว​ยืนยัน​สาม​กฎ​ทุก​ชุด (commutative x|y==y|x, associative (x|y)|z==x|(y|z), idempotent x|x==x) — ตัว driver ที่​ขับ loop นี้​คือ pattern splitmix64 + law-loop ตัว​เดียว​กับ property test ในบท 1/บท 4 (ไม่​กาง code ซ้ำ​ที่​นี่) seed 0xDEADBEEF คงที่​จึง​ได้ checks=9000 ที่​รัน​ซ้ำ​ได้:

G-Counter demo: merge([0:3],[1:5]) = value 8 (order-independent: true)
--- G-Counter semilattice property test (seed=0xdeadbeef, 3000 cases) ---
checks = 9000, violations = 0
PART C: G-Counter merge is a join-semilattice -- the capstone's convergence guarantee.

checks=9000 violations=0 — 3 กฎ × 3000 เคส ไม่มี​ข้อ​ไหน​พลาด นี่​คือ Strong Eventual Consistency ที่ Shapiro et al. (2011) พิสูจน์​ไว้: semilattice + eventual delivery ⇒ ลู่​เข้า โดย​ไม่​ต้อง consensus เลย และ​นั่น​คือ​เหตุผลลึกๆ ว่า​ทำไม CRDT ถึง​อยู่​ฝั่ง​ซ้าย​ของ​เส้น scope ส่วน consensus อยู่​ฝั่ง​ขวา

กล่อง​ไดอะแกรม​เท่านั้น — consensus / leader-based replication / linearizable multi-key txn

สาม​อย่าง​นี้ ไม่มี​ใน code ที่ ship และ​จะ​ไม่มี​ตลอด​คอร์ส — มัน​อยู่​ใน​ไดอะแกรม​ด้าน​บน​เท่านั้น:

1. Raft / consensus correctness. Raft ให้ leader หนึ่ง​ตัว​รับ write, replicate log ไป​ยัง follower, แล้ว​เลื่อน commit index เมื่อ majority ยืนยัน (Ongaro–Ousterhout, ATC 2014) โครงสร้าง​นี้ เข้าใจ​ง่าย แต่ พิสูจน์​ความ​ถูกต้อง​บน​เครื่อง​เดียว​ไม่​ได้: Raft ที่​ผิด​นิดเดียว (เช่น เงื่อนไข election หรือ log-matching เพี้ยน) ก็​ยัง compile zero-warnings และ​ผ่าน happy path เพราะ Rust พิสูจน์​ความ​ถูกต้อง ใน​เครื่อง​เดียว (memory/type/data-race) ไม่ใช่ แบบ​กระจาย bug จะ​โผล่​เฉพาะ​ตอน partition + reorder + clock-skew ที่ ไม่​เกิด​ซ้ำ​ใน process เดียว

2. ทำไม​ตรวจ​ครบ​ไม่​ได้ (FLP 1985). Fischer–Lynch–Paterson พิสูจน์​ว่า ไม่มี algorithm consensus แบบ asynchronous ที่​การันตี​ได้​ว่า​จะ จบ​และ​ถูกต้อง ถ้า​มี node เสีย​แม้​เพียง​หนึ่ง — ต่อ scheduler ที่​เป็น​ปฏิปักษ์ ดังนั้น green test ของ Raft ที่​เขียน​เอง​จะ​เป็น คำ​กล่าว​อ้าง​ความ​ถูกต้อง​ที่​เท็จ ต่าง​จาก quorum arithmetic ที่ enumerate ครบ​ได้ (25 คู่​ข้าง​บน) — state space ของ Raft ระเบิด ตรวจ​ครบ​ไม่​ได้

3. linearizable multi-key transaction ก็​อยู่​ใน​กล่อง​นี้​ด้วย: quorum ซื้อ​แค่ การ​ทับ​กัน (intersection) ไม่​ได้​ซื้อ linearizabilitylinearizabilityมี​ลำดับ​รวม​หนึ่ง​ที่​เคารพ​เวลา​จริง​และ​ถูกต้อง​ตาม object; quorum+LWW 'ไม่' การันตี​ข้อ​นี้ และ​ไม่​ได้​ซื้อ agreement บน​ลำดับ — เรา​พูดตรงๆ ว่า cluster นี้​เป็น eventually consistent ไม่ใช่ linearizable (checker ของ​บท 7 คืน false บน history ของ store นี้ โดย​ตั้งใจ — มัน​มาร์ก​เส้น​ที่ linearizable-txn/consensus ออก​จาก​ส่วน​ที่ ship ได้​พอดี)

honesty spine 4 เส้น (บท​ปิด​ยึด​ครบ​ทั้ง​สี่ เหมือน​บท​เปิด)

A — Rust พิสูจน์ “ใน​เครื่อง​เดียว” ไม่ใช่ “แบบ​กระจาย”: borrow checker จับ data race ใน process เดียว แต่​มอง​ไม่​เห็น partition/reorder/skew — harness ฉีด partition (บท 7) คือ ออราเคิล ของ​สมบัติ​แบบ​กระจาย และ​มัน​คือ​การ ตรวจ​แบบ​มี​ขอบเขต ไม่ใช่​บท​พิสูจน์ (FLP 1985): ผ่าน 500 seed แปล​ว่า​สำรวจ​เฉพาะ interleaving ที่ PRNG เดิน​ไป​ถึง

B — เส้น scope: quorum arithmetic enumerate ครบ​ทุก subset ได้ (25 คู่) จึง ลง code รัน​จริง; consensus/Raft/leader-replication/linearizable-txn ตรวจ​ครบ​ไม่​ได้ จึง​อยู่​ใน กล่อง​ไดอะแกรม เท่านั้น

C — ดี​เท​อร์มิ​นิส​ซึม​คือ​วินัย: seed คงที่ (0xDEADBEEF) + single-scheduler + hard timeout + BTreeMap (ไม่ใช่ HashMap ที่ RandomState สลับ​ลำดับ​ต่อ process) ทุก harness มี round budget มิ​ฉะนั้น​มัน ค้าง (compiler จับ deadlock ไม่​ได้) หรือ โกหก (รัน​แดง​ซ้ำ​ไม่​ได้)

D — นี่​คือ​กลไก​จริง​ของ Dynamo/Cassandra/Riak: consistent-hash + quorum + version vector + CRDT + Merkle + gossip คือ stack จริง​ของ Amazon Dynamo — สอน​ตรง​ต้นฉบับ​ที่ scale ของเล่น​บน kaen-kvstore ไม่ใช่​ของ​กุ​ขึ้น

คนละ​เลน​กับ #8 (bounded-contexts-integration)

อย่า​สับสน​สอง​คอร์ส​นี้ #8 คือ message DELIVERY ข้าม bounded context (Wolverine outbox/RabbitMQ, at-least-once event transport) — ส่ง เหตุการณ์ ให้​ถึง​อีก​บริการ ส่วน #23 คือ data REPLICATION ของ state ของ key เดียว ข้าม replica (quorum + CRDT convergence) — ทำให้ สำเนา​ของ​ค่า​เดียวกัน เห็น​ตรง​กัน คนละ​เลน คนละ​การันตี

คุณ​เดิน​ครบ​สาม​คอร์ส​ของ Systems pillar แล้ว:

  • #21 rust-from-scratch — เรียน Rust: ownership/borrow/lifetime, trait, error handling, thread + channel — เครื่องมือ​ทั้ง​ชุด​ที่​พิสูจน์​ความ​ถูกต้อง ใน​เครื่อง​เดียว
  • #22 rust-kvstore — สร้าง​เอนจิน: เอา Rust นั้น​มาสร้าง kaen-kvstore node เดียว​ที่​รัน​จริง — network server, append-only log + hash index, write→fsync→ack durability, tombstone/compaction, thread pool
  • #23 rust-distributed-systems — กระจาย​มัน: เลื่อน node เดียว​ขึ้น​เป็น cluster — logical clock → consistent hashing → quorum → CRDT → anti-entropy → harness → capstone นี้ ทั้งหมด Rust std ล้วน พิสูจน์​ด้วย property test ที่​รัน​ได้​จริง

บทเรียน​กลาง​ของ​ทั้ง pillar คือ เส้น scope: Rust พิสูจน์​ความ​ถูกต้อง ใน​เครื่อง​เดียว ได้​อย่าง​ทรง​พลัง — เรา​จึง​ลง code รัน​จริง​เฉพาะ​สมบัติ​ที่ enumerate/property-test ได้ (quorum, CRDT, logical clock) ส่วน​ความ​ถูกต้อง แบบ​กระจาย ที่ scheduler เป็น​ปฏิปักษ์ (consensus/Raft) เรา​ซื่อสัตย์​ว่า​ตรวจ​ครบ​ไม่​ได้ จึง​คง​ไว้​เป็น​ไดอะแกรม นี่​ไม่ใช่​ข้อ​จำกัด​ที่​น่า​อาย แต่​คือ วินัย​ทาง​วิศวกรรม: รู้​ว่า​อะไร​พิสูจน์​ได้ อะไร​พิสูจน์​ไม่​ได้ แล้ว​ไม่​กล่าว​อ้าง​เกิน​จริง

จาก​ตรง​นี้ ถ้า​อยาก​ไป​ต่อ​ใน​โลก​จริง: อ่าน DDIA (Kleppmann 2017) ให้​ครบ ch5 (replication) + ch9 (consistency & consensus), ลอง Jepsen (JepsenJepsenระเบียบ​วิธี​ทดสอบ​ระบบ​กระจาย​ด้วย​การ​ฉีด fault แล้ว​ตรวจ history; harness ใน​คอร์ส​นี้​คือ​รุ่น​ย่อ) ของ​จริง​กับ​ระบบ production, และ​ถ้า​จะ​แตะ consensus ให้​ใช้ library ที่​ผ่าน​การ​ทดสอบ​มา​แล้ว — ไม่ใช่​เขียน Raft เอง​จาก scratch แล้ว​เชื่อ green test


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้

บท​นี้​อิง​ต้นทาง​ที่​ลง​วัน​ที่​กำกับ อ่าน​ต่อ​ได้​โดยตรง:

เช็กความเข้าใจ — บทที่ 8

ข้อ 1 / 3

ทำไม quorum arithmetic (W+R>N) ถึง 'ลง code รันจริง' ได้ แต่ consensus/Raft ต้องอยู่ในกล่องไดอะแกรมเท่านั้น?