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

ลืม​ทั้ง​ท่อน หรือ​เก็บ​ค่า​ล่าสุด​ต่อ key

log ที่​ต่อ​ท้าย​อย่าง​เดียว​มี​ปัญหา​ข้อ​เดียว​ที่​หลีก​ไม่​พ้น คือ​มัน​โต​ไป​เรื่อยๆ

kaen-log แก้​ปัญหา​นั้น​ด้วย​สอง​กลไก​ที่​ลืม​คนละ​แบบ และ​ผู้​อ่าน​ส่วน​ใหญ่​จำ​สอง​อย่าง​นี้​สลับ​กัน

retentionretentionกติกา​การ​ลืม​ทั้ง segment ตาม​อายุ​หรือ​ขนาด​รวม · broker ลืม​เป็น​ท่อน ไม่​ได้​ลืม​เป็น record ลบ​ที​ละ​ท่อน ไม่​เคย​เลือก​ลบ record ตัว​ใด​ตัว​หนึ่ง ส่วน compactioncompactionเขียน​เฉพาะ record ที่​ยัง​มี​ชีวิต​ลง file ใหม่​เพื่อ​ทวง​คืน​พื้นที่ · ของ broker สัญญา​แค่​ว่า​ค่า​สุดท้าย​ของ​แต่ละ key ยัง​อยู่ ไม่​ได้​สัญญา​ว่า​ค่า​เก่า​หาย​หมด และ​ห้าม​ขยับ offset เขียน​ท่อน​เก่า​ขึ้น​ใหม่​โดย​รับรอง​แค่​ว่า​ค่า​สุดท้าย​ของ​แต่ละ key ยัง​อยู่

ประโยค​หลัง​เป็น​ข้อ​อ้าง​เรื่อง การ​มี​อยู่ ไม่ใช่​ข้อ​อ้าง​เรื่อง ความ​มี​เพียง​หนึ่ง​เดียว ค่า​ซ้ำ​ของ key เดียวกัน​อยู่​ต่อ​ได้ และ​ไม่มี​บรรทัด​ไหน​ใน​เอกสาร​ของ Kafka สัญญา​ว่า​มัน​จะ​หาย​ไป

📦 code ของ​บท​นี้

crate ของ​คอร์ส​อยู่​ที่ scripts/log-broker/ และ​ชื่อ binary คือ klog บท​นี้​เป็น​เจ้าของ scripts/log-broker/src/clean.rs เพียง file เดียว

บล็อก code กับ​บล็อก​ผล​รัน​ทุก​อัน​ใน​บท​เรียน​นี้​ถูก​คัด​มา​จาก src/clean.rs และ​จาก scripts/log-broker/expected/06-clean.txt ทีละ byte โดย​มี​เทสต์​เทียบ​ไว้ ถ้า​แก้​ใน​บท​เรียน​อย่าง​เดียว​โดย​ไม่​แก้​ที่​ต้นทาง เทสต์​จะ​แดง​ทันที

ผล​รัน​ทุก​บรรทัด​ใน​บท​นี้​มา​จาก​คำ​สั่ง​เดียว คือ klog clean-demo --dir data/orders-0 --now-ms 1700000000000 --retention-ms 604800000 --delete-retention-ms 86400000 --passes 2 ซึ่ง​เทสต์​รัน​ซ้ำ​แล้ว​เทียบ​ผล​ที​ละ byte ทุก​ครั้ง

หัวข้อ​นี้​วาง​เส้น​เปรียบ​เทียบ​กับ​คอร์ส​ก่อน​หน้า​ไว้​ก่อน แล้ว​บอก​ว่า​อะไร​บังคับ​ให้ kaen-log ทำ​ต่าง​ออก​ไป

คอร์ส kaen-kvstore สอน compaction ไป​แล้ว แต่​เป็น​คนละ​ตัว​กับ​บท​นี้ มัน​อ่าน​เซต​ที่​ยัง​มี​ชีวิต​จาก​สารบัญ​ใน​หน่วย​ความ​จำ เขียน log ทั้ง​ก้อน​ขึ้น​ใหม่ แจก offset ชุด​ใหม่​ให้​ทุก record แล้ว​ทิ้ง tombstone ทันที​ใน​รอบ​เดียวกัน

kaen-log ทำ​สาม​ข้อ​นั้น​ไม่​ได้​สัก​ข้อ และ​เหตุผล​รวบ​ได้​เป็น​ประโยค​เดียว KV store เป็น​เจ้าของ​ผู้​อ่าน​ราย​เดียว​ของ​ตัว​เอง มัน​จึง​ลืม​เมื่อ​ไหร่​ก็ได้ ส่วน​ผู้​อ่าน​ของ broker เป็น​คน​แปลก​หน้าที่​ถือ​นาฬิกา​ของ​ตัว​เอง การ​ลืม​จึง​ต้อง​นัด​เวลา

ผล​ที่​ตาม​มา​สี่​ข้อ​คือ​โครง​ของ​ทั้ง​บท​นี้

  • offset ต้อง​อยู่​ที่​เดิม เพราะ​มี​คน​ถือ​เลข​นั้น​อยู่​และ​จะ​กลับ​มา​ขอ​ต่อ​จาก​มัน
  • ช่อง​ว่าง​ที่​เกิด​ขึ้น​ต้อง​อยู่​ต่อ ไม่มี​การ​รูด​เลข​ให้​ชิด​กัน
  • ท่อน​ที่​กำลัง​เขียน​อยู่​ห้าม​แตะ
  • tombstone ต้อง​อยู่​ให้​พ้น​เส้น​ตาย​ก่อน ไม่ใช่​หาย​ไป​ใน​รอบ​ที่​เห็น​มัน​ครั้ง​แรก

หัวข้อ​นี้​กาง​ค่าที่ cleanup.policy รับ​ได้​ทั้ง​สี่​แบบ แล้ว​บอก​ว่า demo ของ​บท​นี้​ตั้ง​ค่า​ไหน

cleanup.policy ไม่​ได้​บังคับ​ให้​เลือก​อย่าง​ใด​อย่าง​หนึ่ง เอกสาร topic config ของ Kafka 4.1 เขียน​ไว้​ว่า “It is also possible to specify both policies in a comma-separated list” แล้ว​ยก​ตัวอย่าง​ของ list นั้น​ไว้​เป็น delete,compact ส่วน​ค่า​ปริยาย​คือ delete ตัว​เดียว

LogConfig ของ kaen-log จึง​เก็บ​มัน​เป็น bool สอง​ตัว​ที่​แยก​จาก​กัน ซึ่ง​เป็น​รูป​เดียว​กับ​ที่ LogConfig.java ของ Kafka 4.1 ใช้ คือ public final boolean compact; กับ public final boolean delete;

expected/06-clean.txt (ตัด​มา​บาง​ส่วน)
== cleanup.policy เป็น list ไม่ใช่ enum ==
ค่า ความหมาย
(ว่าง) ไม่ลบและไม่ compact เลย
delete ลบทั้งท่อนตามเวลาและตามขนาด · **นี่คือค่าปริยาย**
compact เก็บอย่างน้อยค่าสุดท้ายต่อ key
delete,compact ท่อนเก่าถูกลบตามเวลา · ท่อนที่เหลือถูก compact
demo นี้ตั้ง orders เป็น delete,compact เพื่อให้เห็นทั้งสองกลไกบน partition เดียว

demo ตั้ง orders ให้​เป็น delete,compact เพราะ​ต้องการ​ให้​ทั้ง​สอง​กลไก​ทำงาน​บน partition เดียวกัน แล้ว​ดู​ว่า​อะไร​เป็น​ของ​ใคร

expected/06-clean.txt (ตัด​มา​บาง​ส่วน)
cleanup.policy ของ orders = delete,compact
retention.ms = 604800000 · delete.retention.ms = 86400000
segment.bytes = 512 (เล็กเกินกว่าที่ Kafka จริงยอมรับ ซึ่งต่ำสุด 1048576)

ค่า segment.bytes ของ demo เล็ก​กว่า​ที่ Kafka จริง​ยอมรับ หน้า topic config ระบุ​ช่วง​ที่​รับ​ได้​ไว้​ว่า [1048576,...] ท่อน​ขนาด​นี้​จึง​มี​อยู่​ได้​เฉพาะ​ใน​ของ​เล่น มัน​มี​ไว้​ให้​เห็น​หลาย​ท่อน​ใน​หน้า​จอ​เดียว

retention ลบ​ที​ละ​ท่อน และ​ตัดสิน​ด้วย timestamp ที่​มาก​ที่สุด

หัวข้อ​ที่​มีชื่อ​ว่า “retention ลบ​ที​ละ​ท่อน และ​ตัดสิน​ด้วย timestamp ที่​มาก​ที่สุด”

