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

OR-Set และ CRDT ใน​ฐานะ​ชนิด​ของ value ใน kaen-kvstore

บท 4 เรา​สร้าง CRDT แบบ state-based ที่​ลู่​เข้า​ได้​เอง​ด้วย​กฎ semilattice — G-Counter, PN-Counter และ LWW-Register — โดย​ไม่​ต้อง coordinate เลย ตราบ​ใด​ที่ merge เป็น join ที่ commutative + associative + idempotent ทุก​อย่าง​ใน​บท​นั้น​เป็น “ตัว​นับ” หรือ “ค่า​เดียว” ที่ โต​ทาง​เดียว ล้วนๆ ซึ่ง​เป็น​เหตุผล​ว่า​ทำไม​มัน​ง่าย

บท​นี้​เรา​ชน​กำแพง​ที่​ยาก​ที่สุด​ของ CRDT ตรงๆ: การ​ลบ — เซ็ต​ที่​ทั้ง add และ remove ได้ แล้ว​ยัง​ลู่​เข้า​ถูกต้อง คำถาม​คือ ถ้า node หนึ่ง​ลบ element ขณะ​ที่​อีก node เพิ่ม element เดิม​กลับ​พร้อม​กัน (concurrent) ผล​ควร​เป็น​อะไร คำ​ตอบ​ของ OR-Set คือ add-wins และ​กลไก​ที่​ทำให้​มัน​ถูกต้อง​คือ​หัวใจ​ของ​บท​นี้ จาก​นั้น​เรา​จะ​ทำ​สิ่ง​ที่​ทั้ง​คอร์ส​เดิน​มา​ถึง: จับ CRDT ยัด​เป็น​ชนิด​ของ value ใน kaen-kvstore เพื่อ​ให้การ “เขียน” กลาย​เป็นการ “merge” ไม่ใช่​ทับ — เป็น​เมล็ด​พันธุ์​ของ capstone บท 8

📦 kaen-kvstore

คอร์ส​นี้ ต่อยอด repo kaen-kvstore จาก #22 (code ตัวอย่าง​กำลัง​จัด​ทำ) — บท​นี้​พา CRDT ออก​จาก “โปรแกรม demo แยก​เดี่ยว” มา​เป็น ชนิด​ของ value ที่ store เก็บ​จริง: ค่าที่​เก็บ​คือ byte encode ของ​สถานะ CRDT ผ่าน wire แบบ length-prefixed ของ #22 (to_le_bytes/from_le_bytes, ไม่มี serde) แล้ว merge_put = decode → merge → encode ทำให้ “write = merge” ตาม​กฎ semilattice ของ​บท 4 — การ​ลู่​เข้า​จึง​พิสูจน์​ได้​ถึง​ระดับ byte ทุก​เลข​ด้าน​ล่าง​คือ output จริง​จาก Rust std ล้วน​ที่​รัน​ซ้ำ​ได้ byte-identical

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

ทุก snippet pin ที่ Rust stable 1.97.1 (ออก 2026-07-16) และ edition = “2024” [dependencies] ว่างเปล่า — ZERO external crate บท​นี้​แตะ std เพียง std::collections (BTreeMap, BTreeSet, HashMap) กับ integer to_le_bytes/from_le_bytes เท่านั้น — สถานะ CRDT หนุนหลัง​ด้วย BTreeMap/BTreeSet (เรียง​แล้ว) เสมอ เพื่อ​ให้ สถานะ​เท่า​กัน encode เป็น byte เท่า​กัน (ถ้า​ใช้ HashMap/HashSet ลำดับ iteration จะ​สุ่ม → byte ต่าง​ทั้ง​ที่​สถานะ​เดียวกัน = การ​ลู่​เข้า​จอม​ปลอม) ทุก​โปรแกรม compile zero-warnings และ​รัน​จริง

ลอง​ออกแบบ​เซ็ต​ที่​ลบ​ได้​แบบ​ตรง​ไป​ตรง​มา​ที่สุด — 2P-Set (two-phase set): เก็บ grow-only set สอง​ชุด ชุด added กับ​ชุด removed การ contains(e) = e ∈ added ∧ e ∉ removed merge ก็​แค่ union ทั้ง​สอง​ชุด กฎ semilattice ผ่าน​หมด (union คือ join) compile สวย ลู่​เข้า​ได้​จริง

