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

ทดสอบ crash-consistency — จำลอง crash จริง​ด้วย cargo test

บท3 เรา​ประกาศ สัญญา เรื่อง durability ไว้​ชัด — write_all → sync_data → update index → ack — และ​บท4 ก็​ประกาศ​ว่า tombstone กับ compaction ไม่​ทำลาย​ความ​ถูกต้อง แต่​ทั้งหมด​นั้น​ยัง​เป็น​แค่ คำ​พูด compiler ของ Rust พิสูจน์​ให้​เรา​ได้​เยอะ​มาก — ไม่มี use-after-free, ไม่มี data race, Arc<Mutex<T>> ใช้​ถูก — แต่​มี​สิ่ง​หนึ่ง​ที่ borrow checker มอง​ไม่​เห็น​เลย: จะ​เกิด​อะไร​ขึ้น​ถ้า process ตาย​กลางคัน แล้ว​เปิด file ชุด​เดิม​ขึ้น​มา​ใหม่ ความ​ถูกต้อง​ข้อ​นี้​ไม่​ได้​อยู่​ใน​ระบบ​ชนิด (type system) มัน​อยู่​บน disk และ​วิธี​เดียว​ที่​จะ​รู้​คือ ทดสอบมันจริงๆ

บท​นี้​เปลี่ยน​คำ​สัญญา​เรื่อง durability ให้​กลาย​เป็น oracle อัตโนมัติ: เขียน​ข้อมูล → จำลอง crash → reopen → replayreplayการ​อ่าน log ตั้งแต่​ต้น​จน​จบ​เพื่อ​สร้าง​สถานะ​ขึ้น​ใหม่ log → ยืนยัน​ว่า​ทุก key/value ที่ ack แล้ว​รอด และ key ที่​ถูก tombstone หาย​ไป​จริง จุด​ที่​คม​ที่สุด​คือ​เรา​จะ​ไม่​จำลอง crash แบบ​ขอ​ไป​ที — เรา​จะ SIGKILL process ลูกจริงๆ กลาง​การ​เขียน แล้ว​พิสูจน์​ว่า​สถานะ​ที่​กู้​กลับ​มา​เป็น prefix ที่​ต่อ​เนื่อง

📦 kaen-kvstore

code ลงมือ​ของ​คอร์ส​นี้​อยู่​ใน repo kaen-kvstore (code ตัวอย่าง​กำลัง​จัด​ทำ) — ตลอด 8 บท​เรา​สร้าง key-value store บน​เครือข่าย​ที่​กู้​คืน​จาก crash ได้ หนึ่ง​ตัว ด้วย Rust std ล้วน (ไม่มี async/tokio, ไม่มี serde, และไม่มี tempfile — temp dir เขียน​เอง​จาก std::env::temp_dir + pid + nanos + AtomicU64) บท​นี้​ไม่​เพิ่ม feature ให้ store — แต่​สร้าง​ชุด​ทดสอบ crash-consistency ที่​ยืนยัน​สัญญา durability จาก​บท3–4 รวม​ถึง SIGKILL จริง​ที่​ไม่​รัน destructor เลย