หัวข้อ​นี้​แสดง​เงื่อนไข​การ​ลบ​ท่อน แล้ว​ชี้​ว่า​ทำไม record สด​ตัว​เดียว​ถึง​ยก​ทั้ง​ท่อน​ให้​รอด

เอกสาร Implementation › Log ของ Kafka 4.1 เขียน​กลไก​นี้​ไว้​ตรง​ตัว “Data is deleted one log segment at a time” และ​บอก​ด้วย​ว่า​เวลา​ที่​ใช้​ตัดสิน​มา​จาก​ไหน “with the largest timestamp in a segment file (order of records is not relevant) defining the retention time for the entire segment”

คำ​ว่า largest ใน​ประโยค​นั้น​คือ​ทั้งหมด​ของ​กลไก record ที่​สด​ที่สุด​ใน​ท่อน​เป็น​คน​ตัดสิน​ชะตา​ของ​ทั้ง​ท่อน record เก่า​ที่​นั่ง​อยู่​ใน​ท่อน​เดียว​กับ​มัน​รอด​ไป​ด้วย​กัน​หมด

deletable_segments ของ kaen-log ลอก​รูป​มา​จาก UnifiedLog.java ของ Kafka 4.1 ตรงๆ

src/clean.rs · เดิน​จาก​ท่อน​เก่า​สุด แล้ว​หยุด​ที่​ท่อน​แรก​ที่​รอด
/// เดินจากท่อนเก่าสุด แล้ว **หยุดที่ท่อนแรกที่รอด** · ไม่เคยเอื้อมเข้าไปกลาง log
///
/// เงื่อนไขอ่าน timestamp ที่ **มากที่สุด** ของท่อน · record สดตัวเดียว
/// จึงทำให้ทั้งท่อนที่เหลือรอดไปด้วยกันหมด
pub fn deletable_segments(log: &Log, now_ms: i64) -> Vec<u64> {
let mut out = Vec::new();
let mut it = log.segments().iter().peekable();
while let Some(seg) = it.next() {
let next = it.peek();
let upper_bound = next.map_or(log.log_end_offset(), |s| s.base_offset());
let is_last_and_empty = next.is_none() && seg.size() == 0;
let breached = now_ms - seg.largest_timestamp() > log.config.retention_ms;
if log.high_watermark() >= upper_bound && breached && !is_last_and_empty {
out.push(seg.base_offset());
} else {
break;
}
}
out
}

break ใน​กิ่ง else คือ​บรรทัด​ที่​ทำให้​มัน​ไม่ใช่ filter ตัว​นี้ หยุด​ที่​ท่อน​แรก​ที่​รอด มัน​จึง​ไม่มี​ทาง​เอื้อม​ข้าม​ท่อน​ที่​รอด​ไป​ลบ​ท่อน​ที่​อยู่​ถัด​ไป

อีก​สอง​เงื่อนไข​ใน​บรรทัด if ก็​ลอก​มา​จาก Kafka เหมือน​กัน

  • log.high_watermark() >= upper_bound — คอม​เมน​ต์​ใน UnifiedLog.java ให้​เหตุผล​ไว้​ว่า “We don’t delete segments with offsets at or beyond the high watermark to ensure that the log start offset can never exceed it”
  • !is_last_and_empty — ท่อน​สุดท้าย​ที่​ว่าง​เปล่า​ไม่​ถูก​ลบ ไม่​งั้น log จะ​ไม่​เหลือ​ท่อน​ให้​เขียน​ต่อ

demo เขียน record เก่า​หก​ตัว​ลง​ไป​ก่อน แล้ว​ตาม​ด้วย record สด จน​ได้​เจ็ด​ท่อน

expected/06-clean.txt (ตัด​มา​บาง​ส่วน)
== retention ลบทีละท่อน ไม่เคยลบทีละ record ==
file base size largest_timestamp อายุ (ms) หมดอายุ
00000000000000000000.log 0 402 1699395040002 604959998 ใช่
00000000000000000003.log 3 402 1699395040005 604959995 ใช่
00000000000000000006.log 6 402 1699999940008 59992 ไม่ใช่
00000000000000000009.log 9 402 1699999940011 59989 ไม่ใช่
00000000000000000012.log 12 402 1699999940014 59986 ไม่ใช่
00000000000000000015.log 15 402 1699999940017 59983 ไม่ใช่
00000000000000000018.log 18 402 1699999940020 59980 ไม่ใช่
ท่อนที่หมดอายุ [0, 3]
ลบไป 2 ท่อน · record ที่หายไปด้วย 6 ตัว
log start offset 0 → 6
log end offset 21 → 21 (ไม่ขยับ)
record ที่หายไปเป็นจำนวนเต็มท่อนเสมอ · ไม่มีทางลบ record เดี่ยวๆ ได้
timestamp ที่ใช้ตัดสินคือตัวที่ **มากที่สุด** ของท่อน · record สดตัวเดียวยกทั้งท่อนให้รอด

สอง​บรรทัด​สุดท้าย​ของ​บล็อก​คือ​ข้อ​อ้าง​ของ​หัวข้อ​นี้ ตัวเลข​ที่​หาย​ไป​เป็น​จำนวนเต็ม​ท่อน​เสมอ และ log end offset ไม่​ขยับ​เลย เพราะ retention แตะ​แต่​หัว log ไม่​เคย​แตะ​หาง

ท่อน​ที่​ยัง​ไม่​ถูก​ลบ​ออก​จาก disk กับ​ท่อน​ที่​หมด​อายุ เป็น​คนละ​เวลา​กัน

Kafka จริง​มี​สอง​ค่าที่​คั่น​ระหว่าง​สอง​เหตุการณ์​นี้ log.retention.check.interval.ms เป็น​รอบ​ที่​มัน​เดิน​มา​ตรวจ และ file.delete.delay.ms เป็น​เวลา​ที่​มัน​รอ​ก่อน​จะ​เอา file ออก​จาก disk จริงๆ โดย file ถูก rename ให้​ลงท้าย​ด้วย .deleted ไว้​ก่อน

kaen-log ไม่มี​ทั้ง​สอง​ค่า มัน​ลบ​ทันที​ใน​บรรทัด​ที่​เรียก เพราะ demo ถูก​ขับ​ที​ละ​ก้าว ไม่มี thread เบื้อง​หลัง ที่​จะ​เดิน​มา​ตรวจ​ตาม​รอบ ข้อ​นี้​เป็น​สิ่ง​ที่​ของ​เล่น​ตัว​นี้​ตัด​ทิ้ง ไม่ใช่​สิ่ง​ที่ Kafka ทำ​เกิน​จำเป็น

เพดาน​สาม​ชั้น​ที่​ตัว​ทำความ​สะอาด​ข้าม​ไม่​ได้

หัวข้อ​ที่​มีชื่อ​ว่า “เพดาน​สาม​ชั้น​ที่​ตัว​ทำความ​สะอาด​ข้าม​ไม่​ได้”

หัวข้อ​นี้​บอก​ว่า compaction เริ่ม​ทำได้​ถึง offset ไหน และ​ทำไม​ท่อน​ที่​กำลัง​เขียน​อยู่​ถึง​ไม่มี​วัน​ถูก​แตะ

รอบ​ทำความ​สะอาด​หนึ่ง​รอบ​ไม่​ได้​กวาด​ทั้ง log มัน​กวาด​ได้​ถึง​แค่ offset หนึ่ง​แล้ว​หยุด ทุก​อย่าง​ที่​ต่ำ​กว่า​เส้น​นั้น​คือ หัว log ซึ่ง​เป็น​ช่วง​ที่​มัน​สรุป​เป็น​ตาราง​ก่อน​คัด​ผู้​รอด เส้น​นั้น​คือ offset แรก​ที่​ทำความ​สะอาด​ไม่​ได้ และ​มัน​คือ​ค่าที่​น้อย​ที่สุด​ใน​สาม​ค่า

LogCleanerManager.java ของ Kafka 4.1 เขียน​สาม​ค่า​นั้น​ไว้​เป็น​คอม​เมน​ต์​ของ​ตัว​เอง “We cannot clean past: 1. The active segment 2. The last stable offset (including the high watermark) 3. Any segments closer to the head of the log than the minimum compaction lag time”