แต่​มัน​มี bug เชิง​ความหมาย​ที่​ร้ายแรง — นี่​คือ ❌ version ดิบ: พอ element ตก​ไป​อยู่​ใน removed แล้ว มัน เพิ่ม​กลับ​ไม่​ได้​ตลอด​กาล เพราะ removed โต​ทาง​เดียว ไม่มี​วัน​เอา element ออก​จาก​มัน​ได้ ใน​โลก​จริง​นี่​คือ​หายนะ: ลูกค้า​ลบ “นม” ออก​จาก​ตะกร้า แล้ว​เปลี่ยนใจ​กด​เพิ่ม “นม” กลับ — 2P-Set จะ​ยืนยัน​หน้า​ตาย​ว่า “นม” ถูกลบ​ไป​แล้ว เพิ่ม​ไม่​ได้ พฤติกรรม​ที่​เรา อยาก ได้​คือ การ​เพิ่ม​ครั้ง​ใหม่​ควร​ลบล้าง​การ​ลบ​ครั้ง​เก่า — แต่ 2P-Set แยก “นม ตัว​เดิม​ที่​เคย​ลบ” ออก​จาก “นม ตัว​ใหม่​ที่​เพิ่ง​เพิ่ม” ไม่​ได้ เพราะ​มัน​จำ​แค่ ค่า ไม่​ได้​จำ ตัวตน​ของ​แต่ละ add

OR-Set: ทุก add มี tag ที่​ไม่​ซ้ำ, remove ลบ​ได้​เฉพาะ​ที่ “เห็น​แล้ว”

หัวข้อ​ที่​มีชื่อ​ว่า “OR-Set: ทุก add มี tag ที่​ไม่​ซ้ำ, remove ลบ​ได้​เฉพาะ​ที่ “เห็น​แล้ว””

ทาง​แก้​คือ OR-SetOR-Setเซ็ต add-wins: ทุก add ติด tag ไม่​ซ้ำ, remove ลบ​ได้​เฉพาะ tag ที่​เห็น​แล้ว → re-add ที่ concurrent รอด (Observed-Remove Set, Shapiro et al. 2011, Specification 12) ไอเดีย​กลาง: แยก​ตัวตน​ของ​แต่ละ add ออก​จาก​ค่า​ของ​มัน ทุก​ครั้ง​ที่ add(e) เรา​แนบ tag ที่​ไม่​ซ้ำ​ทั้ง​ระบบ ให้ element นั้น ส่วน remove(e) จะ tombstone เฉพาะ tag ที่ node นี้ เห็น​แล้ว เท่านั้น — นี่​คือ​ที่มา​ของ​ชื่อ observed-remove contains(e) เป็น​จริง​ก็​ต่อ​เมื่อ e มี add-tag อย่าง​น้อยหนึ่ง​อัน​ที่ ยัง​ไม่​ถูก tombstone

กลไก​นี้​ทำให้ re-add ที่ concurrent รอด: ตอน node B เพิ่ม element กลับ มัน​แนบ tag ใหม่ ที่ node A (ตัว​ที่​สั่ง​ลบ) ยัง ไม่​เคย​เห็น — remove ของ A จึง tombstone ไม่​ถึง tag ใหม่​ตัว​นี้ นี่​คือ add-wins

