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

Harness ฉีด partition แบบดี​เท​อร์มิ​นิสติก (Jepsen-lite) + linearizability checker

บท 6 คุณ​สร้าง​กลไก ลู่​เข้า ครบ​ชุด​แล้ว — Merkle anti-entropy หา key ที่​ต่าง​แบบ sub-linear, gossip กระจาย​ข่าว​ใน O(log N), membership merge เป็น monotone semilattice ทุก​ชิ้น​ผ่าน property test เป็น​พันๆ เคส แต่​มี​คำถาม​ที่​ยัง​ค้าง: เรา​รู้​ได้​อย่างไร​ว่า​มัน​ถูกต้อง​ตอน​เครือข่าย “ขาด​จริง” — ตอน​ที่ replica ครึ่ง​หนึ่ง​คุย​กับ​อีก​ครึ่ง​ไม่​ได้ ต่าง​ฝั่ง​ต่าง​รับ​เขียน แล้ว​พอ​เชื่อม​กลับ มัน​ยัง​ลู่​เข้าหา​ค่า​เดียวกัน​โดย​ไม่​ทำ write ที่ ack ไป​แล้ว​หล่น​หาย?

บท​นี้​คือ ออราเคิล ของ​ทั้ง​คอร์ส เรา​สร้าง harness ฉีด partitionpartitionเครือข่าย​ขาด: ข้าม​กลุ่ม​ส่ง​ข้อความ​ไม่​ถึงกัน — ต้นเหตุ​ของ split-brain ที่ harness ฉีด​เข้าไป แบบดี​เท​อร์มิ​นิสติก — รุ่น​ย่อ​ของ JepsenJepsenระเบียบ​วิธี​ทดสอบ​ระบบ​กระจาย​ด้วย​การ​ฉีด fault แล้ว​ตรวจ history; harness ใน​คอร์ส​นี้​คือ​รุ่น​ย่อ — ที่ จำลอง เครือข่าย​ขาด​ใน process เดียว, ขับ​ด้วย seed คงที่, ตัด​จบ​ด้วย hard timeout แล้ว assert สมบัติ​แบบ​กระจาย​ที่ borrow checker มอง​ไม่​เห็น พร้อม linearizability checker ที่​ทำ​หน้าที่​เป็น​ออราเคิล​อีก​ตัว บอก​ได้​ว่า history ที่​บันทึก​มา​นั้น เรียง​เป็น​ลำดับ​เชิง​เส้น​ที่​ถูกต้อง​ได้​ไหม และ​จบ​ด้วย​ความ​จริง​ที่​ขม​ที่สุด​ของ​บท​นี้: ผ่าน 500 seed ไม่ใช่​บท​พิสูจน์

📦 kaen-kvstore

คอร์ส​นี้ ต่อยอด repo kaen-kvstore จาก #22 (code ตัวอย่าง​กำลัง​จัด​ทำ) — บท​นี้​สร้าง เครื่องมือ​ทดสอบ ที่​บท 8 (capstone) จะ​ใช้​ขับ cluster kaen-kvstore หลาย replica จริง harness, driver property test และ linearizability checker ใน​บท​นี้​คือ ออราเคิล ที่​พิสูจน์​สอง​สมบัติ​ของ capstone (“ไม่มี acked write หาย​ใต้ W+R>N” และ “replica ลู่​เข้า​หลัง heal”) — สมบัติ​ที่ demo happy-path ยืนยัน​ไม่​ได้ ต้อง​ใช้ harness ฉีด fault เท่านั้น ทั้งหมด Rust std ล้วน: splitmix64 เขียน​เอง + std::sync::mpsc + BTreeMapไม่มี proptest, ไม่มี tokio, ไม่มี serde

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

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

ย้อน​กลับ​ไป​ที่​เส้น scope ของ​คอร์ส: borrow checker ของ Rust การันตี ใน​เครื่อง​เดียว ว่า​ไม่มี data race, ไม่มี use-after-free — แต่​มัน มอง​ไม่​เห็น การ​สลับ​ลำดับ​ข้อความ, partition, หรือ clock skew เพราะ​สิ่ง​เหล่า​นั้น​เกิด ระหว่าง process ไม่ใช่ ใน process code replication ที่ subtle-wrong compile ผ่าน zero-warnings และ​เดโม happy-path สวยได้สบายๆ