src/clean.rs · เพดาน​สาม​ชั้น เอา​ค่าที่​น้อย​ที่สุด
/// เพดานสามชั้นตามคอมเมนต์ของ Kafka เอง: ท่อนที่กำลังเขียนอยู่ · เส้น committed
/// · และท่อนที่ยังใหม่กว่า `min.compaction.lag.ms`
pub fn first_uncleanable_offset(log: &Log, now_ms: i64) -> u64 {
let mut bounds = vec![log.high_watermark(), log.active_segment().base_offset()];
if log.config.min_compaction_lag_ms > 0 {
for seg in log.segments() {
if seg.largest_timestamp() > now_ms - log.config.min_compaction_lag_ms {
bounds.push(seg.base_offset());
break;
}
}
}
bounds.into_iter().min().expect("มีอย่างน้อยสองค่าเสมอ")
}

ชั้น​ที่​หนึ่ง​คือ​ท่อน​ที่​กำลัง​เขียน​อยู่ และ​เหตุผล​ของ​มัน​เป็น​เรื่อง​กลไก​ล้วนๆ รอบ​ทำความ​สะอาด​หนึ่ง​รอบ​คือ​การ เขียน​ท่อน​ใหม่​ให้​ครบ​ก่อน แล้ว​ค่อย​สลับ​เข้าไป​แทน​ของ​เดิม สลับ file ที่​ยัง​มี​คน​ต่อ​ท้าย​อยู่​ไม่​ได้ เพราะ byte ที่​เขียน​เข้า​มาระ​หว่าง​นั้น​จะ​หาย​ไป​กับ file เก่า

เอกสาร Design ของ Kafka 4.1 ยืนยัน​ว่า​กติกา​นี้​ไม่มี​ข้อ​ยกเว้น “The active segment will not be compacted even if all of its messages are older than the minimum compaction time lag”

ชั้น​ที่​สอง​ใน​ของ​จริง​คือ last stable offset ซึ่ง​เป็น​เส้น​ของ transaction kaen-log ยัง​ไม่มี transaction บท​นี้​จึง​ใช้ high watermark แทน และ​เรียก​ชื่อ​ชั้น​นี้​ไว้​เฉยๆ บท​ที่ 7 จะ​กาง​ว่า last stable offset ต้อง​มี​อะไร​มาร​อง​รับ​บ้าง แล้ว​ประกาศ​ว่า​คอร์ส​นี้​ไม่​ได้​สร้าง​มัน ส่วน​บท​ที่ 8 เป็น​บท​ที่​ทำให้ high watermark ซึ่ง​ยืน​แทน​อยู่​ตรง​นี้ มี​ค่า​ของ​ตัว​เอง​จริงๆ

ชั้น​ที่​สาม​คือ min.compaction.lag.ms ซึ่ง​ค่า​ปริยาย​ของ Kafka เป็น​ศูนย์ if ใน code จึง​ข้าม​ทั้ง​กิ่ง​ไป​เมื่อ​ไม่มี​ใคร​ตั้ง​ค่า

compaction สัญญา​ว่า​อย่าง​น้อย ไม่​ได้​สัญญา​ว่า​เพียง​หนึ่ง​เดียว

หัวข้อ​ที่​มีชื่อ​ว่า “compaction สัญญา​ว่า​อย่าง​น้อย ไม่​ได้​สัญญา​ว่า​เพียง​หนึ่ง​เดียว”

หัวข้อ​นี้​ยก​ข้อ​รับ​ประกัน​สี่​ข้อ​ของ Kafka มา​ดู​ข้อ​ที่​โดน​เข้าใจ​ผิด​บ่อย​ที่สุด แล้ว​ชี้​ว่า​กลไก​ที่​ทำให้​มัน​เป็น​แบบ​นั้น​อยู่​ตรง​ไหน​จริงๆ

เอกสาร Design ของ Kafka 4.1 เขียน​ข้อ​ที่​สี่​ไว้​ว่า “Any consumer progressing from the start of the log will see at least the final state of all records in the order they were written”

คำ​ว่า at least ใน​ประโยค​นั้น​ไม่ใช่​การ​เขียน​เผื่อ มัน​คือ​ขอบเขต​ของ​ข้อ​อ้าง compaction บอก​ว่า​ค่า​สุดท้าย​ของ​แต่ละ key ยัง​อยู่ มัน​ไม่​ได้​บอก​ว่า​ค่า​เก่า​ของ key นั้น​หาย​ไป​หมด​แล้ว

ที่​ที่​คน​มัก​ชี้​คือ​ตัว​เปรียบ​เทียบ​ใน should_retain แต่​ตัว​จริง​ไม่​ได้​อยู่​ที่​บรรทัด​นั้น

src/clean.rs · ตัว​เปรียบ​เทียบ กับ​สิ่ง​ที่​ปล่อย​ให้​ค่า​ซ้ำ​รอด​จริง
/// ค่าซ้ำของ key เดียวกันรอด compaction ได้จริง · แต่ตัวที่ปล่อยให้รอดคือ **เพดาน
/// `first_uncleanable_offset`** ไม่ใช่เครื่องหมาย `>=` ข้างล่างนี้ · `compact` ข้าม
/// ทุกท่อนที่ `base_offset >= first_uncleanable` และท่อนที่กำลังเขียนอยู่เป็นหนึ่งใน
/// เพดานนั้นเสมอ ค่าเก่าของ key ที่ยังอยู่เหนือเส้นจึงไม่เคยถูกแตะ · นั่นคือเหตุผล
/// ที่ข้ออ้างของ compaction เป็น "อย่างน้อยค่าสุดท้าย" ไม่ใช่ "ค่าสุดท้ายเพียงตัวเดียว"
///
/// `>=` ลอกมาจาก `Cleaner.shouldRetainRecord` ของ Kafka ตรงตัว และของจริงมันมีงานทำ
/// เพราะ `SkimpyOffsetMap` เก็บ key เป็นค่า MD5 แล้วเทียบกันที่ค่านั้น · key คนละตัว
/// จึงชนช่องเดียวกันได้ และช่องที่ชนอาจถือ offset ที่สูงกว่า record ที่กำลังถูกตรวจ
/// `>=` เก็บ record ตัวนั้นไว้แทนที่จะทิ้ง · ตารางของ kaen-log เก็บ byte ของ key
/// ตรงๆ จึงชนไม่ได้ และทุก record ที่มาถึงบรรทัดนั้น `>=` กับ `==` ให้ผลเท่ากันหมด
pub fn should_retain(map: &OffsetMap, batch: &Batch, rec: &Record, now_ms: i64) -> bool {
if rec.offset > map.latest_offset() {
return true;
}
let Some(key) = rec.key.as_deref() else { return false };
let latest_for_key = match map.get(key) {
Some(latest) => rec.offset >= latest,
None => true,
};
let retain_deletes =
match delete_horizon_ms(batch.header.attributes, batch.header.base_timestamp) {
// ยังไม่เคยถูกประทับเส้นตาย · tombstone จึงรอดรอบทำความสะอาดนี้ไปแน่นอน
None => true,
Some(horizon) => now_ms < horizon,
};
latest_for_key && (rec.value.is_some() || retain_deletes)
}

สิ่ง​ที่​ปล่อย​ให้​ค่า​ซ้ำ​รอด​คือ เพดาน first_uncleanable_offset รอบ​ทำความ​สะอาด​ข้าม​ทุก​ท่อน ที่ base offset ถึง​เพดาน​แล้ว และ​ท่อน​ที่​กำลัง​เขียน​อยู่​เป็น​หนึ่ง​ใน​ค่าที่​ประกอบ​เป็น​เพดาน​นั้น​เสมอ ค่า​เก่า​ของ key ที่​บังเอิญ​ตก​อยู่​เหนือ​เส้น จึง​ไม่​เคย​ถูก​แตะ​สัก​รอบ​เดียว

ตาราง​ใน​ผล​รัน​ของ​หัวข้อ compaction ข้าง​ล่าง​พิมพ์​เพดาน​นั้น​ออก​มา​เป็น 25 ขณะ​ที่ log end offset เป็น 28 record สาม​ตัว​ท้าย​สุด​จึง​อยู่​นอก​มือ​ของ compaction ทั้ง​ที่ key ของ​มัน​ซ้ำ​กับ​ของ​ที่​เพิ่ง​ถูก​บีบ​ไป

แล้ว >= มี​ไว้​ทำ​อะไร มัน​ลอก​มา​จาก Cleaner.shouldRetainRecord ของ Kafka ตรง​ตัว และ​ใน​ของ​จริง​มัน​มี​งาน​ทำ เพราะ SkimpyOffsetMap ไม่​ได้​เก็บ key มัน​เก็บ​ค่า MD5 ของ key แล้ว​เทียบ​กัน​ที่​ค่า​นั้น