use std::collections::{BTreeMap, BTreeSet};
#[derive(Clone, Debug, PartialEq, Eq)]
struct OrSet {
adds: BTreeMap<Vec<u8>, BTreeSet<u64>>, // element -> tags ที่เคยถูก add
tombs: BTreeSet<u64>, // tags ที่ถูก remove (observed-remove)
}
impl OrSet {
fn new() -> Self {
Self { adds: BTreeMap::new(), tombs: BTreeSet::new() }
}
// add: แนบ tag ที่ไม่ซ้ำทั้งระบบให้ element
fn add(&mut self, elem: &[u8], tag: u64) {
self.adds.entry(elem.to_vec()).or_default().insert(tag);
}
// remove: tombstone เฉพาะ tag ที่ 'เห็นแล้ว' ณ ตอนนี้เท่านั้น
fn remove(&mut self, elem: &[u8]) {
if let Some(tags) = self.adds.get(elem) {
for &t in tags {
self.tombs.insert(t);
}
}
}
fn contains(&self, elem: &[u8]) -> bool {
match self.adds.get(elem) {
Some(tags) => tags.iter().any(|t| !self.tombs.contains(t)),
None => false,
}
}
// merge = union ของ add-tags (ต่อ element) ∪ union ของ tombstones
// => 2 grow-only-set join รวมกัน = comm/assoc/idempotent มาฟรี
fn merge(&self, other: &Self) -> Self {
let mut out = self.clone();
for (elem, tags) in &other.adds {
let e = out.adds.entry(elem.clone()).or_default();
for &t in tags {
e.insert(t);
}
}
for &t in &other.tombs {
out.tombs.insert(t);
}
out
}
fn elements(&self) -> Vec<Vec<u8>> {
self.adds.keys().filter(|k| self.contains(k)).cloned().collect()
}
}
tag ต้อง​ไม่​ซ้ำ 'ทั้ง​ระบบ' ไม่ใช่​ต่อ node

หัวใจความ​ถูกต้อง​อยู่​ที่ tag ต้อง unique ทั้ง cluster วิธี​มาตรฐาน​คือ​ประกอบ​จาก (node_id << 40) | local_counter — เอา id ของ node ขึ้น bit สูง ต่อ​ด้วย​ตัว​นับ monotonic ใน​เครื่อง ห้าม derive tag จาก ค่า ของ element หรือ​จาก wall-clock เด็ดขาด ถ้า2 node บังเอิญ​ใช้ tag เดียวกัน​สำหรับ add คนละ​ครั้ง การ remove ฝั่ง​หนึ่ง​จะ เงียบๆ ฆ่า add ที่ concurrent ของ​อีก​ฝั่ง​ทิ้ง — กฎ semilattice ยัง​ผ่าน​หมด (มัน​ยัง​ลู่​เข้า) แต่ ความหมาย ผิด นี่​คือ bug ประเภท​ที่ compiler กับ property test กฎ merge จับ​ไม่​ได้ ต้อง​ออกแบบ tag ให้​ถูก​ตั้งแต่​ต้น

เอา​สถานการณ์​ตะกร้า​สินค้า​มา​รัน​จริง — ทั้ง2 node เห็น apple@tag1 อยู่​ก่อน แล้ว A สั่ง​ลบ (tombstone tag1) ขณะ​ที่ B เพิ่ม apple กลับ​ด้วย tag2 ที่​ยัง​ไม่​เคย​เห็น (concurrent):

let tag1: u64 = (1u64 << 40) | 0;
let tag2: u64 = (2u64 << 40) | 0;
let mut a = OrSet::new();
let mut b = OrSet::new();
a.add(b"apple", tag1);
b.add(b"apple", tag1); // ทั้งคู่เห็น add เดิม
a.remove(b"apple"); // A tombstone tag1
b.add(b"apple", tag2); // B re-add ด้วย tag ใหม่ที่ยังไม่เห็น
let merged = a.merge(&b);
println!("concurrent add||remove -> contains(apple) = {} (add wins)", merged.contains(b"apple"));
// add แล้ว remove บน node เดียว => หายไปตามคาด
let mut c = OrSet::new();
c.add(b"pear", (1u64 << 40) | 5);
c.remove(b"pear");
println!("add then remove -> contains(pear) = {}", c.contains(b"pear"));

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

concurrent add||remove -> contains(apple) = true (add wins)
add then remove -> contains(pear) = false

หลัง merge สถานะ​คือ adds{apple:{tag1, tag2}} กับ tombs{tag1}contains(apple) ไล่ tag ของ apple เจอ​ว่า tag2 ไม่ อยู่​ใน tombstones จึง​คืน true: add ชนะ ตรง​ข้าม​กับ 2P-Set ดิบ​ที่​จะ​บอกว่า apple ถูกลบ​ถาวร ส่วน pear ที่ add แล้ว remove บน node เดียว (เห็น tag ตัวเอง​ครบ) ถูก tombstone หมด​จึง​คืน false ถูกต้อง