สมบัติ​แบบ​กระจาย​จึง​ต้อง​มี ออราเคิล​ตัว​อื่น มา​ยืนยัน และ​ออราเคิล​นั้น​คือ harness ที่ ฉีด fault เข้าไป​เอง​อย่าง​จงใจ แล้ว​ตรวจ​ผลลัพธ์ — นี่​คือ​แก่น​ของ​ระเบียบ​วิธี Jepsen ของ Kyle Kingsbury: อย่า​รอ​ให้ partition เกิด​เอง​ใน​โปร​ดัก​ชัน จง สร้าง มัน​ขึ้น​มา​ใน​ที่​ควบคุม​ได้ แล้ว​ดู​ว่า​ระบบ​ยัง​รักษา​สมบัติ​ที่​สัญญา​ไว้​หรือ​ไม่

แต่ Jepsen จริง​รัน​บน​หลาย​เครื่อง​ด้วย wall-clock — เรา​ย่อ​มัน​ลง​เป็น single-scheduler ใน process เดียว เพื่อ​ให้​ได้​สิ่ง​ที่​มี​ค่าที่สุด​ใน​การ​ทดสอบ​ระบบ​กระจาย: ดี​เท​อร์มิ​นิส​ซึม ถ้า harness สุ่ม​ด้วย​เวลา​จริง​หรือ​ด้วย​ลำดับ thread ที่ OS จัด รัน​แดง​ครั้ง​หนึ่ง​แล้ว​รัน​ซ้ำ​ไม่​ได้ — bug report ที่ reproduce ไม่​ได้​ก็​ไร้​ค่า วินัย​ของ​บท​นี้​คือ ทุก แหล่ง​สุ่ม​มา​จาก splitmix64splitmix64PRNG ตัว​เล็ก​เขียน​เอง seed ได้ ผล​ซ้ำ​ได้ 100% — แทน proptest/quickcheck ใน​แซนด์บ็อกซ์ std ล้วน ที่ seed คงที่ และ ทุก loop ที่​อาจ​ค้าง​ถูก​ตัด​ด้วย budget

เริ่ม​ที่​เครื่องมือ​พื้นฐาน — PRNG ที่ seed ได้​กับ property driver ตัว​เล็ก for_all จะ​รัน property บน case-seed หลาย​พัน​ตัว​ที่ ดึง​จาก master stream และ​เมื่อ​เจอ counterexample ตัว​แรก มัน​คืน seed ที่​ทำให้​พัง กลับ​มา นั่น​คือ “poor-man’s shrink” — handle เดียว​ที่ reproduce เคส​นั้น​ซ้ำ​ได้​ทันที:

struct Rng {
state: u64,
}
impl Rng {
fn new(seed: u64) -> Self {
Self { state: seed }
}
fn next_u64(&mut self) -> u64 {
self.state = self.state.wrapping_add(0x9E3779B97F4A7C15);
let mut z = self.state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
z ^ (z >> 31)
}
fn below(&mut self, n: u64) -> u64 {
self.next_u64() % n
}
fn unit(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
}
}
// Ok(passed) หรือ Err(reproducing_seed) เมื่อเจอ counterexample ตัวแรก
fn for_all<F: Fn(u64) -> bool>(master: u64, cases: u64, prop: F) -> Result<u64, u64> {
let mut seeder = Rng::new(master);
for _ in 0..cases {
let seed = seeder.next_u64();
if !prop(seed) {
return Err(seed);
}
}
Ok(cases)
}