key คนละ​ตัว​จึง​ลง​ช่อง​เดียวกัน​ได้ และ​ช่อง​ที่​ชน​อาจ​ถือ offset ที่​สูง​กว่า record ที่​กำลัง​ถูก​ตรวจ​อยู่ >= เก็บ record ตัว​นั้น​ไว้​แทนที่​จะ​ทิ้ง เพราะ​ยอม​ให้​มี​ค่า​ซ้ำ ดี​กว่า​ยอม​ให้​ค่า​ล่าสุด​หาย​ไป

ตาราง​ของ kaen-log เก็บ byte ของ key ตรงๆ ใน HashMap มัน​จึง​ชน​ไม่​ได้​เลย และ​ทุก record ที่​เดิน​มา​ถึง​บรรทัด​นั้น >= กับ == ให้​ผล​เท่า​กัน​หมด เปลี่ยน​เครื่องหมาย​เป็น == แล้ว​ผล​รัน​ของ​บท​นี้​ไม่​ขยับ​สัก byte

Kafka ไม่​ได้​ทำ​แบบ​นี้​เพราะ​เขียน​หลวม เอกสาร topic config อธิบาย min.cleanable.dirty.ratio ไว้​ว่า “This ratio bounds the maximum space wasted in the log by duplicates (at 50% at most 50% of the log could be duplicates)” นั่น​คือ​การ​ประกาศ​งบ​ของ​ค่า​ซ้ำ​ออก​มา​ตรงๆ ไม่ใช่​การ​สัญญา​ว่า​ไม่มี​ค่า​ซ้ำ

สอง​การ​ตรวจ​แรก​ใน function ก็​มี​ความ​หมาย​ของ​ตัว​เอง record ที่ offset สูง​กว่า​หัว​ที่​สรุป​ไว้​จะ​รอด​ทุก​ตัว เพราะ​มัน​อยู่​นอก​ช่วง​ที่​ตาราง​สรุป​ครอบ​ถึง และ record ที่​ไม่มี key ถูก​ทิ้ง​เสมอ

ข้อ​หลัง​ตรง​กับ​ที่ Kafka ปฏิเสธ​ตั้งแต่​ตอน produce เลย LogValidator.java ของ 4.1 คืน error พร้อม​ข้อความ “Compacted topic cannot accept message without key in topic partition” เพราะ record ที่​ไม่มี key ไม่มี​ทาง​ถูก​อ้าง​ถึง​ใน​ตาราง​สรุป มัน​จึง​ไม่มี​นิยาม​ของ​คำ​ว่า​ค่า​ล่าสุด

⚠ อย่า​อ้าง​ลำดับ​ระหว่าง key สอง​ตัว​ที่​ต่าง​กัน

สิ่ง​ที่ Kafka รับรอง​คือ​ข้อ​ที่​สอง “Compaction will never re-order messages, just remove some” กับ​ข้อ​ที่​สาม “The offset for a message never changes”

ทั้ง​สอง​ประโยค​พูด​ถึง​ลำดับ​ที่​ยัง​อยู่ ไม่​ได้​พูด​ถึง​ความ​สัมพันธ์​ระหว่าง key สอง​ตัว​หลัง​บาง​ตัว​หาย​ไป ไม่มี​แหล่ง​ต้นทาง​ไหน​รับรอง​ข้อ​นั้น และ​ประโยค​ทำนอง​ว่า “record ของ key A กับ key B ยัง​เรียง​เหมือน​เดิม​หลัง compact” เป็น​ประโยค​ที่​ต้อง​ไม่​เขียน ต่อ​ให้​มัน​ดู​จริง​แค่​ไหน​ก็ตาม

ผู้​อ่าน​ที่​เริ่ม​จาก​ต้น log อาจ​เห็น​ค่า​กลาง​ทาง​ของ key หนึ่ง หรือ​อาจ​ไม่​เห็น ขึ้น​กับ​ว่า​รอบ​ทำความ​สะอาด เดิน​ไป​ถึง​ไหน​ตอน​ที่​มัน​อ่าน ข้อ​อ้าง​ที่​ยืน​ได้​มี​ข้อ​เดียว​คือ​ค่า​สุดท้าย​ยัง​อยู่

หนึ่ง​รอบ​ทำความ​สะอาด​ของ kaen-log จึง​เดิน​ตาม​ลำดับ​ที่​ตายตัว

flowchart TB
    U["หาเพดาน · offset แรกที่ทำความสะอาดไม่ได้"] --> M["สรุปหัว log เป็นตาราง key ไปหา offset ล่าสุด"]
    M --> F["คัดผู้รอดออกจากทุกท่อนที่ต่ำกว่าเพดาน"]
    F --> W["เขียนผู้รอดลง file ที่ลงท้ายด้วย .cleaned"]
    W --> S["fsync แล้ว rename แล้ว fsync ที่ folder"]
    S --> R["log ใหม่ · offset เดิมทุกตัว มีช่องว่าง"]
    A["ท่อนที่กำลังเขียนอยู่"] -.->|"อยู่เหนือเพดาน ไม่เข้ากระบวนการ"| R

คำ​บรรยาย​ภาพ: ภาพ​นี้​เป็น​ลำดับ​ขั้น​ตอน ไม่ใช่​ผล​รัน · ท่อน​ที่​กำลัง​เขียน​อยู่​ไม่​ได้​เข้า​กระบวนการ​นี้​เลย มัน​เดิน​ลัด​ไป​อยู่​ใน log ใหม่​ตาม​เดิม และ​นั่น​คือ​เหตุผล​ที่​ค่า​ล่าสุด​ของ key หนึ่ง​มัก​ไม่​ได้​อยู่​ใน​ท่อน​ที่​ถูก​ทำความ​สะอาด

ขั้น​ตอน​ที่​สลับ file เข้าไป​แทน​ของ​เดิม​ใช้​ลำดับ​ที่​คอร์ส​ที่ 22 สอน​ไว้​แล้ว

src/clean.rs · เขียน​ให้​ครบ​ก่อน แล้ว​ค่อย​สลับ​เข้าไป​แทน
for (base, bytes) in &rewritten {
let prefix = segment_prefix(*base);
let cleaned = dir.join(format!("{prefix}{CLEANED_FILE_SUFFIX}"));
std::fs::write(&cleaned, bytes)?;
std::fs::File::open(&cleaned)?.sync_all()?;
std::fs::rename(&cleaned, dir.join(format!("{prefix}{LOG_FILE_SUFFIX}")))?;
// สารบัญเป็นของที่คำนวณใหม่ได้ · ลบทิ้งแล้วให้ตอนเปิดสร้างใหม่ ตามบทที่ 2
let index = dir.join(format!("{prefix}{INDEX_FILE_SUFFIX}"));
if index.exists() {
std::fs::remove_file(index)?;
}
}

สารบัญ​ของ​ท่อน​ถูก​ลบ​ทิ้ง​ไป​เลย ไม่​ได้​เขียน​ใหม่ เพราะ​มัน​คำนวณ​กลับ​มา​ได้​ทั้งหมด​ตอน​เปิด log ครั้ง​ถัด​ไป ซึ่ง​เป็น​ข้อ​ที่​บท​ที่ 2 วาง​ไว้

หัวข้อ​นี้​บอก​ว่า tombstone หน้าตา​เป็น​อย่างไร​บน wire แล้ว​โยง​กลับ​ไป​ที่​ค่า​ลบ​หนึ่ง​ของ​บท​ที่ 1

tombstonetombstonerecord พิเศษ​ที่​มาร์ค key ว่า​ถูก​ลบ ไม่​ได้​ลบ byte เดิม​ทันที · ใน broker คือ record ที่​ความ​ยาว​ของ value เป็น -1 ไม่ใช่ record ชนิด​ใหม่ และ​ไม่ใช่​ธง​ใน​หัว batch เอกสาร Design ของ Kafka 4.1 นิยาม​ไว้​สั้นๆ ว่า “A message with a key and a null payload will be treated as a delete from the log”

บท​ที่ 1 กาง​รูป​นี้​ไว้​แล้ว ความ​ยาว -1 ใน​ช่อง value_len แปล​ว่า null ซึ่ง​ไม่ใช่​ค่า​เดียว​กับ value ที่​ยาว​ศูนย์ byte

expected/06-clean.txt (ตัด​มา​บาง​ส่วน)
== tombstone · key มีอยู่ แต่ value เป็น null ==
เขียน OrderCancelled ของ ORD-1043 เป็น tombstone ที่ offset 21
value_len บน wire เป็น -1 ซึ่งไม่เหมือน value ที่ยาวศูนย์ byte
เขียนต่ออีก 6 record เพื่อดัน tombstone ให้พ้นท่อนที่กำลังเขียนอยู่
log end offset 28 · จำนวนท่อน 7
key ที่ยังมีชีวิตตอนนี้ ["ORD-1042", "ORD-1044"]