merge ของ OR-Set ไม่มี​อะไร​มาก​ไป​กว่า union ของ grow-only set สอง​ก้อน: union ของ add-tags (ทีละ element) ∪ union ของ tombstones set union เป็น join ของ semilattice อยู่​แล้ว — commutative, associative, idempotent — ดังนั้น OR-Set ที่​ประกอบ​จาก grow-only set สอง​ชุด​จึง สืบทอด สาม​กฎ​นี้​มา โดย​ไม่​ต้อง​พิสูจน์​ใหม่ นี่​คือ​ลีลา​การ​ออกแบบ CRDT ที่​สำคัญ: ประกอบ​ของ​ใหม่​จาก semilattice ที่​รู้​แล้ว​ว่า​ลู่​เข้า แทนที่​จะ​คิด merge law ขึ้น​เอง​แล้ว​เสี่ยง​พลาด

เรา​พิสูจน์​ด้วย property test เดิม​จาก​บท 4 — สุ่ม OR-Set สาม​ก้อน​ด้วย splitmix64 (seed 0x000F_5E70, 3000 เคส) แล้ว​ยืนยัน​ทั้ง​สาม​กฎ​ทุก​เคส:

let seed: u64 = 0x000F_5E70;
let mut rng = SplitMix64::new(seed);
let (mut checks, mut violations) = (0u64, 0u64);
for _ in 0..3000 {
let x = random_orset(&mut rng);
let y = random_orset(&mut rng);
let z = random_orset(&mut rng);
if x.merge(&y) != y.merge(&x) { violations += 1; } // commutative
checks += 1;
if x.merge(&y).merge(&z) != x.merge(&y.merge(&z)) { violations += 1; } // associative
checks += 1;
if x.merge(&x) != x { violations += 1; } // idempotent
checks += 1;
}

หมายเหตุ: random_orset(&mut rng) ไม่​ได้​กาง code body ไว้​ตรง​นี้ — มัน​คือ​คู่อนาล็อก​แบบ OR-Set ของ​ตัว​สุ่ม​จาก​บท 4 ที่​สุ่ม​บน element universe เล็ก (ad) กับ tag id 0–7 (จึง​ทำให้ tag ชน​กัน​ข้าม​ก้อน​บ่อย merge/tombstone interaction ไม่​เป็น​สุญญากาศ)

--- OR-Set semilattice laws (seed=0x000f5e70, 3000 cases) ---
commutative + associative + idempotent : checks=9000 violations=0
ALL OR-Set semilattice laws hold.

9000 checks, 0 violations — 3000 เคส × 3 กฎ เพราะ random_orset สุ่ม​บน element universe เล็ก (a,b,c,d) กับ tag universe เล็ก (0–7) tag จึง​ชน​กัน​ข้าม​ก้อน​บ่อย ทำให้ merge/tombstone interaction ไม่​เป็น​สุญญากาศ การ​เทียบเท่า​กัน​ใช้ derived Eq บน BTreeMap/BTreeSet ที่​เทียบ​เชิง​โครงสร้าง​ตาม​ลำดับ​เรียง จึง​ถูกต้อง​โดย​ไม่​ต้อง​เขียน PartialEq เอง (ต่าง​จาก vector clock ในบท 1 ที่ implicit-zero บังคับ​ให้​เขียน​เอง)

sequenceDiagram
    participant A as replica A
    participant B as replica B
    Note over A,B: ทั้งคู่เห็น apple@tag1
    Note over A: remove(apple) → tombstone {tag1}
    Note over B: add(apple, tag2) — re-add ที่ concurrent, tag ใหม่ยังไม่ถูกเห็น
    A->>B: ส่ง state adds{apple:{tag1}}, tombs{tag1}
    B->>A: ส่ง state adds{apple:{tag1,tag2}}
    Note over A,B: merge → adds{apple:{tag1,tag2}}, tombs{tag1}<br/>tag2 ไม่อยู่ใน tombs → contains(apple)=true