Crash-consistencycrash-consistencyคุณสมบัติ​ที่​ทดสอบ​ได้: crash แล้ว reopen ต้อง​ได้​สถานะ​ที่​สอดคล้อง คือ​คุณสมบัติ​ที่​ว่า: ถ้า crash เกิด​ที่​จุด​ใด​ก็ตาม แล้ว​เปิด store ขึ้น​มา​ใหม่ สถานะ​ที่​มอง​เห็น​ต้อง​สอดคล้อง — ทุก write ที่ store ตอบ Ok (ack) ไป​แล้ว​ต้อง​ยัง​อยู่ และ​ไม่มี write ครึ่งๆ กลางๆ โผล่​มา​ให้​เห็น นี่​คือ​เหตุผล​ที่ crash-consistency เป็น​ด่าน​ที่ ทดสอบ​ด้วย unit test ได้​จริง — เพราะ durability เป็น​คุณสมบัติ​ของ node เดียว (single-node): crash คือ​เหตุการณ์​ที่​จำลอง​ได้​บน​เครื่อง​เดียว เปิด file เดิม replay แล้ว​เทียบ​สถานะได้ตรงๆ (ต่าง​จาก​ความ​ถูกต้อง​ของ distributed consensus ที่​ต้อง​มี network partition, การ​สลับ​ลำดับ message, clock skew — จำลอง​ใน unit test ไม่​ได้ ซึ่ง​เป็น​เหตุผล​ที่ #23 จะ ถกเถียง เรื่อง replication แทนที่​จะ fsync-พิสูจน์​มัน)

compiler พิสูจน์​ความ​ปลอดภัย local — ไม่ใช่ crash-correctness

borrow checker รับประกัน​ว่า code ของ​คุณ​ไม่มี memory bug และ​ไม่มี data race ใน​หน่วย​ความ​จำ แต่​มัน มอง​ไม่​เห็น disk — ลำดับ write→fsync→ack จะ​ถูก​หรือ​ผิด, torn tail จะ​ถูก​ทิ้ง​หรือ​ถูก​อ่าน​ผิด, tombstone จะ replay ครบ​ไหม — เรื่อง​พวก​นี้ compiler เงียบ​สนิท test คือ oracle เดียว​ที่​ตัดสิน​ความ​ถูกต้อง​ระดับ crash ได้ บท​นี้​จึง​ไม่ใช่​บท​เสริม แต่​เป็น​บท​ที่​ทำให้​สัญญา​จาก​บท3–4 เชื่อถือ​ได้

ก่อน​เขียน crash test เรา​ต้อง​รู้จัก​โครงสร้าง test สอง​ชั้น​ของ Rust ตาม​ที่​หนังสือ The Rust Programming Language บท 11 §11.3 (“Test Organization”) วาง​ไว้ เพราะ crash test ของ​เรา​ต้อง​อยู่ ชั้น​ที่​ถูก:

  • unit test — เขียน​ไว้ ใน file เดียว​กับ code หลัง #[cfg(test)] mod tests module นี้ compile เฉพาะ​ตอน cargo test (ไม่​ติด​ไป​กับ binary ที่ ship จริง) และ เข้าถึง​ของ private ได้ เพราะ​มัน​เป็น module ลูก​ของ crate เดียวกัน — เหมาะ​กับ​ทดสอบ logic ภายใน​อย่าง remove ที่​ต้อง idempotent
  • integration test — อยู่​ใน directory tests/ แยก​ต่างหาก แต่ละ file ถูก compile เป็น crate แยก​กัน ที่ use เข้า​มา​ผ่าน public API เท่านั้น — มัน​คือ​แรง​กดดัน​ที่​ซื่อสัตย์​ต่อ API สาธารณะ​ของ KvStore: ถ้า open/set/get ไม่ pub ก็​เขียน integration test ไม่​ได้​เลย

crash test ของ​เรา​จะ​เป็น integration test — เพราะ​การ​จำลอง crash ที่แท้​จริง​คือ “ผู้​ใช้​จาก​ภายนอก​เปิด store, เขียน, พัง, แล้ว​เปิด​ใหม่” ซึ่ง​เห็น​แต่ public API เหมือน​ผู้​ใช้​จริง​ทุก​ประการ ส่วน logic ภายใน​เรา​ทดสอบ​ด้วย unit test สั้นๆ ใน file lib.rs:

#[cfg(test)]
mod tests {
use super::*;
#[test]
fn remove_is_idempotent() -> io::Result<()> {
let dir = unit_dir(); // temp dir เฉพาะ test นี้
let mut store = KvStore::open(&dir)?;
store.set(b"k", b"v")?;
assert!(store.remove(b"k")?); // ลบครั้งแรก: key มีอยู่ -> true
assert!(!store.remove(b"k")?); // ลบซ้ำ: หายไปแล้ว -> false
assert_eq!(store.get(b"k")?, None);
std::fs::remove_dir_all(&dir).ok();
Ok(())
}
}

remove คืน io::Result<bool>true เมื่อ key เคย​มี​อยู่ (จึง​เขียน tombstone) และ false เมื่อ​ไม่มี​อะไร​ให้​ลบ unit test นี้​เข้าถึง​ได้​ทั้ง KvStore และ​ตัว​ช่วย private อย่าง unit_dir() เพราะ​มัน​อยู่​ใน crate เดียวกัน — สิ่ง​ที่ integration test ทำ​ไม่​ได้

ก่อน​เขียน​ตัว​จริง ต้อง​ซื่อสัตย์​เรื่อง​ขอบเขต​ของ​มัน​ก่อน ไม่​งั้น​เรา​จะ​หลอก​ตัวเอง:

crash test เขียว​ไม่​ได้​แปล​ว่า durable บน​ฮาร์ดแวร์​จริง

crash test ที่​เรา​กำลัง​จะ​เขียน พิสูจน์ ได้​ว่า: ลำดับ write→fsync→ack กับ​ตรรกะ replay ประกอบ​สถานะ​ที่ มอง​เห็น ขึ้น​มา​ได้​เหมือน​เดิม​ทุก byte หลัง process ตาย

แต่​มันพิสูจน์​ไม่​ได้ว่า fsync ผลัก byte ลง​จาน​แม่เหล็ก​จริง — เพราะ page cache ของ OS อยู่​รอด​การ​ตาย​ของ process ถ้า​เรา SIGKILL process บน​เครื่อง​เดียวกัน ข้อมูล​ที่​ยัง​ไม่ fsync ก็​ยัง​ค้าง​ใน page cache แล้ว process ใหม่​อ่าน​กลับ​มา​ได้​อยู่ดี → store ที่ ลืม เรียก fsync ก็จะ สอบ​ผ่าน บน​เครื่อง​นี้! สิ่ง​เดียว​ที่​พิสูจน์ durability จริง​คือ ตัด​ไฟ (power-loss) ซึ่ง​อยู่​นอก​วิสัย​ของ unit test

ทวน​จาก​บท3: sync_all บังคับ​ทั้ง data + metadata (fsync), sync_data บังคับ data + เฉพาะ metadata ที่​ผู้​อ่าน​ต้อง​ใช้ (fdatasync) — สำหรับ append-only log ตัว sync_data พอ​และ​ถูก​กว่า crash test ยืนยัน ตรรกะ ของ​ลำดับ ไม่ใช่ ฟิสิกส์ ของ disk — พูดตรงๆ ไว้​เสมอ

วิธี​ที่​ง่าย​ที่สุด​ใน​การ “จำลอง crash” คือ drop ตัว store ทิ้ง​โดย​ไม่​ปิด​อย่าง​สุภาพ แล้ว​เปิด​ใหม่ replay:

#[test]
fn survives_crash_without_clean_shutdown() -> std::io::Result<()> {
let dir = unique_temp_dir("crash");
const N: u32 = 1000;
{
let mut store = KvStore::open(&dir)?;
for i in 0..N {
store.set(format!("key-{i}").as_bytes(), format!("val-{i}").as_bytes())?;
}
for i in (0..N).step_by(10) { // ลบทุก key ที่หาร 10 ลงตัว
assert!(store.remove(format!("key-{i}").as_bytes())?);
}
drop(store); // "crash": ไม่มี shutdown สะอาด
}
{
let mut store = KvStore::open(&dir)?; // reopen -> replay จาก log
for i in 0..N {
let got = store.get(format!("key-{i}").as_bytes())?;
if i % 10 == 0 {
assert_eq!(got, None); // key ที่ tombstone แล้ว: หายจริง
} else {
assert_eq!(got.as_deref(), Some(format!("val-{i}").as_bytes()));
}
}
assert_eq!(store.len() as u32, N - N / 10); // 1000 - 100 = 900 key ที่รอด
}
std::fs::remove_dir_all(&dir).ok();
Ok(())
}

เขียน 1000 key ลบไป 100 (ทุก​ตัว​ที่​หาร 10 ลงตัว) drop แล้ว reopen — replay ต้อง​ได้ 900 key พร้อม value เดิม​เป๊ะ และ 100 key ที่ tombstone ต้อง​คืน None นี่​เป็น test ที่​ดี แต่​มัน​เป็น version ดิบ เพราะ drop(store) จะ valid ใน​ฐานะ crash จำลอง เฉพาะ​เมื่อ store ไม่มี buffer และ fsync ทุก write — ซึ่ง​ตรง​กับ store ของ​เรา ตอน​นี้

กับดัก: `drop` ที่​รัน destructor อาจ 'โกหก' ใน​บท​หน้า

นาที​ที่​บท7 เพิ่ม BufWriter เข้าไป​เพื่อ​รีด​สมรรถนะ ตัว Drop ของ BufWriter จะ flush buffer ให้​อัตโนมัติ​ตอน​ถูก drop — แปล​ว่า test แบบ drop(store) ข้าง​บน​จะ สอบ​ผ่าน แม้​กับ byte ที่ crash จริง​จะ​ทำ​หาย (เพราะ crash จริง​ไม่มี​ใคร flush buffer ให้) drop รัน destructor แต่ crash จริง​ไม่​รัน destructor test ที่พึ่ง drop อย่าง​เดียว​จึง​เป็น​ตาข่าย​ที่​มี​รู​โหว่​ตาม​เงื่อนไข — เรา​ต้อง​มี มาตรฐาน​ทองคำ ที่​ไม่​รัน destructor เลย

crash ที่ จริง คือ process ถูก​ฆ่า​ทันที​โดย​ไม่มี​โอกาส​เก็บกวาด — ไม่มี destructor, ไม่มี flush, ไม่มี Drop วิธี​จำลอง​ให้​ตรง​คือ: spawn process ลูก​ที่​เขียน​ไม่​หยุด แล้ว​ส่ง SIGKILL ใส่​มัน​กลางคัน บน Unix Child::kill() ส่ง SIGKILL ซึ่ง​ระบบ​ปฏิบัติการ​ฆ่า process ทิ้ง​ทันที ไม่มี destructor ใด​ได้​ทำงาน — นี่​คือ crash ของ​จริง ไม่ใช่​การ​จำลอง

process ลูก​คือ binary เล็กๆ ใน src/bin/crash_writer.rs — เขียน key-i → val-i วน​ไป​เรื่อยๆ ไม่มี​วัน​จบ​เอง:

// src/bin/crash_writer.rs — เขียนไม่หยุด, fsync ทุก write, ไม่เคยออกอย่างสะอาด
use kaen_kvstore::KvStore;
fn main() -> std::io::Result<()> {
let dir = std::env::args().nth(1).expect("usage: crash_writer <dir>");
let mut store = KvStore::open(std::path::Path::new(&dir))?;
let mut i: u64 = 0;
loop {
store.set(format!("key-{i}").as_bytes(), format!("val-{i}").as_bytes())?;
i += 1;
}
}

ฝั่ง test เรียก​มัน​ด้วย std::process::Command แล้ว kill ทิ้ง​หลัง​ปล่อย​ให้​เขียน​ไป​พัก​หนึ่ง จุด​ที่​ต้อง​รู้จัก​คือ env!("CARGO_BIN_EXE_crash_writer") — มาโคร​ที่ cargo เติม path เต็ม​ของ binary พี่น้อง​ให้​ตอน compile test เรา​จึง​ไม่​ต้อง hardcode target/debug/... เอง (ซึ่ง​จะ​พัง​ทันที​เมื่อ​เปลี่ยน target/profile) และ temp dir เรา​เขียน​เอง​จาก env::temp_dir + pid + nanos + AtomicU64 — ไม่​พึ่ง crate tempfile:

// tests/crash_consistency.rs — เป็น crate ของตัวเอง link เฉพาะ public API ของ kaen_kvstore
use kaen_kvstore::KvStore;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU64 = AtomicU64::new(0);
fn unique_temp_dir(tag: &str) -> PathBuf {
// ไม่ใช้ tempfile: pid + nanos + counter อะตอมมิก การันตี path ไม่ชนกัน
let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let mut dir = std::env::temp_dir();
dir.push(format!("kaen-kvstore-{tag}-{}-{nanos}-{n}", std::process::id()));
dir
}
#[test]
fn sigkill_midwrite_leaves_recoverable_prefix() -> std::io::Result<()> {
use std::process::Command;
use std::thread::sleep;
use std::time::Duration;
let dir = unique_temp_dir("sigkill");
std::fs::create_dir_all(&dir)?;
let exe = env!("CARGO_BIN_EXE_crash_writer"); // path ของ binary พี่น้อง จาก cargo
let mut child = Command::new(exe).arg(&dir).spawn()?;
sleep(Duration::from_millis(400)); // ปล่อยให้เขียนไปสักพัก
child.kill()?; // SIGKILL: ไม่มี destructor -> crash จริง
let _ = child.wait(); // เก็บศพ process ที่ถูกฆ่า
let mut store = KvStore::open(&dir)?; // reopen -> replay
let n = store.len() as u64;
assert!(n > 0);
for i in 0..n {
assert_eq!(
store.get(format!("key-{i}").as_bytes())?.as_deref(),
Some(format!("val-{i}").as_bytes()) // ทุก key ที่กู้ได้ value ถูกต้อง
);
}
assert_eq!(store.get(format!("key-{n}").as_bytes())?, None); // key ตัวถัดไปที่ยังไม่ทันจบ: ไม่มี
std::fs::remove_dir_all(&dir).ok();
Ok(())
}

หัวใจ​ของ assertion คือ: เพราะ crash_writer เขียน key ตาม​ลำดับ 0, 1, 2, … และ fsync ทุก​ตัว file ที่​เหลือ​หลัง​ถูก​ฆ่า​คือ prefix ต่อ​เนื่อง ของ record สมบูรณ์ [0, n) บวก torn tail ที่​อาจ​ค้าง​อยู่​ท้าย file ตอน reopen ตัว replay จะ index เฉพาะ record ที่​ครบ แล้ว ทิ้ง torn tail (จำ​จาก​บท3: read_exact เจอ UnexpectedEof กลาง record → break) ผล​คือ​ทุก key-0..key-{n-1} อยู่​ครบ​พร้อม value ถูกต้อง ส่วน key-n (ตัว​ที่​ยัง​เขียน​ไม่​จบ​ตอน​ถูก​ฆ่า) ต้อง ไม่มี — สถานะ​สอดคล้อง​เสมอ ไม่​ว่า​มีด​ตกลง​ตรง​ไหน

ทำความ​สะอาด temp dir อย่าง​ปลอดภัย — เจาะจง ไม่​ใช้ glob

ทุก test สร้าง dir เฉพาะ​ของ​ตัวเอง จาก pid + nanos + AtomicU64 แล้ว​เก็บกวาด​ด้วย std::fs::remove_dir_all(&dir).ok() ที่ เจาะจง path เดียว — ห้าม​ใช้ pattern แบบ rm -rf /tmp/kaen-* หรือ​ลบ​ด้วย glob เด็ดขาด เพราะ test อื่น​ที่​รัน​ขนาน​กัน​อาจ​ถือ dir ของ​มัน​อยู่ การ​ลบ​แบบ​กวาด​ทั้ง​กอง = เหยียบ​ข้อมูล​ของ test อื่น ส่วน .ok() ท้าย​สุด​จงใจ​กลืน error ตอน​ลบ เพื่อ​ไม่​ให้​ความ​ล้มเหลว​ของ​การ​เก็บกวาด​ไป​บดบัง assertion จริง​ที่​เป็น​เป้าหมาย​ของ test

sequenceDiagram
    participant W as crash_writer (process ลูก)
    participant D as file log บน disk
    participant T as test (process แม่)
    W->>D: set key-0..key-(n-1) (write_all + fsync ทุกตัว)
    T->>W: Child::kill() = SIGKILL
    Note over W: ถูกฆ่ากลางเขียน key-n<br/>ไม่มี destructor ทำงาน
    T->>D: KvStore::open() -> replay
    Note over T,D: index เฉพาะ record ที่ครบ [0,n)<br/>ทิ้ง torn tail (UnexpectedEof)
    T->>T: assert key-0..key-(n-1) ครบ + value ถูก
    T->>T: assert key-n = None

คำ​บรรยาย​ภาพ: การ​ทดสอบ crash-consistency ด้วย SIGKILL จริง — process ลูก​เขียน​แล้ว fsync ทีละ record จน​ถูก​ฆ่า​กลางคัน (destructor ไม่​ทำงาน) process แม่ reopen แล้ว replay ได้ prefix ต่อ​เนื่อง [0,n) พร้อม​ทิ้ง torn tail

ยัง​เหลือ​อีก​กรณี​ที่​ต้อง​ตอก​ให้​แน่น — crash ที่​เกิด กลาง​การ​เขียน record เดียว (ไม่ใช่​ระหว่าง record) เรา​จำลอง​มันตรงๆ ด้วย​การ​เขียน record สมบูรณ์​หนึ่ง​ตัว​ให้ durable ก่อน แล้ว ต่อ​ท้าย​ด้วย header ขยะ​ที่​อ้าง​ว่า​มี value ยาว แต่​ไม่มี byte ตาม​มา:

#[test]
fn torn_tail_after_durable_write_is_discarded() -> std::io::Result<()> {
use std::io::Write;
let dir = unique_temp_dir("torn");
{
let mut store = KvStore::open(&dir)?;
store.set(b"alpha", b"one")?; // record สมบูรณ์ + durable หนึ่งตัว
}
{
let path = dir.join("kvstore.log");
let mut f = std::fs::OpenOptions::new().append(true).open(&path)?;
f.write_all(&5u32.to_le_bytes())?; // key_len = 5
f.write_all(&9999u32.to_le_bytes())?; // val_len = 9999 (ไม่มีวันมาถึง)
f.write_all(b"beta")?; // tail ที่ขาดครึ่ง
f.sync_all()?;
}
{
let mut store = KvStore::open(&dir)?;
assert_eq!(store.get(b"alpha")?.as_deref(), Some(b"one".as_ref()));
assert_eq!(store.len(), 1); // ทิ้ง torn tail, record durable รอด
}
std::fs::remove_dir_all(&dir).ok();
Ok(())
}

replay เจอ header ที่​บอกว่า​จะ​มี value 9999 byte แต่ read_exact อ่าน​ได้​ไม่​ครบ → UnexpectedEofbreak → torn tail ถูก​ทิ้ง record alpha ที่ durable ก่อนหน้า​รอด และ len() == 1 นี่​คือ​คุณสมบัติ truncation-tolerant ที่ length prefix + read_exact มอบ​ให้ (ย้ำ​จาก​บท3: มัน ไม่ ทน silent bit-flip ซึ่ง​ต้อง​ใช้ CRC — งาน​ของ Bitcask/production)

รัน​ด้วย musl target (recipe เดิม​จาก​ทั้ง​คอร์ส เพราะ sandbox ไม่มี linker cc):

LLD=$(find ~/.rustup -name rust-lld | head -1)
RUSTFLAGS="-Clink-self-contained=yes -Clinker-flavor=ld.lld -Clinker=$LLD" \
cargo test --target x86_64-unknown-linux-musl

ผล​รัน​จริง​บน​เครื่อง​นี้:

Running unittests src/lib.rs
running 1 test
test tests::remove_is_idempotent ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
Running tests/crash_consistency.rs
running 3 tests
test torn_tail_after_durable_write_is_discarded ... ok
test sigkill_midwrite_leaves_recoverable_prefix ... ok
test survives_crash_without_clean_shutdown ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.79s

เขียว​ครบ — 1 unit + 3 integration รวม SIGKILL จริง​ที่​ใช้​เวลา​ราว 2.8 วินาที (ส่วน​ใหญ่​คือ 400 ms ที่​ปล่อย​ให้​ลูก​เขียน คูณ​จำนวน test ที่​มี child) สังเกต​ว่า crash_writer ปรากฏ​เป็น “unittests” ที่​รัน 0 test — เพราะ​มัน​เป็น binary ที่​ไม่มี #[test] ใน​ตัวเอง compile ผ่าน​ก็​พอ

บท​นี้​เรา​ไม่​เพิ่ม feature ให้ store แม้แต่​บรรทัด​เดียว — แต่​ทำ​สิ่ง​ที่​สำคัญ​กว่า: เปลี่ยน​คำ​สัญญา durability จาก​บท3–4 ให้​เป็น oracle ที่​รัน​ซ้ำ​ได้ เรา​รู้จัก​สอง​ชั้น​ของ test (unit หลัง #[cfg(test)] เข้าถึง private ได้ vs integration ใน tests/ ที่​กดดัน public API — Book ch11 §11.3); เห็น​ว่า drop(store) เป็น version ดิบ ที่​จะ​โกหก​ทันที​ที่​บท7 ใส่ BufWriter; และ​สร้าง มาตรฐาน​ทองคำ ด้วย SIGKILL จริง​ผ่าน std::process::Command + Child::kill() ที่​ไม่​รัน destructor เลย ค้นหา binary พี่น้อง​ด้วย env!("CARGO_BIN_EXE_...") และ​เก็บกวาด temp dir แบบ​เจาะจง remove_dir_all(&dir).ok() — ไม่มี glob และ​เหนือ​สิ่ง​อื่น​ใด เรา​ซื่อสัตย์​ว่า test เขียว​พิสูจน์ ตรรกะ ของ replay ไม่ใช่​ว่า fsync แตะ​จาน​จริง (page cache อยู่​รอด​การ​ตาย​ของ process — พิสูจน์ durability จริง​ต้อง​ตัด​ไฟ)

บท7 เรา​จะ​วัด​ราคา​ของ durability: ตอน​นี้​เรา​มั่นใจ​ว่า store ถูกต้อง แล้ว คำถาม​ถัด​ไป​คือ เร็ว​แค่​ไหน — เรา​จะ​เขียน benchmark harness เอง​ด้วย std::time::Instant (ไม่​ใช้ criterion) วัด latencylatencyเวลา​ต่อ1 operation รายงาน​เป็น percentile p50/p95/p99 เป็น percentile p50/p95/p99 และ throughput แล้ว​จะ​เห็น​ตัวเลข​ที่​ชวน​สะดุด: ทำไม throughput ถึง​ได้​แค่​หลัก​ร้อย​ต่อ​วินาที? คำ​ตอบ​คือ​ภาษี fsync ที่​เรา​จ่าย​ทุก write — และ​นั่น​คือ​สะพาน​สู่​บท​สุดท้าย


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

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

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

ข้อ 1 / 3

ตามหนังสือ The Rust Programming Language บท 11 §11.3 unit test กับ integration test ต่างกันอย่างไร และทำไม crash test ของเราจึงเป็น integration test?