demo เขียน OrderCancelled ของ ORD-1043 เป็น tombstone แล้ว​เขียน​ต่อ​อีก​ชุด​หนึ่ง เพื่อ​ดัน tombstone ให้​พ้น​ท่อน​ที่​กำลัง​เขียน​อยู่ ไม่​งั้น​มัน​จะ​อยู่​เหนือ​เพดาน​และ​ไม่มี​รอบ​ไหน​ได้​แตะ​มัน​เลย

เส้น​ตาย​เริ่ม​เดิน​ตอน​ถูก​กวาด​รอบ​แรก ไม่ใช่​ตอน​ถูก​เขียน

หัวข้อ​ที่​มีชื่อ​ว่า “เส้น​ตาย​เริ่ม​เดิน​ตอน​ถูก​กวาด​รอบ​แรก ไม่ใช่​ตอน​ถูก​เขียน”

หัวข้อ​นี้​อธิบาย​ว่า​ทำไม tombstone ถึง​หาย​ไป​ทันที​ไม่​ได้ และ​นาฬิกา​ที่​นับ​ถอย​หลัง​ของ​มัน​เริ่ม​เดิน​เมื่อ​ไหร่

ถ้า tombstone หาย​ไป​ใน​รอบ​ที่​เห็น​มัน​ครั้ง​แรก ผู้​อ่าน​ที่​ตาม​ช้า​จะ​ไม่มี​ทาง​รู้​เลย​ว่า key นั้น​ถูก​ลบ มัน​จะ​เห็น​แค่​ค่า​เก่า​ค่า​หนึ่ง​แล้ว​เชื่อ​ว่า​นั่น​คือ​ค่า​ปัจจุบัน

เอกสาร topic config ของ Kafka 4.1 จึง​เรียก delete.retention.ms ว่า​เป็น​เส้น​ของ​ความ​ถูก​ต้อง ไม่ใช่​เส้น​ของ​พื้นที่ “This setting also gives a bound on the time in which a consumer must complete a read if they begin from offset 0 to ensure that they get a valid snapshot of the final stage” โดย​ค่า​ปริยาย​ของ​มัน​คือ 86400000

delete horizondelete horizonเส้น​เวลา​ที่ tombstone ต้อง​อยู่​ให้​พ้น​ก่อน​ถึง​จะ​ถูก​เก็บ​กวาด​ได้ มี​ไว้​ให้​ผู้​อ่าน​ที่​ตาม​ช้า​ได้​เห็น​การ​ลบ คือ​เวลา​ที่ tombstone ใน​ก้อน​นั้น​ถูก​ทิ้ง​ได้ และ​มัน​ถูก​เก็บ​ไว้​ใน batch เอง

src/clean.rs · ธง​ของ​เส้น​ตาย​คือ bit 6 ของ attributes
/// bit 6 ของ `attributes` · เมื่อมันถูกตั้ง `base_timestamp` ไม่ใช่ timestamp ของ record
/// ตัวแรกอีกต่อไป แต่เป็น **เวลาที่ tombstone ในก้อนนี้ถูกลบได้**
pub const DELETE_HORIZON_FLAG_MASK: i16 = 0x0040;

ค่า​นี้​ตรง​กับ DefaultRecordBatch.java ของ Kafka 4.1 ที่​ประกาศ​ไว้​ว่า private static final byte DELETE_HORIZON_FLAG_MASK = 0x40;

src/clean.rs · อ่าน​เส้น​ตาย​ออก​จาก​หัว batch
pub fn delete_horizon_ms(attributes: i16, base_timestamp: i64) -> Option<i64> {
if attributes & DELETE_HORIZON_FLAG_MASK != 0 { Some(base_timestamp) } else { None }
}

จุด​ที่​คน​พลาด​กัน​มาก​ที่สุด​คือ นาฬิกา​ของ​เส้น​ตาย​ไม่​ได้​เริ่ม​เดิน​ตอน​ที่ tombstone ถูก​เขียน MemoryRecords.java ของ Kafka 4.1 ตั้ง​ค่า​มัน​ตอน​กวาด ด้วย​บรรทัด deleteHorizonMs = filter.currentTime + filter.deleteRetentionMs; และ​ตั้ง​เฉพาะ​ก้อน​ที่​ยัง​ไม่​เคย​ถูก​ตั้ง

kaen-log ทำท่า​เดียวกัน​ใน​รอบ​ที่​เห็น tombstone ก้อน​นั้น​เป็น​ครั้ง​แรก

src/clean.rs · ประทับ​เส้น​ตาย​ใน​รอบ​แรก​ที่​เห็น tombstone (ตัด​มา​บาง​ส่วน)
let mut opts = BatchOptions::from_header(&batch.header);
// เข็มนาฬิกาของเส้นตายเริ่มเดินที่ **รอบทำความสะอาดรอบแรกที่เห็น tombstone**
// ไม่ใช่ตอนที่ tombstone ถูกเขียน · tombstone จึงรอดครบอย่างน้อยหนึ่งรอบเสมอ
if has_tombstone && !already_stamped {
opts.attributes |= DELETE_HORIZON_FLAG_MASK;
opts.base_timestamp = Some(now_ms + log.config.delete_retention_ms);
stats.horizons_stamped += 1;
}
out.extend_from_slice(&Batch::build_with(survivors, &opts)?.raw);

ผล​ที่​ตาม​มา​คือ tombstone รอด​ครบ​อย่าง​น้อยห​นึ่ง​รอบ​เสมอ เพราะ​รอบ​ที่​ประทับ​เส้น​ตาย​ให้​มัน คือ​รอบ​ที่​มัน​ยัง​ต้อง​อยู่ กิ่ง None ใน should_retain เป็น​ที่​ที่​ข้อ​นี้​ถูก​บังคับ

อายุ​จริง​ของ tombstone จึง​เป็น​ผล​บวก​ของ​สาม​ช่วง ไม่ใช่ delete.retention.ms เฉยๆ

  • เวลา​ที่​รอ​จนกว่า​มัน​จะ​ตกลง​มา​ต่ำ​กว่า​เพดาน คือ​พ้น​ท่อน​ที่​กำลัง​เขียน​อยู่
  • delete.retention.ms นับ​จาก​รอบ​แรก​ที่​กวาด​เจอ​มัน
  • เวลา​ที่​รอ​จน​รอบ​ทำความ​สะอาด​รอบ​ถัด​ไป​เดิน​มา​ถึง
🔁 กลไก​นี้​เพิ่ง​มา​ถึง Kafka ไม่​นาน และ​ของ​เดิม​พัง​ทั้ง​สอง​ทาง

ก่อน​จะ​มี​ธง​ใน​หัว batch Kafka ประมาณ​เวลา​ของ tombstone จาก​เวลา​แก้ไข​ล่าสุด​ของ file ท่อน​สุดท้าย ซึ่ง​เดา​สูง​ไป​ก็ได้ เดา​ต่ำ​ไป​ก็ได้

รายการ Improvement ของ release notes รุ่น 3.1.0 มี​บรรทัด KAFKA-8522 ชื่อ​ว่า “Tombstones can survive forever” อยู่​ด้วย นั่น​คือ​ทาง​ที่​มัน​พัง​ด้าน​หนึ่ง ส่วน​อีก​ด้าน​คือ tombstone หาย​เร็ว​เกิน​ไป​จน​แอป​พลาด​การ​ลบ

ข้อ​นี้​เป็น​หลัก​ฐาน​ว่า “เก็บ tombstone ไว้​สัก​พัก” ไม่ใช่​ราย​ละเอียด​ปลีก​ย่อย มัน​ต้อง​มี​ที่​เก็บ​เวลา​ที่​แน่นอน​อยู่​ใน​รูป​แบบ​บน disk ถึง​จะ​ทำได้​ถูก

หัวข้อ​นี้​ชี้​ผล​ข้าง​เคียง​ของ​การ​ยืม​ช่อง​เดิม​มา​เก็บ​เส้น​ตาย และ​เทสต์​ที่​กัน​ไม่​ให้ timestamp เพี้ยน

DELETE_HORIZON_FLAG_MASK ไม่​ได้​พา​ช่อง​ใหม่​มา​ด้วย มัน​เปลี่ยน​ความ​หมาย​ของ​ช่อง​ที่​มี​อยู่​แล้ว คอม​เมน​ต์​ของ DefaultRecordBatch.java เขียน​ไว้​ว่า “If the delete horizon flag is set to 1, the BaseTimestamp will be set to the time at which tombstone records and aborted transaction markers in the batch should be removed”