คำ​บรรยาย​ภาพ: add-wins — การ re-add ที่ concurrent พก tag2 ตัว​ใหม่​ที่​ยัง​ไม่มี​ใคร tombstone หลัง merge สอง​ทาง tag2 จึง​รอด และ apple ยัง​อยู่​ใน​เซ็ต ต่าง​จาก 2P-Set ดิบ​ที่​ลบ​แล้ว​เพิ่ม​กลับ​ไม่​ได้​ตลอด​กาล

แล้ว LWW-map ล่ะ — และ​ความ​จริง​ที่​ต้อง​พูด: LWW ทิ้ง write ที่ concurrent

หัวข้อ​ที่​มีชื่อ​ว่า “แล้ว LWW-map ล่ะ — และ​ความ​จริง​ที่​ต้อง​พูด: LWW ทิ้ง write ที่ concurrent”

OR-Set แก้​ปัญหา “เก็บ​ทุก add ไว้​แล้ว​ให้ add ชนะ” แต่​บาง​ครั้ง​เรา​ไม่​ได้​อยาก​เก็บ​ทุก​อย่าง — เรา​อยาก​ได้ ค่า​ล่าสุด​ค่า​เดียว ต่อ key เช่น field โปร file นั่น​คือ​งาน​ของ LWW-RegisterLWW-Registerรี​จิส​เตอร์ค่า​เดียว​ที่ timestamp สูงสุด​ชนะ — ทิ้ง write ที่ concurrent (lossy โดย design) ที่​บท 4 สร้าง​ไว้ (Last-Write-Wins: เก็บ​ค่าที่ (ts, node) สูงสุด) ขยาย​เป็น LWW-map ก็​แค่ BTreeMap จาก key ไปหา LWW-Register แล้ว merge ทีละ key

แต่​ต้อง​พูดตรงๆ ให้​ครบ: LWW ทิ้ง write ที่ concurrent ทิ้ง​อย่าง​เงียบๆ โดย design (Kleppmann, DDIA ch5) ถ้า2 node เขียน key เดียวกัน​แบบ concurrent มี​แค่​ค่า​เดียว​ที่ (ts, node) สูงกว่าที่​รอด อีก​ค่า หาย​ไป​โดย​ไม่มี​ร่องรอย — ไม่มี​การ merge ค่า ไม่มี sibling ให้​เห็น เทียบ​กับ OR-Set/PN-Counter ที่ รักษา ทุก operation ที่ concurrent ไว้ LWW เลือก “เรียบ​ง่าย​และ​ค่า​เดียว” แลก​กับ “ยอม​ทำ update หาย” นี่​คือ​เหตุผล​ที่ LWW ห้าม​ใช้​กับ​ตะกร้า​สินค้า ยอด​เงิน หรือ​เซ็ต (ใช้ OR-Set/PN-Counter แทน) แต่​โอเค​กับ field ที่ “ค่า​ล่าสุด​ถูก​เสมอ”

tie-break ของ LWW ต้อง deterministic เสมอ

timestamp อย่าง​เดียว ไม่ใช่ total order — 2 node ประทับ ts เดียวกัน​ได้ ถ้า​ตอน ts เท่า​กัน​แล้ว merge ไม่​ตัดสิน​ให้​เด็ดขาด merge จะ​เลิก​เป็น commutative (A merge B ได้​ค่า​หนึ่ง, B merge A ได้​อีก​ค่า → ไม่​ลู่​เข้า) ทาง​แก้​คือ​ใส่ tie-break ที่ deterministic เข้าไป​ใน​ลำดับ — เทียบ​เป็น tuple (ts, node, value) ครบ​ทุก​ชั้น อย่าง​ที่​บท 4 ทำ node id จึง​ตัดสิน​ตอน ts เสมอ ค่าที่​ได้​จึง​ไม่​ขึ้น​กับ​ลำดับ merge นี่​คือ​เงื่อนไข​ที่​ทำให้ LWW ยัง​เป็น CRDT ที่​ถูกต้อง