mixing ทุก​จุด​ใช้ wrapping_add/wrapping_mul เสมอ — ค่า​คงที่​ของ Vigna ล้น​แน่นอน ถ้า​ใช้ +/* ธรรมดา​มัน​จะ panic ทันที​ใน debug build เพื่อ​ให้​เห็น​ว่า driver พูด​จริง เรา​เดโม​สาม​อย่าง: ดี​เท​อร์มิ​นิส​ซึม (new(42) ให้ stream เดียวกันเป๊ะ, new(43) เบน​ออก), property จริง​ที่​ผ่าน แล้ว property ที่ จงใจ​ให้​เท็จ (พัง​เมื่อ below(1000) ตก​ที่ 500) เพื่อ​โชว์​ว่า Err คืน seed ที่ reproduce ได้​จริง:

fn main() {
let (mut a, mut b) = (Rng::new(42), Rng::new(42));
let det = (0..8).all(|_| a.next_u64() == b.next_u64());
let diff = Rng::new(43).next_u64() != Rng::new(42).next_u64();
println!("determinism: new(42)==new(42) -> {det}; new(43)!=new(42) -> {diff}");
assert!(det && diff, "PRNG must be seed-deterministic");
let mut mr = Rng::new(0xDEAD_BEEFu64);
let mean = (0..5000).map(|_| mr.unit()).sum::<f64>() / 5000.0;
println!("mean of 5000 unit() draws (master=0xDEADBEEF) = {mean:.4}");
let master = 0xDEAD_BEEFu64;
let cases = 5000u64;
// property จริง: below(n) < n ทุกครั้ง
match for_all(master, cases, |seed| {
let mut rr = Rng::new(seed);
let n = 1 + rr.below(1000);
rr.below(n) < n
}) {
Ok(k) => println!("range property below(n)<n : {k}/{cases} PASS"),
Err(s) => println!("range property FAILED, reproduce with Rng::new({s})"),
}
// property ที่จงใจให้เท็จ: พังเมื่อ below(1000)==500
match for_all(master, u64::MAX, |seed| {
let mut rr = Rng::new(seed);
rr.below(1000) != 500
}) {
Ok(_) => println!("false property unexpectedly held"),
Err(s) => {
let mut chk = Rng::new(s);
let got = chk.below(1000);
println!("false property (below(1000)==500): reproduce with Rng::new({s})");
println!(" cross-check: Rng::new({s}).below(1000) = {got}");
assert_eq!(got, 500);
}
}
}

รัน​จริง​ได้​ตาม​นี้:

determinism: new(42)==new(42) -> true; new(43)!=new(42) -> true
mean of 5000 unit() draws (master=0xDEADBEEF) = 0.5048
range property below(n)<n : 5000/5000 PASS
false property (below(1000)==500): reproduce with Rng::new(12774923733135906520)
cross-check: Rng::new(12774923733135906520).below(1000) = 500

บรรทัด​สำคัญ​คือ​บรรทัด​สุดท้าย: property เท็จ​พัง​ที่ case-seed 12774923733135906520 และ cross-check ยืนยัน​ว่า Rng::new(12774923733135906520).below(1000) ให้ 500 เป๊ะ — เอา seed ตัว​นี้​ไป​ใส่ Rng::new(...) เมื่อไร​ก็ reproduce เคส​ที่​พัง​ได้​ทันที นี่​คือ​เหตุผล​ว่า​ทำไม​เรา​ต้อง พิมพ์ seed ออก​มา เสมอ

หัวใจ​ของ​บท​คือ harness ที่​ขับ 5 replica แบบ LWW-RegisterLWW-Registerรี​จิส​เตอร์ค่า​เดียว​ที่ timestamp สูงสุด​ชนะ — ทิ้ง write ที่ concurrent (lossy โดย design) ด้วย W=R=3 (ดังนั้น W+R=6>5) เรา​แบ่ง cluster เป็น​สอง​ครึ่ง {0,1} กับ {2,3,4} แล้ว​เดิน​สี่​เฟส: (1) เขียน​ตอน​เชื่อม​กัน​ปกติ, (2) ฉีด partition แล้ว​เขียน​บน ทั้ง​สอง​ฝั่ง, (3) heal, (4) รัน anti-entropy จนถึง fixpoint — โดย​มี round budget เป็น hard timeout

store แต่ละ replica เป็น BTreeMap<u64, Lww> ไม่ใช่ HashMapRandomState ของ HashMap สุ่ม seed ต่อ process ลำดับ iteration จะ​ต่าง​ทุก​ครั้ง ทำลาย​ดี​เท​อร์มิ​นิส​ซึม​ทันที การ merge ของ LWW คือ “ค่าที่ (ts, node) ใหญ่​สุด​ชนะ” ซึ่ง​เป็น total order deterministic:

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Lww {
ts: u64,
node: u64,
val: u64,
}
impl Lww {
fn merge(self, other: Lww, buggy: bool) -> Lww {
if buggy {
// ❌ version ดิบ: บวก val => ไม่ idempotent => merge(a,a) != a
// => anti-entropy ไม่มีวันถึง fixpoint compile ผ่าน แต่ค้าง
Lww {
ts: self.ts.max(other.ts),
node: self.node.max(other.node),
val: self.val.wrapping_add(other.val),
}
} else if (other.ts, other.node) > (self.ts, self.node) {
other
} else {
self
}
}
}

buggy คือ​กับดัก​ที่​ตั้ง​ไว้​พิสูจน์​ว่า timeout ทำงาน​จริง: merge ที่​บวก val เป็น commutative และ associative แต่ ไม่ idempotentmerge(a, a) ให้ 2·val ไม่ใช่ a ผล​คือ full-mesh anti-entropy ไม่มี​วัน​หยุด​นิ่ง val โต​ขึ้น​ทุกรอบ compiler จับ deadlock/ไม่​ลู่​เข้า​แบบ​นี้ ไม่​ได้​เลย — จับ​ได้​ทาง​เดียว​คือ round budget

anti-entropy วน full-mesh: ทุก replica merge จาก snapshot ของ​ทุก replica อื่น จนกว่า​รอบ​หนึ่ง​จะ​ไม่มี​อะไร​เปลี่ยน (fixpoint) หรือ​หมด budget คืน Result<usize, usize>Ok(rounds) เมื่อ​ลู่​เข้า, Err(budget) เมื่อ timeout:

type Store = BTreeMap<u64, Lww>;
fn anti_entropy(replicas: &mut [Store], buggy: bool, max_rounds: usize) -> Result<usize, usize> {
for round in 1..=max_rounds {
let snapshot = replicas.to_vec();
let mut changed = false;
for i in 0..replicas.len() {
for j in 0..replicas.len() {
if i == j {
continue;
}
for (&k, &v) in &snapshot[j] {
match replicas[i].get(&k).copied() {
None => {
replicas[i].insert(k, v);
changed = true;
}
Some(cur) => {
let merged = cur.merge(v, buggy);
if merged != cur {
replicas[i].insert(k, merged);
changed = true;
}
}
}
}
}
}
if !changed {
return Ok(round);
}
}
Err(max_rounds) // hard timeout: ไม่ลู่เข้าในงบรอบที่ตั้งไว้
}

ระหว่าง partition, coordinator เขียน​ได้​เฉพาะ replica ใน ครึ่ง​เดียวกัน — write จาก​ฝั่ง A ถึง​แค่ {0,1} = 2 node < W=3 จึง ไม่ ack (อาจ​หาย​ก็ได้ ไม่​การันตี), ส่วน write จาก​ฝั่ง B ถึง {2,3,4} = 3 node = W จึง ack และ ต้อง รอด​หลัง heal นี่​คือ​แก่น​ของ​สมบัติ durability เรา​บังคับ​ให้​เกิด conflict บน key 0 ทุก seed (ฝั่ง A เขียน​ค่า​หนึ่ง, ฝั่ง B เขียน​อีก​ค่า​ด้วย ts สูง​กว่า) เพื่อ​ให้ split-brain เกิด จริง ทุก​ครั้ง แล้ว assert สอง​สมบัติ:

  • convergence — หลัง heal ทุก replica เท่า​กัน​เป๊ะ (byte-identical ผ่าน BTreeMap ordering)
  • durability — ไม่มี acked write หาย: ts ที่​ลู่​เข้า >= ts สูงสุด​ที่ ack ต่อ key เพราะ write ที่ ack แล้ว​ถึง >=W replica และ LWW ทับ​ได้​เฉพาะ​ด้วย ts ที่​สูง​กว่า
--- partition-inject harness (base seed=0xC0FFEE, 500 trials) ---
split-brain BEFORE heal : 500/500 (non-vacuous: every seed partitioned)
converged AFTER heal : 500/500
durable (no acked lost) : 500/500
anti-entropy rounds to fixpoint: min=2 max=2
negative control (buggy non-idempotent merge): TIMEOUT fired after 50 rounds
500/500 seeds passing is BOUNDED CHECKING, not a proof (FLP 1985).

อ่านที​ละ​บรรทัด: 500/500 split-brain ก่อน heal คือ non-vacuity — ทุก seed แตก​เป็น​สอง​ฝั่ง​ที่​เห็น​ค่า​ต่าง​กัน​จริง ไม่ใช่​ผ่าน​เพราะ​ไม่มี​อะไร​เกิด; 500/500 converged และ 500/500 durable คือ​สอง​สมบัติ​ที่​ผ่าน​ทุก seed; rounds min=2 max=2 — semilattice ที่​ถูกต้อง​บน full-mesh ลู่​เข้า​ใน 2 รอบ​เสมอ; และ​บรรทัด TIMEOUT fired after 50 rounds คือ negative control — merge ดิบ​ที่​ไม่ idempotent ชน budget พอดี พิสูจน์​ว่า hard timeout ไม่ใช่​ของ​ประดับ ถ้า​เรา​ไม่​ตั้ง budget โปรแกรม​นี้​จะ​วน​ไม่​จบ​และ compiler ไม่​เตือน​อะไร​เลย

sequenceDiagram
    participant A as ฝั่ง A {0,1}
    participant B as ฝั่ง B {2,3,4}
    Note over A,B: เฟส 1 — เชื่อมกัน: write ack ครบ (ถึงทั้ง 5 replica)
    Note over A,B: 🧱 ฉีด partition (ตัดข้อความข้ามครึ่ง)
    Note over A: เขียน key0 (ถึง 2 node < W ⇒ ไม่ ack)
    Note over B: เขียน key0 ts สูงกว่า (ถึง 3 node = W ⇒ ack)
    Note over A,B: split-brain: A ≠ B (500/500 seed)
    Note over A,B: 🔗 heal + anti-entropy (budget 50 รอบ)
    Note over A,B: ลู่เข้าใน 2 รอบ ⇒ ทุก replica เท่ากัน, acked write ของ B รอด

คำ​บรรยาย​ภาพ: single-scheduler + seed คงที่ + round budget — harness ที่ “ไม่” ค้าง​และ “พูด​จริง”: เขียน​สอง​ฝั่ง​ตอน partition, ฝั่ง B ที่​ถึง quorum ได้ ack แล้ว​ต้อง​รอด​หลัง heal, ทุก replica ลู่​เข้า​ค่า​เดียว​ใน 2 รอบ

negative control คือ​หัวใจ — harness ที่​พิมพ์ PASS เสมอ​ไร้​ค่า

ทุก assertion ใน​บท​นี้​มา​คู่​กับ​การ​ทำให้​มัน พัง​จริง ได้: (1) merge ที่​ไม่ idempotent → TIMEOUT after 50 rounds (ถ้า budget หาย​ไป harness จะ​ค้าง compiler เงียบ); (2) merge ที่​เก็บ ts ต่ำ​กว่า → durability assert fail (ACKED WRITE LOST); (3) เปลี่ยน BTreeMap เป็น HashMap → รัน​ซ้ำ​ได้​ผล​ต่าง​เพราะ RandomState; (4) split-brain 500/500 คือ non-vacuity ที่​กัน​ไม่​ให้ convergence “ผ่าน” เพราะ​ไม่มี​อะไร​ให้​ลู่​เข้า​ตั้งแต่​แรก ถ้า​คุณ​เขียน harness แล้ว​มัน ไม่​เคย แดง​เลย​ไม่​ว่า​จะ​ใส่ bug อะไร แปล​ว่า​มัน​ไม่​ได้​ตรวจ​อะไร

linearizability checker: ออราเคิล​ที่​บอกว่า history “เรียง​เป็น​เส้น​ได้​ไหม”

หัวข้อ​ที่​มีชื่อ​ว่า “linearizability checker: ออราเคิล​ที่​บอกว่า history “เรียง​เป็น​เส้น​ได้​ไหม””

harness ตรวจ convergence ได้ แต่​ยัง​มี​สมบัติ​ที่​แรง​กว่า​และ store แบบ eventual-consistency ของ​เรา ไม่ การันตี นั่น​คือ linearizabilitylinearizabilityมี​ลำดับ​รวม​หนึ่ง​ที่​เคารพ​เวลา​จริง​และ​ถูกต้อง​ตาม object; quorum+LWW 'ไม่' การันตี​ข้อ​นี้: มี ลำดับ​รวม​หนึ่ง​เดียว ที่ (ก) เคารพ​เวลา​จริง — ถ้า op หนึ่ง​จบ​ก่อน​อีก op เริ่ม มัน​ต้อง​มา​ก่อน​ใน​ลำดับ — และ (ข) ถูกต้อง​ตาม object (read คืน​ค่าที่​เขียน​ล่าสุด​ใน​ลำดับ​นั้น) หรือ​ไม่

Herlihy กับ Wing (1990) นิยาม​มัน​ไว้ และ​วิธี​ตรวจ​แบบ backtracking ของ Wing-Gong ก็​ตรง​ไป​ตรง​มา: ลอง “ทำให้​เป็น​เส้น” op ที่ ไป​ต่อ​ได้ (ไม่มี op ไหน​ที่​ต้อง​มา​ก่อน​มัน — เงื่อนไข​คือ op.call <= min(ret ของ op ที่ยังไม่ใช้)), เอา​ไป apply กับ register model แบบ​ลำดับ, recurse, ถ้า​ตัน​ก็​ถอย​กลับ:

#[derive(Clone, Copy)]
struct Op {
is_write: bool,
arg: u64, // ค่าที่เขียน หรือค่าที่ read คืนมา
call: u64, // เวลาจริงตอนเรียก
ret: u64, // เวลาจริงตอนตอบ (ret > call)
}
fn search(ops: &[Op], used: &mut [bool], remaining: usize, state: u64) -> bool {
if remaining == 0 {
return true;
}
let min_ret = ops
.iter()
.enumerate()
.filter(|(i, _)| !used[*i])
.map(|(_, o)| o.ret)
.min()
.unwrap();
for i in 0..ops.len() {
if used[i] {
continue;
}
let o = ops[i];
if o.call > min_ret {
continue; // มี op ที่จบก่อน o เริ่ม ⇒ o เป็นตัวถัดไปไม่ได้
}
let new_state = if o.is_write {
o.arg
} else {
if o.arg != state {
continue; // read ผิดกฎภายใต้ลำดับนี้ ⇒ ลอง op อื่น
}
state
};
used[i] = true;
if search(ops, used, remaining - 1, new_state) {
used[i] = false;
return true;
}
used[i] = false;
}
false
}

ทดสอบ3 history บน register ที่​เริ่ม​ที่ 0: H1 เขียน 1 เสร็จ​แล้ว​อ่าน​ทีหลัง​ได้ 1 (เรียง​เป็น​เส้น​ได้); H2 เขียน 1 เสร็จ​ที่​เวลา 10 แต่ read ที่ เริ่ม​หลัง​จาก​นั้น (เวลา 20) กลับ​คืน 0 — นี่​คือ anomaly ของ quorum+LWW แบบ eventual-consistency เป๊ะๆ เรียง​เป็น​เส้น​ไม่​ได้; H3 มี​สอง​เขียน ที่​ซ้อน​เวลา​กัน (concurrent) แล้ว read ตรง​กัน​ที่ 2 (มี​ลำดับ w1,w2,r2,r2 ที่​ถูก​กฎ):

H1 (write then read sees it) -> true
H2 (stale read = EC quorum anomaly) -> false
H3 (concurrent writes, reads agree) -> true
checker agrees: true / false / true.
Running it on the EC quorum+LWW history (H2) returns FALSE by design.

H2 -> false คือ​ประเด็น​ทั้งหมด: store ของ​เรา — quorum + LWW + read-repair — ให้ freshness ใน​เชิง​มี​ขอบเขต staleness (บท 3) แต่ ไม่​การันตี linearizability read ที่ concurrent กัน​เห็น​ค่า​เดิน​ถอย​หลัง​ได้ checker นี้​จึง​เป็น​ออราเคิล​ที่ ทำ​เครื่องหมาย​เส้น scope ให้​ชัด: จุด​ที่ linearizable-txn / consensus เริ่มต้น คือ​จุด​ที่ subset ที่​เรา​ลง code รัน​จริง​จบ​ลง

round budget คือ timeout ใน​ตรรกะ​ที่​ทดสอบ แต่​บท 8 จะมี std::net จริง และ socket ที่​ปลายทาง​ถูก partition จะ block ตลอด​กาล timeout ตัว​ที่​สอง​จึง​เป็น wall-clock — recv_timeout — ที่​แปลง block ชั่ว​นิรันดร์​ให้​เป็น error ที่​กู้​คืน​ได้ ทันที ไม่ใช่​หลัง​จาก​ผ่าน​ไป​หนึ่ง​ชั่วโมง:

use std::sync::mpsc;
use std::time::{Duration, Instant};
let (_tx, rx) = mpsc::channel::<u64>();
let start = Instant::now();
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(v) => println!("got {v}"),
Err(_) => println!("partitioned peer: recv_timeout fired after {:?}", start.elapsed()),
}

_tx ต้อง​อยู่​รอด (ไม่​ถูก drop) มิ​ฉะนั้น channel จะ disconnect แล้ว recv_timeout คืน error ทันที ด้วย​เหตุผล​ผิด (Disconnected ไม่ใช่ Timeout) — เรา​ต้องการ​ให้​มัน รอ​ครบ 100ms แล้ว​ค่อย timeout เพื่อ​จำลอง peer ที่​เงียบ:

partitioned peer: recv_timeout fired after 101.589374ms (no forever-block)
reachable peer answered: 7
wall-clock deadlines live at the real-net boundary ONLY, never inside logic-under-test.

(ตัวเลข 101.5ms เป็น wall-clock จึง​ไม่ deterministic — เป็น​ข้อ​ยกเว้น​ที่​ตั้งใจ: เวลา​จริง​อยู่​ที่ ขอบ เครือข่าย​เท่านั้น) กฎ​เหล็ก​คือ wall-clock deadline อยู่​ที่ boundary ของ real-net เท่านั้น ห้าม​หลุด​เข้าไป​ใน​ตรรกะ​ที่​ทดสอบ เพราะ​มัน​จะ​พา​ความ non-deterministic กลับ​มา​ทำลาย reproducibility ทั้งหมด — ใน​ตรรกะ​ใช้ round budget ที่​นับ​เป็น​จำนวน​รอบ ไม่ใช่​นับ​เป็น​วินาที

honesty spine — บท​นี้​ยึด 3 เส้น (A, B, C)

A — Rust พิสูจน์ “ใน​เครื่อง​เดียว” ไม่ใช่ “แบบ​กระจาย” และ harness คือ​การ​ตรวจ​แบบ​มี​ขอบเขต: ผ่าน 500/500 seed ไม่ใช่​บท​พิสูจน์ (FLP 1985) — มัน​สำรวจ​เฉพาะ interleaving ที่ splitmix64 เดิน​ไป​ถึง impl ที่ subtle-wrong ผ่าน 500 seed แล้ว​พัง​บน schedule ที่ 501 ได้สบายๆ harness เป็น ออราเคิล​ที่​ดี​ที่สุด​ที่ compiler เป็น​ให้​ไม่​ได้ แต่​มัน​คือ bounded checking ไม่ใช่ proof

B — เส้น scope: นี่แหละ คือ​เหตุผล​ที่ consensus/Raft correctness เป็น ไดอะแกรม​เท่านั้น ในบท 8 — quorum arithmetic เอ็น​นิว​เมอเรต​ครบ​ทุก subset ได้ (บท 3 ทำ​ไป​แล้ว: 22M คู่) แต่ state space ของ partition × reorder × clock-skew ของ Raft ระเบิด​จน ตรวจ​ครบ​ไม่​ได้ Fischer-Lynch-Paterson (1985) พิสูจน์​ว่า​ไม่มี consensus แบบ asynchronous ที่​การันตี​ทั้ง terminate และ​ถูกต้อง​ต่อ adversarial scheduler ที่​มี node เจ๊ง​แม้แต่​ตัว​เดียว — green test บน Raft จึง​เป็น​คำ​กล่าว​อ้าง correctness ที่ เท็จ

C — ดี​เท​อร์มิ​นิส​ซึม​คือ​วินัย: seed คงที่​พิมพ์​ออก​มา​เป็น repro handle (12774923733135906520), single-scheduler, BTreeMap ไม่ใช่ HashMap (RandomState สุ่ม​ลำดับ​ต่อ process), และ ทุก loop ที่​อาจ​ค้าง​มี hard budget มิ​ฉะนั้น harness จะ โกหก (รัน​แดง​ซ้ำ​ไม่​ได้) หรือ ค้าง (compiler จับ deadlock ไม่​ได้ — negative control พิสูจน์​ให้​เห็น​ว่า budget ทำงาน​จริง)

บท​นี้​สร้าง ออราเคิล ของ​ทั้ง​คอร์ส: for_all driver ที่ seed ด้วย splitmix64splitmix64PRNG ตัว​เล็ก​เขียน​เอง seed ได้ ผล​ซ้ำ​ได้ 100% — แทน proptest/quickcheck ใน​แซนด์บ็อกซ์ std ล้วน และ​คืน seed สำหรับ reproduce เมื่อ property พัง (12774923733135906520); harness ฉีด partitionpartitionเครือข่าย​ขาด: ข้าม​กลุ่ม​ส่ง​ข้อความ​ไม่​ถึงกัน — ต้นเหตุ​ของ split-brain ที่ harness ฉีด​เข้าไป แบบ single-scheduler ที่​เขียน​สอง​ฝั่ง​ตอน​เครือข่าย​ขาด, heal, รัน anti-entropy ใต้ hard round budget แล้ว assert convergence + durability ผ่าน 500/500 seed (พร้อม non-vacuity 500/500 split-brain และ negative control ที่​ยิง TIMEOUT after 50 rounds จาก merge ที่​ไม่ idempotent); และ linearizability checker แบบ Wing-Gong ที่​ตอบ true/false/true — ทำ​เครื่องหมาย​ว่า store แบบ eventual-consistency ของ​เรา ไม่ linearizable โดย design (H2 -> false) ทุก snippet compile zero-warnings บน Rust 1.97.1 / std ล้วน และ​รัน​จบ​ใต้ timeout 60 ไม่มี​ค้าง รัน​ซ้ำ byte-identical

และ​ความ​จริง​ที่​บท​นี้​ยืนยัน​คือ: ผ่าน 500 seed คือ​การ​ตรวจ​แบบ​มี​ขอบเขต ไม่ใช่​บท​พิสูจน์ — นี่​คือ​เส้น​แบ่ง​ว่า​ทำไม​สิ่ง​ที่ property-test ได้ (quorum, CRDT, gossip) เรา​ลง code แต่ consensus/Raft ที่​ตรวจ​ครบ​ไม่​ได้ (FLP 1985) ต้อง​อยู่​ใน​กล่อง​ไดอะแกรม

บท 8 (capstone): ตอน​นี้​เรา​มี​ทั้ง​ชิ้น​ส่วน (บท 1–6) และ​เครื่องมือ​พิสูจน์ (บท 7) ครบ​แล้ว บท​หน้า​เรา​ประกอบ​ทุก​อย่าง​เป็น cluster kaen-kvstore หลาย replica บน std::net — consistent-hash วาง key, quorum + read-repair, version vector ตรวจ conflict, CRDT merge แก้ conflict — แล้ว​ขับ​ด้วย harness ของ​บท​นี้​เพื่อ assert “ไม่มี acked write หาย” กับ “ลู่​เข้า​หลัง heal” ส่วน consensus/Raft จะ​อยู่​ใน​กล่อง​ไดอะแกรม​เท่านั้น ด้วย​เหตุผล FLP ที่​บท​นี้​เพิ่ง​วาง​ไว้


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

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

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

ข้อ 1 / 3

ทำไม harness ฉีด partition ถึงต้องมี hard timeout (round budget) เสมอ ทั้งที่ code compile ผ่าน zero-warnings?