ที่​อันตราย​คือ base_timestamp ไม่​ได้​ทำ​หน้าที่​เก็บ​เวลา​เฉยๆ มัน​เป็น ฐาน​ที่ timestamp ของ​ทุก record ใน​ก้อน​ถูก​เก็บ​เป็น​ส่วน​ต่าง​จาก​มัน ย้าย​ฐาน​โดย​ไม่​คิด delta ใหม่ เท่ากับ​เลื่อน​เวลา​ของ​ทุก record ใน​ก้อน

Kafka เขียน​ข้อ​นี้​ไว้​เป็น​คอม​เมน​ต์​ใน MemoryRecords.java เหมือน​กัน ว่า “in which case, we need to reset the base timestamp and overwrite the timestamp deltas”

kaen-log ไม่​ได้ copy ธง​แล้ว​จบ มัน​ส่ง record ที่​รอด​กลับ​เข้า Batch::build_with ซึ่ง​คิด delta ใหม่ จาก​ฐาน​ใหม่​ให้​ทั้ง​ก้อน และ​มี​เทสต์​ยืนยัน​ว่า​ค่าที่ decode กลับ​ออก​มายัง​ตรง

src/clean.rs · เทสต์​ที่​ยืนยัน​ว่า timestamp ไม่​เพี้ยน​หลัง​ประทับ​เส้น​ตาย
/// เส้นตายขี่มาใน `base_timestamp` · timestamp ของ record ต้องยัง decode กลับถูก
#[test]
fn stamping_a_horizon_does_not_corrupt_record_timestamps() {
let dead = Record::tombstone(5, 1_700_000_000_000, "ORD-1");
let opts = BatchOptions {
attributes: DELETE_HORIZON_FLAG_MASK,
base_timestamp: Some(1_800_000_000_000),
..BatchOptions::producer_defaults()
};
let batch = Batch::build_with(vec![dead.clone()], &opts).unwrap();
let back = Batch::decode(&batch.raw).unwrap();
assert_eq!(back.header.base_timestamp, 1_800_000_000_000);
assert_eq!(back.records[0].timestamp_ms, 1_700_000_000_000);
assert_eq!(
delete_horizon_ms(back.header.attributes, back.header.base_timestamp),
Some(1_800_000_000_000)
);
}

เทสต์​ตัว​นี้​ตรวจ​สอง​อย่าง​พร้อม​กัน base_timestamp ที่​อ่าน​กลับ​มา​ต้อง​เป็น​เส้น​ตาย ส่วน timestamp_ms ของ record ต้อง​เป็น​เวลา​เดิม​ของ record ไม่ใช่​เส้น​ตาย

หัวข้อ​นี้​อ่าน​ตาราง​ของ​สอง​รอบ แล้ว​บอก​ว่า​ตัวเลข​นั้น​พิสูจน์​อะไร และ​ไม่​ได้​พิสูจน์​อะไร

--passes 2 สั่ง​ให้ demo กวาด​สอง​รอบ​ด้วย --now-ms ค่า​เดิม

expected/06-clean.txt (ตัด​มา​บาง​ส่วน)
== compaction · 2 รอบด้วย now-ms เดิม ==
รอบ uncleanable key ในหัว ท่อนที่ทำ record ก่อน → หลัง byte ก่อน → หลัง tombstone เก็บ/ทิ้ง ประทับเส้นตาย
1 25 3 6 19 → 3 2488 → 347 1/0 1
2 25 3 6 3 → 3 347 → 347 1/0 0
รอบที่สองขึ้นไปไม่เปลี่ยนอะไรเลย · เพราะ now-ms ถูกฉีดเข้ามา ไม่ได้อ่านจากเครื่อง
key ที่ยังมีชีวิตหลัง compact ["ORD-1042", "ORD-1044"]
tombstone ยังอยู่ เพราะ now-ms ยังไม่ถึงเส้นตายที่ประทับไว้ในรอบแรก

รอบ​แรก​ลด record จาก 19 เหลือ 3 และ​ลด byte จาก 2488 เหลือ 347 รอบ​ที่​สอง​อ่าน​ค่า​เดิม​ทุก​ช่อง

สิ่ง​ที่​ตัวเลข​คู่​นี้​พิสูจน์​คือ idempotence กวาด​ซ้ำ​บน log ที่​กวาด​ไป​แล้ว​ต้อง​ไม่​เปลี่ยน​อะไร ถ้า​มัน​ยัง​เปลี่ยน​อยู่ แปล​ว่า​กติกา​การ​คัด​ผู้​รอด​ขึ้น​กับ​สถานะ​ของ file ไม่ใช่​ขึ้น​กับ​ข้อมูล

สิ่ง​ที่​มัน​ไม่​ได้​พิสูจน์​คือ​ความ​นิ่ง​ข้าม​การ​รัน สอง​อย่าง​นี้​คนละ​ข้อ​อ้าง ความ​นิ่ง​ข้าม​การ​รัน​เป็น​งาน​ของ​เทสต์​ที่​รัน​คำ​สั่ง​เดิม​ซ้ำ​หลาย​รอบ​แล้ว​เทียบ​ผล​ที​ละ byte ไม่ใช่​งาน​ของ​ธง​นี้

ช่อง tombstone เก็บ/ทิ้ง เป็น 1/0 ทั้ง​สอง​รอบ tombstone จึง​ยัง​อยู่​หลัง​กวาด​สอง​รอบ เพราะ --now-ms ยัง​ไม่​ถึง​เส้น​ตาย​ที่​ประทับ​ไว้​ใน​รอบ​แรก

demo เดิน​นาฬิกา​ข้าม​เส้น​ตาย​ด้วย​การ​เรียก​รอบ​ที่​สาม​ด้วย now_ms ที่​บวก delete.retention.ms ไป​แล้ว

expected/06-clean.txt (ตัด​มา​บาง​ส่วน)
== เดินนาฬิกาข้ามเส้นตาย · now-ms + delete.retention.ms + 1 ==
tombstone ที่เก็บไว้ 0 · ที่ทิ้ง 1
อายุจริงของ tombstone = เวลาที่รอจนถูกทำความสะอาดได้ + delete.retention.ms
+ เวลาที่รอรอบถัดไป · ไม่ใช่ delete.retention.ms เฉยๆ

compact ตัว​เดียวกัน log ก้อน​เดียวกัน ต่าง​กัน​แค่​ค่า​นาฬิกา​ที่​ฉีด​เข้าไป นี่​คือ​เหตุผล​ที่​ทั้ง crate ห้าม​อ่าน​นาฬิกา​ของ​เครื่อง ถ้า should_retain เรียก​นาฬิกา​เอง ผล​รัน​ของ​บท​นี้​จะ​ต่าง​กัน​ทุก​ครั้ง​และ​ไม่มี​อะไร​ให้​เทียบ

หัวข้อ​นี้​แสดง​ว่า​อ่าน​ที่ offset ที่​ถูก compact ไป​แล้ว​ได้​อะไร และ​ข้อ​จำกัด​นี้​ย้อน​ไป​ถึง​บท​ที่ 4 อย่างไร

เอกสาร Design ของ Kafka 4.1 เขียน​ข้อ​นี้​ไว้​ยาว และ​ทุก​ประโยค​มี​ผล​กับ code “the messages in the tail of the log retain the original offset assigned when they were first written—that never changes” ตาม​ด้วย “all offsets remain valid positions in the log, even if the message with that offset has been compacted away”

ประโยค​หลัง​คือ​ส่วน​ที่​คน​มอง​ข้าม ช่อง​ว่าง​ไม่ใช่ error offset ที่ record หาย​ไป​แล้ว​ยัง​เป็น​ตำแหน่ง ที่​ขอ​ได้ และ​สิ่ง​ที่​ได้​กลับ​มา​คือ record ตัว​ถัด​ไป​ที่​ยัง​อยู่

expected/06-clean.txt (ตัด​มา​บาง​ส่วน)
== offset ไม่ขยับ ช่องว่างยังอยู่ ==
offset ที่ยังมี record อยู่จริงในท่อนที่เสิร์ฟคำขอนี้ [23, 24]
log end offset ยังเป็น 28 เท่าเดิม · compaction ไม่เคยแจก offset ใหม่
อ่านที่ offset 6 ซึ่งถูก compact ไปแล้ว → ได้ record ตัวถัดไปที่ Some(23)
offset ทุกตัวยังเป็นตำแหน่งที่ถูกต้อง แม้ record ตรงนั้นจะไม่มีแล้ว
ห้ามอ้างลำดับระหว่าง key สองตัวที่ต่างกัน · ไม่มีแหล่งต้นทางไหนรับรองข้อนั้น
สิ่งที่ Kafka รับรองคือ compaction ไม่เคยสลับลำดับ แค่เอาบางตัวออก