ตอน​นี้​ถึง​จุด​ที่​ทั้ง​คอร์ส​เดิน​มา — เอา CRDT ไป เป็น​ชนิด​ของ value ที่ store เก็บ​จริง ใน kaen-kvstore ปกติ store คือ key → bytes และ “write” คือ ทับ ค่า​เดิม แต่​ถ้า​เรา​ให้ value เป็น byte-encode ของ CRDT แล้ว​นิยาม write ใหม่​เป็น merge_put = decode → merge → encode การ​เขียน​จะ​กลาย​เป็นการ merge ไม่ใช่​ทับ — และ​การ​ลู่​เข้า​จะ​พิสูจน์​ได้​ถึง​ระดับ byte

encode ใช้ wire แบบ length-prefixed ของ #22 ตรงๆ ไม่มี serde: นับ​จำนวน entry เป็น u32, ต่อ​ด้วย key (length-prefixed) และ tag แต่ละ​ตัว​เป็น u64 ผ่าน to_le_bytes กุญแจ​สำคัญ​คือ​หนุน​สถานะ​ด้วย BTreeMap/BTreeSet ที่​เรียง​แล้ว — สถานะ​ที่​เท่า​กัน​จึง encode ออก​มา​เป็น byte ที่​เท่า​กัน​เป๊ะ (byte-canonical) ทำให้ “byte เท่า​กัน” เป็น​พยาน​ของ “ลู่​เข้า​แล้ว”:

fn encode(set: &OrSet) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&(set.adds.len() as u32).to_le_bytes());
for (elem, tags) in &set.adds { // BTreeMap => ลำดับ key คงที่
out.extend_from_slice(&(elem.len() as u32).to_le_bytes());
out.extend_from_slice(elem);
out.extend_from_slice(&(tags.len() as u32).to_le_bytes());
for &t in tags { // BTreeSet => ลำดับ tag คงที่
out.extend_from_slice(&t.to_le_bytes());
}
}
out.extend_from_slice(&(set.tombs.len() as u32).to_le_bytes());
for &t in &set.tombs {
out.extend_from_slice(&t.to_le_bytes());
}
out
}

ฝั่ง​อ่าน​ใช้ Cursor เดิน​ไล่ byte (อ่าน u32/u64 ผ่าน from_le_bytes, อ่าน frame ที่ length-prefixed) แล้ว decode กลับ​เป็น OrSet ส่วน store เก็บ value เป็น HashMap<Vec<u8>, Vec<u8>> (key → encoded CRDT bytes) และ merge_put ทำงาน​สาม​จังหวะ:

struct KvStore {
map: HashMap<Vec<u8>, Vec<u8>>,
}
impl KvStore {
// wire = 1 frame length-prefixed ที่พก encoded OR-Set state
fn merge_put(&mut self, key: &[u8], wire: &[u8]) {
let mut cur = Cursor::new(wire);
let payload = cur.frame();
let incoming = decode(&payload);
let current = self.map.get(key).map(|b| decode(b)).unwrap_or_else(OrSet::new);
let merged = current.merge(&incoming); // <-- write = merge, ไม่ใช่ทับ
self.map.insert(key.to_vec(), encode(&merged));
}
}

ทดสอบ​สมบัติ​ที่​สำคัญ​ที่สุด — ลู่​เข้าถึง​ระดับ byte ภาย​ใต้ reorder + duplicate: 2 node เก็บ​ตะกร้า​เดียวกัน​ใต้ key cart:42 node 1 เพิ่ม milk, เพิ่ม apple, แล้ว ลบ apple; node 2 re-add apple ด้วย tag ใหม่ (concurrent) แล้ว​เพิ่ม bread แต่ละ​ฝั่ง​ส่ง​สถานะ​เป็น frame แล้ว node 1 รับ frame ซ้ำ (duplicate) ส่วน node 2 รับ2 frame ใน​ลำดับ​กลับ​กัน (reversed):