demo เลือก offset ที่​หาย​ไป​จริง​มา​ขอ แล้ว​พิมพ์​ว่า​ได้​อะไร​กลับ​มา ถ้า​มัน​คืน​ค่า​ว่าง บรรทัด​นั้น​จะ​เป็น​ข้อ​อ้าง​ที่​ผล​รัน​หัก​ล้าง​เอง demo จึง​หยุด​พร้อม error ถ้า​ไม่มี​ช่อง​ว่าง​เลย

ข้อ​นี้​ย้อน​กลับ​ไป​ผูก​กับ​บท​ที่ 4 โดยตรง การ​อ่าน​ของ kaen-log ไม่​ได้​แม็ป offset ไปหา record ตัว​หนึ่ง แบบ​หนึ่ง​ต่อ​หนึ่ง มัน​แม็ป offset ไปหา ตำแหน่ง​เริ่ม​อ่าน แล้ว​เดิน​ไป​ข้าง​หน้า โครง​แบบ​นั้น​รองรับ​ช่อง​ว่าง​อยู่​แล้ว​ตั้งแต่​ต้น โดย​ไม่​ต้อง​เพิ่ม​อะไร​ใน​บท​นี้​เลย

หัวข้อ​นี้​ชี้ field ใน​หัว batch ที่​การ​เขียน​ใหม่​ต้อง​คง​ไว้ และ​บอก​ว่า​บท​ไหน​เป็น​คน​อ่าน​มัน​กลับ

การ​เขียน​ท่อน​ขึ้น​ใหม่​คือ​การ​สร้าง batch ขึ้น​ใหม่ และ​ตรง​นั้น​มี​กับ​ดัก​ที่​ไม่​ฟ้อง​ตอน compile

Batch::build ตั้ง producer id, producer epoch และ base sequence กลับ​เป็น​ลบ​หนึ่ง เพราะ​มัน​คือ ทาง​ที่ producer ธรรมดา​ใช้ ส่วน​ตัว​ทำความ​สะอาด​ต้อง​ใช้ Batch::build_with แล้ว​ส่ง​ค่า​เดิม​กลับ​เข้าไป

src/clean.rs · เทสต์​ที่​ตรึง​สาม field ของ producer ไว้
/// compaction ต้องคง first/last sequence ไว้ ไม่งั้นสถานะ producer ของบทที่ 7 พัง
#[test]
fn rewriting_a_batch_preserves_the_producer_fields() {
let opts = BatchOptions {
producer_id: 1000,
producer_epoch: 3,
base_sequence: 7,
partition_leader_epoch: 2,
..BatchOptions::producer_defaults()
};
let batch =
Batch::build_with(vec![Record::new(0, 1, "ORD-1", b"v".to_vec())], &opts).unwrap();
let again =
Batch::build_with(batch.records.clone(), &BatchOptions::from_header(&batch.header))
.unwrap();
assert_eq!(again.header.producer_id, 1000);
assert_eq!(again.header.producer_epoch, 3);
assert_eq!(again.header.base_sequence, 7);
assert_eq!(again.header.partition_leader_epoch, 2);
}

สาม field นั้น​คือ​สิ่ง​ที่​บท​ที่ 7 อ่าน​กลับ​ตอน​สร้าง​สถานะ​ของ producer ขึ้น​ใหม่​หลัง restart ถ้า compaction ล้าง​มัน​ทิ้ง หน้าต่าง​กัน​ซ้ำ​จะ​ว่าง​เปล่า​และ producer เดิม​จะ​ส่ง​ซ้ำ​เข้า​มา​ได้​อีก

Kafka บอก​ข้อ​เดียวกัน​นี้​ใน​เอกสาร​รูป​แบบ​ของ​มัน​เอง ว่า​รุ่น v2 ขึ้น​ไป​คง first และ last offset กับ sequence ของ batch เดิม​ไว้​หลัง​กวาด และ​อาจ​เก็บ​หัว batch ที่​ไม่​เหลือ record ไว้​ทั้ง​ก้อน​เพื่อ​รักษา​ข้อมูล​ลำดับ​ของ producer

นี่​คือ​รอย​ต่อ​ที่​พัง​ตอน​รัน ไม่ใช่​ตอน​อ่าน ทั้ง build และ build_with คืน Batch เหมือน​กัน เทสต์​ตัว​ข้าง​บน​จึง​มี​ไว้​ตรึง​ข้อ​นี้​ไว้​ที่​บท​นี้ ไม่ใช่​ปล่อย​ให้​บท​ที่ 7 ไป​เจอ​เอง

หัวข้อ​นี้​เอา partition ภายใน​ของ​บท​ที่ 5 มา​เข้า​เครื่อง​เดียวกัน แล้ว​ดู​ว่า​อะไร​เกิด​ขึ้น

บท​ที่ 5 สร้าง partition ภายใน​ชื่อ __offsets ไว้​เก็บ offset ที่​แต่ละ group commit แล้ว มัน​ตั้ง cleanup.policy เป็น compact และ​นั่น​คือ​ทุก​อย่าง​ที่​มัน​ต้อง​ทำ

expected/06-clean.txt (ตัด​มา​บาง​ส่วน)
== __offsets ถูกทำความสะอาดด้วยตัวเดียวกัน ไม่มีกรณีพิเศษ ==
record ที่เขียนไปทั้งหมด 12 · key ที่ยังมีชีวิต 3
ในท่อนที่ทำความสะอาดได้ · record 8 → 2 · key ที่ยังมีชีวิต 3 → 3
log end offset 12 (ไม่ขยับ)
ตัวทำความสะอาดไม่รู้เลยว่านี่คือ partition ภายใน · มันเห็นแค่ topic ที่ตั้ง compact

บรรทัด​กลาง​อ่าน​ว่า “ใน​ท่อน​ที่​ทำความ​สะอาด​ได้” ด้วย​เหตุผล ค่า​ล่าสุด​ของ key หนึ่ง​มัก​นั่ง​อยู่​ใน ท่อน​ที่​กำลัง​เขียน​อยู่ ซึ่ง​ไม่มี​วัน​ถูก​กวาด ตัวเลข​ที่​ลด​ลง​จึง​เป็น​ตัวเลข​ของ​ท่อน​เก่า​เท่านั้น ไม่ใช่​ของ​ทั้ง partition

จำนวน key ที่​ยัง​มี​ชีวิต​เท่า​เดิม​ก่อน​และ​หลัง และ demo หยุด​พร้อม error ถ้า​มัน​ไม่​เท่า เพราะ compaction ที่​ทำ key หาย​คือ bug ไม่ใช่​การ​ประหยัด​พื้นที่

ประโยค​สุดท้าย​ของ​บล็อก​คือ​ประเด็น​ของ​หัวข้อ​นี้ ตัว​ทำความ​สะอาด​ไม่มี if ที่​ถาม​ว่า​ชื่อ topic ขึ้น​ต้น​ด้วย​ขีด​ล่าง​สอง​ตัว​หรือ​ไม่ มัน​เห็น​แค่ LogConfig ที่​ตั้ง cleanup_compact ไว้

ของ​เล่น​ตัว​นี้​เก่ง​กว่า​และ​ง่าย​กว่า Kafka คนละ​ที่

หัวข้อ​ที่​มีชื่อ​ว่า “ของ​เล่น​ตัว​นี้​เก่ง​กว่า​และ​ง่าย​กว่า Kafka คนละ​ที่”

หัวข้อ​นี้​แยก​สิ่ง​ที่ kaen-log ทำได้​ดี​กว่า ออก​จาก​สิ่ง​ที่​มัน​ตัด​ทิ้ง

ตาราง​สรุป​หัว log ของ kaen-log เก็บ key เต็มๆ ใน HashMap ส่วน Kafka เก็บ hash ของ key ที่​ถูก​ย่อ​แล้ว โดย​เอกสาร Design ระบุ​ว่า​มัน​ใช้​พื้นที่ 24 byte ต่อ entry

โครง​แบบ​ของ Kafka ชน​กัน​ได้ และ​เมื่อ​ชน record ที่​ยัง​มี​ชีวิต​อาจ​หลุด​หาย​ไป kaen-log จึง ถูก​ต้อง​กว่า​ตรง​นี้ ไม่ใช่​ง่าย​กว่า และ​เหตุผล​ก็​ตรง​ไป​ตรง​มา ของ​เล่น​ไม่​ต้อง​กวาด log ที่​ใหญ่​เกิน​หน่วย​ความ​จำ

ส่วน​ที่ kaen-log ตัด​ทิ้ง​จริงๆ มี​สี่​ข้อ

  • ไม่มี min.cleanable.dirty.ratio ของ​จริง​ใช้​อัตราส่วน​นี้​เลือก​ว่า​จะ​กวาด partition ไหน​ก่อน โดย​ตัว​ทำความ​สะอาด​เป็น​ตัวกลาง​ที่​ดู​ทุก partition พร้อม​กัน ส่วน demo นี้​สั่ง​กวาด​ตรงๆ
  • ไม่มี​การ​จำกัด​อัตรา Kafka หรี่​ปริมาณ I/O ของ​การก​วาด​ได้ เพื่อ​ไม่​ให้​ไป​แย่ง​ทาง​ของ producer
  • ไม่มี max.compaction.lag.ms ค่า​นี้​มี​ไว้​บังคับ​ให้​ของ​ที่​ต้อง​ลบ​ตาม​กฎหมาย​ถูก​กวาด​ใน​เวลา​ที่​กำหนด ต่อ​ให้​อัตราส่วน​ยัง​ไม่​ถึง​เกณฑ์​ก็ตาม
  • ไม่​ได้​ทำ​ตาม​ประโยค “Cleaning does not block reads” ของ​เอกสาร Design เพราะ compact รับ &mut Log ผู้​เรียก​จึง​ถือ lock ของ log ไว้​ทั้ง​รอบ และ​คำขอ fetch ที่​ต้อง​ใช้ lock เดียวกัน ตอน​วางแผน​อ่าน​ก็​ค้าง​รอ​ไป​ด้วย

ที่ compact เขียน .cleaned ให้​ครบ​ก่อน​แล้ว​ค่อย​สลับ เป็น​เรื่อง​ของ​ความ​ปลอดภัย​ตอน​เครื่อง​ดับ ตาม​ลำดับ fsync → rename → fsync ที่ folder ซึ่ง​คอร์ส​ที่ 22 วาง​ไว้ ไม่ใช่​เรื่อง​ของ​เวลา​ที่​ถือ lock

บท​ที่ 7 จะ​รับ field ของ producer ที่​บท​นี้​อุตส่าห์​รักษา​ไว้ ไป​สร้าง​หน้าต่าง​กัน​ซ้ำ แล้ว​ตอบ​คำถาม​ว่า exactly-once เป็น​ข้อ​อ้าง​ที่​จริง​ใน​ขอบเขต​แค่​ไหน


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • Apache Kafka 4.1 — Design (ตรวจ​แล้ว 2026-09-03) — ข้อ​รับ​ประกัน​สี่​ข้อ​ของ compaction รวม​ทั้ง​ประโยค at least the final state ประโยค​ที่​ว่า offset ไม่​เคย​เปลี่ยน​และ​ช่อง​ว่าง​ยัง​เป็น​ตำแหน่ง​ที่​ถูก​ต้อง นิยาม​ของ tombstone ประโยค​ที่​ยืนยัน​ว่า​ท่อน​ที่​กำลัง​เขียน​อยู่​ไม่​ถูก compact ประโยค Cleaning does not block reads และ​ตัวเลข 24 byte ต่อ entry ของ​ตาราง​สรุป​หัว log ที่ Kafka ใช้
  • Apache Kafka 4.1 — Implementation › Log (ตรวจ​แล้ว 2026-09-03) — ประโยค Data is deleted one log segment at a time และ​ประโยค​ที่​ระบุ​ว่า timestamp ที่​มาก​ที่สุด​ของ​ท่อน​เป็น​ตัว​กำหนด​อายุ​ของ​ทั้ง​ท่อน
  • Apache Kafka 4.1 — Topic Configs (ตรวจ​แล้ว 2026-09-03) — คำ​อธิบาย​ของ cleanup.policy ที่​ระบุ​ว่า​รับ​ได้​เป็น list และ​ค่า​ปริยาย​คือ delete · retention.ms ปริยาย 604800000 · delete.retention.ms ปริยาย 86400000 พร้อม​ประโยค​ที่​บอก​ว่า​มัน​เป็น​ขอบ​ของ​ความ​ถูก​ต้อง · segment.bytes ที่​รับ​ค่า​ได้​ตั้งแต่ 1048576 ขึ้น​ไป · และ​ประโยค​เรื่อง​งบ​ของ​ค่า​ซ้ำ​ใน min.cleanable.dirty.ratio
  • UnifiedLog.java branch 4.1 (ตรวจ​แล้ว 2026-09-03) — เงื่อนไข startMs - segment.largestTimestamp() > retentionMs และ​คอม​เมน​ต์​เรื่อง high watermark ที่​กัน​ไม่​ให้ log start offset แซง​มัน​ไป
  • LogCleanerManager.java branch 4.1 (ตรวจ​แล้ว 2026-09-03) — คอม​เมน​ต์ We cannot clean past ที่​ระบุ​เพดาน​สาม​ชั้น และ​การ​เอา​ค่าที่​น้อย​ที่สุด​ใน​สาม​ค่า​นั้น
  • DefaultRecordBatch.java branch 4.1 (ตรวจ​แล้ว 2026-09-03) — DELETE_HORIZON_FLAG_MASK = 0x40 และ​คอม​เมน​ต์​ที่​ระบุ​ว่า​เมื่อ​ธง​นี้​ถูก​ตั้ง BaseTimestamp จะ​กลาย​เป็น​เวลา​ที่ tombstone ใน​ก้อน​นั้น​ถูก​ลบ​ได้ · และ​คอม​เมน​ต์ On Compaction ที่​ระบุ​ว่า magic v2 ขึ้น​ไป​คง first และ last offset กับ sequence ของ batch เดิม​ไว้​หลัง​กวาด และ​อาจ​เก็บ​หัว batch ที่​ไม่​เหลือ record ไว้​เพื่อ​รักษา​ข้อมูล​ลำดับ​ของ producer
  • MemoryRecords.java branch 4.1 (ตรวจ​แล้ว 2026-09-03) — บรรทัด​ที่​ตั้ง​เส้น​ตาย​เป็น filter.currentTime + filter.deleteRetentionMs เฉพาะ​ก้อน​ที่​ยัง​ไม่​เคย​ถูก​ตั้ง และ​คอม​เมน​ต์​ที่​บอก​ว่า​ต้อง​เขียน timestamp delta ใหม่​ทั้ง​ก้อน​ตอน​ย้าย​ฐาน
  • LogConfig.java branch 4.1 (ตรวจ​แล้ว 2026-09-03) — สอง field public final boolean compact; กับ public final boolean delete; ที่​ยืนยัน​ว่าน​โย​บาย​สอง​ข้อ​นี้​แยก​จาก​กัน​จริง
  • LogValidator.java branch 4.1 (ตรวจ​แล้ว 2026-09-03) — จุด​ที่ Kafka ปฏิเสธ record ที่​ไม่มี key บน topic ที่​ตั้ง compact ตั้งแต่​ตอน produce
  • ServerLogConfigs.java branch 4.1 (ตรวจ​แล้ว 2026-09-03) — log.retention.check.interval.ms ที่​เป็น​รอบ​เดิน​มา​ตรวจ (LOG_CLEANUP_INTERVAL_MS_DEFAULT = 5 * 60 * 1000L) กับ file.delete.delay.ms ที่​เป็น​เวลา​รอ​ก่อน​เอา file ออก​จาก disk จริง
  • LogFileUtils.java branch 4.1 (ตรวจ​แล้ว 2026-09-03) — DELETED_FILE_SUFFIX = ".deleted" ซึ่ง​เป็น​ชื่อ​ที่​ท่อน​ถูก rename ไป​ก่อน
  • Cleaner.java branch 4.1 (ตรวจ​แล้ว 2026-09-03) — วิธี​เก่า​ที่​ประมาณ​เส้น​ตาย​จาก​เวลา​แก้ไข​ล่าสุด​ของ​ท่อน​สุดท้าย segments.get(segments.size() - 1).lastModified() - log.config().deleteRetentionMs ซึ่ง​ยัง​อยู่​สำหรับ batch รุ่น​ก่อน v2
  • KIP-354 · Add a Maximum Log Compaction Lag (ตรวจ​แล้ว 2026-09-03) — เหตุผล​ของ max.compaction.lag.ms ที่​เขียน​ไว้​ว่า “Ability to delete a record through compaction in a timely manner has become an important requirement in some use cases (e.g., GDPR)”
  • Apache Kafka 3.1.0 — Release Notes (ตรวจ​แล้ว 2026-09-03) — รายการ Improvement ที่​มี KAFKA-8522 ชื่อ Tombstones can survive forever ซึ่ง​เป็น​รุ่น​ที่​กลไก​เส้น​ตาย​แบบ​เก็บ​ไว้​ใน​หัว batch มา​ถึง

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

ข้อ 1 / 3

topic หนึ่งตั้ง cleanup.policy เป็น compact แล้วกวาดจนนิ่ง ข้อไหนเป็นข้ออ้างที่ยืนได้