let frame1 = put_frame(&encode(&local1)); // สถานะ node 1
let frame2 = put_frame(&encode(&local2)); // สถานะ node 2
let mut node1 = KvStore::new();
node1.merge_put(b"cart:42", &frame1);
node1.merge_put(b"cart:42", &frame2);
node1.merge_put(b"cart:42", &frame2); // DUPLICATE — idempotent no-op
let mut node2 = KvStore::new();
node2.merge_put(b"cart:42", &frame2);
node2.merge_put(b"cart:42", &frame1); // ลำดับกลับกันกับ node1
let identical = node1.value_bytes(b"cart:42") == node2.value_bytes(b"cart:42");
converged cart:42 bytes identical on both nodes = true
converged members = ["apple", "bread", "milk"] (apple survived via add-wins)
stored value size = 86 bytes

node 1 รับ frame ซ้ำ​หนึ่ง​ครั้ง node 2 รับ​กลับ​ลำดับ แต่ byte ที่​เก็บ​สุดท้าย​เท่า​กัน​เป๊ะidempotent ทำให้ frame ซ้ำ​เป็น no-op, commutative ทำให้​ลำดับ​ไม่มี​ผล นี่​คือ​กฎ semilattice ของ​บท 4 ทำงาน​ถึง​ระดับ byte จริงๆ ไม่ใช่​แค่​ระดับ logical state และ apple รอด​มา​อยู่​ใน​สมาชิก​ด้วย add-wins (tag ที่ re-add ไม่​ถูก tombstone) ค่า 86 bytes นั้น pin กับ encoding นี้ + tag ที่​ใช้ — เปลี่ยน layout หรือ tag เมื่อไหร่​เลข​ก็​เปลี่ยน แต่​ใน​เงื่อนไข​คงที่​นี้​มัน​ซ้ำ​ได้ 100%

tombstones โต​ทาง​เดียว — toy นี้​ไม่มี GC (พูดตรงๆ)

tombs ใน OR-Set (และ metadata ของ CRDT ทุก​ตัว) โต​ทาง​เดียว​เท่านั้น ลบ element เยอะๆ tombstones ก็​บวม​ไม่​หยุด ระบบ​จริง (Riak, Akka) ต้อง​มี causal-stability pruning — ตัด tag/tombstone ที่​ทุก replica เห็น​ครบ​แล้ว​ทิ้ง​ได้ (Shapiro et al. 2011 §3.3) แต่ toy ใน​บท​นี้ ไม่​ทำ GC เลย และ​นั่น​คือ​ขอบเขต​ที่​เรา​ตั้งใจ​ไว้: เรา​พิสูจน์ ความ​ถูกต้อง​ของ​การ​ลู่​เข้า ให้​ครบ ส่วน​การ prune ที่​ถูกต้อง (ต้อง​รู้​ว่า operation ใด causally stable แล้ว) เป็น​อีก​ชั้น​ที่ production ต้อง​มี — บท​นี้​เอ่ย​ถึง​แต่​ไม่​ลง code

honesty spine ของ​บท​นี้

D — นี่​คือ​กลไก​จริง​ของ Dynamo/Riak: OR-Set กับ CRDT-as-value คือ​หัวใจ​ของ Riak Data Types และ Dynamo-style store จริง ไม่ใช่​ของ​กุ​ขึ้น​เพื่อ​สอน — เรา​ลง​ตาม Shapiro et al. 2011 (Specification 12) ที่​สเกล toy บน kaen-kvstore

A — Rust พิสูจน์​ความ​ถูกต้อง “ใน​เครื่อง​เดียว”: compiler การันตี​ว่า encode/decode ไม่มี memory bug แต่ มัน​มอง​ไม่​เห็น ว่า merge law ถูก​หรือ​เปล่า — property test 9000 checks คือ ออราเคิล ของ​การ​ลู่​เข้า และ​มัน​คือ​การ ตรวจ​แบบ​มี​ขอบเขต ไม่ใช่​บท​พิสูจน์ ผ่าน 3000 เคส​แปล​ว่า​สำรวจ​เฉพาะ OR-Set ที่ seed เดิน​ไป​ถึง ที่​ร้ายกว่า​นั้น: tag ที่​ไม่ unique จะ​ทำให้ semantics ผิด​ทั้ง​ที่​กฎ merge ยัง​ผ่าน​หมด — เป็น bug ที่​อยู่​นอก​สายตา​ทั้ง compiler และ property test กฎ ต้อง​ออกแบบ tag ให้​ถูก

C — ดี​เท​อร์มิ​นิส​ซึม​คือ​วินัย: seed คงที่ (0x000F_5E70) + BTreeMap/BTreeSet แทน HashMap/HashSet ทุก​ที่​ที่​ลำดับ iteration ป้อน byte-canonical convergence มิ​ฉะนั้น​สถานะ​เดียวกัน​จะ encode ต่าง byte = การ​ลู่​เข้า​จอม​ปลอม

บท​นี้​ปิด​ช่องว่าง​ที่​ยาก​ที่สุด​ของ CRDT — การ​ลบ — และ​พา CRDT ลง​ไป​อยู่​ใน store จริง: 2P-Set ดิบ​ลบ​แล้ว​เพิ่ม​กลับ​ไม่​ได้​ตลอด​กาล​เพราะ​จำ​แค่​ค่า​ไม่​จำ​ตัวตน​ของ add; OR-SetOR-Setเซ็ต add-wins: ทุก add ติด tag ไม่​ซ้ำ, remove ลบ​ได้​เฉพาะ tag ที่​เห็น​แล้ว → re-add ที่ concurrent รอด แก้​ด้วย​การ​แนบ tag ที่​ไม่​ซ้ำ ให้​ทุก add แล้ว remove tombstone เฉพาะ tag ที่​เห็น​แล้ว → re-add ที่ concurrent พก tag ใหม่​จึง​รอด = add-wins; merge = union ของ grow-only set สอง​ชุด จึง​ได้ comm/assoc/idempotent มา​ฟรี (9000 checks, 0 violations); LWW-RegisterLWW-Registerรี​จิส​เตอร์ค่า​เดียว​ที่ timestamp สูงสุด​ชนะ — ทิ้ง write ที่ concurrent (lossy โดย design) ที่​ขยาย​เป็น LWW-map นั้น​เรียบ​ง่าย​แต่ ทิ้ง write ที่ concurrent อย่าง​เงียบๆ โดย design — ห้าม​ใช้​กับ​ตะกร้า/ยอด​เงิน/เซ็ต และ tie-break ต้อง deterministic; สุดท้าย​เรา​จับ CRDT เป็น value ของ kaen-kvstore ผ่าน wire length-prefixed ของ #22 ทำให้ “write = merge” และ​พิสูจน์​การ​ลู่​เข้าถึง​ระดับ byte (86 bytes เท่า​กัน​เป๊ะ2 node) ภาย​ใต้ reorder + duplicate; tombstones โต​ทาง​เดียว toy นี้​ไม่ GC (พูดตรงๆ) ทุก snippet compile zero-warnings บน Rust 1.97.1 / edition 2024 / std ล้วน และ​รัน​ได้​เลข​ตาม​ที่​เห็น

บท 6 เรา​จะ​ทำให้ replica เทียบ​และ sync กันเอง​อย่าง​มี​ประสิทธิภาพ: ตอน​นี้​เรา​รู้​แล้ว​ว่า value ที่ merge ได้​หน้าตา​เป็น​อย่างไร บท​หน้า​ถาม​ว่า 2 node จะ​รู้​ได้​เร็วๆ ว่า​ตรง​ไหน diverge โดย​ไม่​ต้อง​ส่ง​ข้อมูล​ทั้ง​ก้อน​มา​เทียบ — คำ​ตอบ​คือ Merkle treeMerkle treeต้นไม้ hash ที่​เทียบ root แล้ว​ลง​เฉพาะ subtree ที่​ต่าง หา​ช่วง key ที่ diverge แบบ sub-linear ที่​เทียบ root ก่อน​แล้ว​ลง​เฉพาะ subtree ที่​ต่าง (anti-entropy) กับ gossip ที่​กระจาย​ข่าว​แบบ​ระบาด​ใน O(log N) รอบ


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

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

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

ข้อ 1 / 3

ทำไม OR-Set ถึงทำให้ re-add ที่ concurrent กับ remove 'รอด' (add-wins) ในขณะที่ 2P-Set ดิบทำไม่ได้?