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

อ่าน​จาก offset ที่​ขอ แล้ว​จอด​คำขอ​ไว้​แทนที่​จะ​ตอบ​ว่าง

ผู้​อ่าน​ราย​หนึ่ง​หาย​ไป​หนึ่ง​สัปดาห์​แล้วก​ลับ​มา มัน​ขอ record ที่ offset 7

broker ไม่​ต้อง​ไป​เปิด​ตาราง​ว่า​ผู้​อ่าน​ราย​นี้​เคย​อ่าน​ถึง​ไหน เพราะ​มัน​ไม่มี​ตาราง​นั้น ตัวเลข 7 มา​พร้อม​คำขอ และ​นั่น​คือ​สถานะ​ทั้งหมด​ที่​มี​อยู่

ผล​ที่​ตาม​มา​มี​สอง​ข้อ ข้อ​แรก​คือ​การ​ย้อน​กลับ​ไป​อ่าน​ใหม่​ไม่มี​ราคา ข้อ​ที่​สอง​แพง​กว่า​นั้น เมื่อ​ผู้​อ่าน​ตาม​ทัน​ปลาย log แล้ว broker ไม่มี​อะไร​จะ​ตอบ และ​มัน​เลือก​ที่​จะ​ไม่​ตอบ​ว่าง

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

บล็อก code ใน​บท​นี้​ถูก​คัด​มา​จาก scripts/log-broker/src/fetch.rs เป็น​หลัก และ​แตะ​อีก​สอง file ที่​ไม่มี​บท​เป็น​เจ้าของ คือ src/partition.rs กับ src/pool.rs บท​นี้​เป็น​บท​เดียว​ของ​คอร์ส​ที่​ยก src/pool.rs มา​แสดง​ได้

ผล​รัน​ทุก​บรรทัด​มา​จาก scripts/log-broker/expected/04-fetch.txt ซึ่ง​เป็น​ผล​ของ​คำ​สั่ง​เดียว คือ klog fetch-demo --dir data/orders-0 --fetch-offset 7 --min-records 2 --max-wait-ms 500 --steps 4

ทั้ง​สอง​ฝั่ง​ถูก​เทียบ​ที​ละ byte ด้วย​เทสต์ ถ้า​แก้​ใน​บท​เรียน​อย่าง​เดียว​โดย​ไม่​แก้​ที่​ต้นทาง เทสต์​จะ​แดง​ทันที

โดเมน​ยัง​เป็น​ของ​เดิม คือ topic orders และ​ผู้​อ่าน​สาม​ราย​ที่​บท​ที่ 1 แนะนำ​ไว้ คือ kitchen courier กับ analytics

หัวข้อ​นี้​แสดง​ว่า broker ไม่​ถือ​สถานะ​ของ​ผู้​อ่าน​ไว้​เลย และ​ดู​จาก field ที่​คำขอ​จริง​บรรทุก​มา

คำขอ fetch ของ Kafka บรรทุก​ของ​สาม​อย่าง​ที่​บท​นี้​สนใจ

  • MaxWaitMs — "The maximum time in milliseconds to wait for the response."
  • MinBytes — "The minimum bytes to accumulate in the response."
  • FetchOffset ต่อ​หนึ่ง partition ชนิด int64 และ​คำ​อธิบาย​ของ​มัน​สั้น​มาก คือ "The message offset."

สอง​ตัว​แรก​อยู่​ระดับ​คำขอ ส่วน​ตัว​ที่​สาม​อยู่​ระดับ partition คำขอ​เดียว​จึง​ขอ​หลาย partition พร้อม​กัน​ได้ โดย​แต่ละ partition พก​ตำแหน่ง​ของ​ตัว​เอง​มา

ไม่มี field ไหน​ใน​คำขอ​ที่​บอก​ว่า “ฉัน​คือ​ใคร” สำหรับ​จุด​ประสงค์​ของ​การ​อ่าน broker จึง​ไม่​ต้อง​จำ​อะไร​ระหว่าง​คำขอ​สอง​ครั้ง​เลย

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

บท​ที่ 5 จะ​พูด​ถึงที่​ที่​ผู้​อ่าน​ฝากตัว​เลข​นั้น​ไว้​ได้ เมื่อ​มัน​ไม่​อยาก​ถือ​เอง​ข้าม​การ​รี​ส​ตาร์ต แต่​ตัวเลข​ที่​ฝาก​ไว้​กับ​ตัวเลข​ที่​พก​มา​กับ​คำขอ ยัง​เป็น​คนละ​ตัว​อยู่ดี

หัวข้อ​นี้​บอก​ว่า​ผู้​อ่าน​คนละ​ชนิด​เห็น log ได้​ไม่​เท่า​กัน และ​ค่าที่​ตัดสิน​คือ​อะไร

fetch isolationfetch isolationกติกา​ว่า​ผู้​อ่าน​เห็น​ได้​ถึง offset ไหน · ผู้​อ่าน​ทั่วไป​เห็น​ถึง high watermark ไม่ใช่​ถึง​ปลาย log ของ leader คือ​กติกา​ว่า​คำขอ​หนึ่ง​อ่าน​ได้​ถึง offset ไหน Kafka ตัดสิน​ด้วย​การ​แตก​สาม​ทาง​ที่​เดียว​ใน FetchIsolation.of ซึ่ง​เขียน​เป็น if else if else

ถ้า​คำขอ​ไม่​ได้​มา​จาก consumer เพดาน​คือ LOG_END ถ้า​มา​จาก consumer ที่​ขอ READ_COMMITTED เพดาน​คือ TXN_COMMITTED นอก​นั้น​คือ HIGH_WATERMARK

และ field ที่​แยก follower ออก​จาก consumer คือ ReplicaId ตัว​เดียว คำ​อธิบาย​ของ​มัน​ใน file ของ protocol เขียน​ว่า "The broker ID of the follower, of -1 if this request is from a consumer."

kaen-log สร้าง​สอง​ใน​สาม​ทาง​นั้น ทาง​ที่​สาม​มี​แต่​ชื่อ บท​ที่ 7 อธิบาย​กลไก​ของ​มัน​ไว้ แต่​ไม่มี​บท​ไหน​สร้าง

src/fetch.rs · เพดาน​สอง​แบบ กับ field เดียว​ที่​แยก​มัน​ออก​จาก​กัน
/// เพดานที่สามของ Kafka คือ last stable offset · บทที่ 7 อธิบายกลไกของมัน แต่ไม่มีบทไหนสร้าง
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FetchIsolation {
LogEnd,
HighWatermark,
}
/// field เดียวที่แยก follower fetch ออกจาก consumer fetch คือ `replica_id`
/// · `-1` แปลว่ามาจาก consumer
pub fn isolation_of(replica_id: i32) -> FetchIsolation {
if replica_id >= 0 { FetchIsolation::LogEnd } else { FetchIsolation::HighWatermark }
}
fn ceiling(log: &Log, iso: FetchIsolation) -> u64 {
match iso {
FetchIsolation::LogEnd => log.log_end_offset(),
FetchIsolation::HighWatermark => log.high_watermark(),
}
}

ceiling เป็น​ที่​เดียว​ที่​ค่า​ของ enum กลาย​เป็น​ตัวเลข ทาง​อ่าน​ที่​เหลือ​รับ​ตัวเลข​นั้น​มา​ใช้​อย่าง​เดียว ไม่มี​ที่ไหน​ตัดสิน​เพดาน​ซ้ำ​อีก

⚠ ตอน​นี้​สอง​เพดาน​ให้​ผล​เท่า​กัน และ​นั่น​ไม่ใช่​ข้อ​อ้าง​ของ​บท​นี้

demo พิมพ์​ทั้ง​สอง​ทางออก​มา​ให้​ดู แล้ว​บอก​เอง​ว่า​มัน​ยัง​แยก​กัน​ไม่​ออก

kaen-log ยัง​มี replica เดียว high watermark จึง​เดิน​ตาม log end ทุก​ก้าว เพดาน​สอง​ตัว​จึง​ชี้​ไป​ที่​เลข​เดียวกัน​เสมอ

บท​ที่ 8 คือ​บท​ที่​ทำให้​มัน​ต่าง​กัน​จริง จนกว่า​จะ​ถึง​ตอน​นั้น HighWatermark คือ LogEnd ที่​ใส่​ชื่อ​อื่น และ​บท​นี้​ไม่​อ้าง​มากกว่า​นั้น

expected/04-fetch.txt (ตัด​มา​บาง​ส่วน)
== field เดียวที่แยก follower ออกจาก consumer ==
replica_id -1 → HighWatermark (consumer อ่านได้ถึง high watermark เท่านั้น)
replica_id 2 → LogEnd (follower อ่านได้ถึง log end)
follower อ่านจาก offset 0 ได้ record 3 ตัว
จนกว่าบทที่ 8 จะมาถึง high watermark เดินตาม log end ทุกก้าว
สองเพดานนี้จึงยังให้ผลเท่ากัน · HighWatermark ตอนนี้คือ LogEnd ที่ใส่ชื่ออื่น

หัวข้อ​นี้​กาง​ว่า read_range ถือ lock ช่วง​ไหน และ​ปล่อย​ก่อน​อะไร

ทาง​อ่าน​ของ kaen-log ต่อย​อด​จาก​การ​อ่าน​สอง​จังหวะ​ที่​บท​ที่ 2 วาง​ไว้ คือ​หา entry ที่​ใกล้​ที่สุด​ใน​สารบัญ แล้ว​สแกน​ต่อ​จาก​จุด​นั้น บท​นี้​ไม่​สอน​ซ้ำ แต่​รับ​ผล​ของ​มัน​มา​ใช้

สิ่ง​ที่​บท​นี้​เพิ่ม​เข้าไป​คือ​คำถาม​ว่า ระหว่าง​ที่​อ่าน byte จาก disk อยู่​นั้น ใคร​ถูกกัน​ออก​ไป​บ้าง

คำ​ตอบ​ที่​ผู้​อ่าน​ชอบ​สรุป​เอง​คือ “ไม่​ต้อง​ถือ lock เลย” ซึ่ง​ไม่​จริง read_range ถือ lock ของ Log จริง แต่​ถือ​แค่​ช่วง​สั้นๆ ช่วง​เดียว

src/fetch.rs · ถือ lock แค่​ตอน​วางแผน แล้ว​อ่าน​นอก lock
/// คืน byte ดิบของ batch ที่ครอบช่วง `[from, ceiling)` · **คืนทั้ง batch เสมอ**
/// batch ก้อนแรกอาจเริ่มที่ offset ต่ำกว่า `from` และผู้อ่านเป็นคนทิ้งส่วนหน้าเอง
/// เหมือนที่ Kafka ทำทุกประการ
pub fn read_range(p: &Partition, from: u64, ceiling: u64, max_bytes: usize) -> io::Result<Vec<u8>> {
if from >= ceiling {
return Ok(Vec::new());
}
// ถือ lock ของ log แค่ตอนวางแผน แล้วสำเนา descriptor ออกมา
let plan = {
let log = p.log.lock().expect("log ถูก poison");
plan_read(&log, from, ceiling, max_bytes)?
};
// อ่าน byte จริงหลังปล่อย lock แล้ว · `read_at` ไม่ขยับหัวอ่านของ file
// จึงเรียกพร้อมกันได้จากหลาย thread บน descriptor เดียวกัน
let mut out = Vec::new();
for (pos, len) in plan.spans {
let mut buf = vec![0u8; u64_to_len(u64::from(len))?];
plan.file.read_exact_at(&mut buf, pos)?;
out.extend_from_slice(&buf);
}
Ok(out)
}

กลไก​อยู่​ที่​บล็อก​กลาง ใต้ lock มัน​ทำ​สอง​อย่าง​แล้ว​จบ คือ​คิด​ว่า​จะ​อ่าน​ช่วง​ไหน​บ้าง และ​สำเนา descriptor ของ file ออก​มา​ด้วย try_clone

พอ​ออก​จาก​บล็อก guard ตาย​ไป​พร้อม​กับ scope และ lock ถูก​ปล่อย​คืน read_at ทุก​ครั้ง​ที่​ตาม​มา​จึง​รัน​ตอน​ไม่​ถือ lock

read_at เป็น​ตัว​ที่​ทำให้​ทรง​นี้​ใช้ได้ มัน​รับ​ตำแหน่ง​มา​เป็น argument แทนที่​จะ​พึ่ง​หัว​อ่าน​ของ file descriptor สำเนา​เดียว​จึง​ถูก​อ่าน​พร้อม​กัน​จาก​หลาย thread ได้​โดย​ไม่​ต้อง​กัน​ใคร

อีก​ข้อ​ที่​บล็อก​นี้​ตัดสิน​ไว้​คือ คืน​ทั้ง batch เสมอ batch ก้อน​แรก​อาจ​เริ่ม​ที่ offset ต่ำ​กว่า​ที่​ขอ และ​ผู้​อ่าน​เป็น​คน​ทิ้ง​ส่วน​หน้า​เอง ซึ่ง​เป็น​สิ่ง​ที่ Kafka ทำ​เหมือน​กัน​ทุก​ประการ

ผล​ของ​กติกา “หนึ่ง​คำขอ​เสิร์ฟ​จาก​ท่อน​เดียว” มอง​เห็น​ได้​ตรงๆ ใน​ผล​รัน ตอน​ตั้ง​ต้น demo เขียน​ไป 7 record แล้ว​ท่อน​ถูก roll จริง​สาม​ท่อน

expected/04-fetch.txt (ตัด​มา​บาง​ส่วน)
== ตั้งต้น ==
เขียน 7 record ผ่าน pool ที่มี worker 2 ตัว · หนึ่ง batch ต่อหนึ่ง record
log end offset 7
high watermark 7
จำนวนท่อน 3
base offset ของแต่ละท่อน [0, 3, 6]
offset 0 อยู่บนท่อนที่กำลังเขียนอยู่: ไม่ใช่

คำขอ​ที่​ขอ​จาก offset 0 จึง​ได้ record กลับ​ไป​แค่​สาม​ตัว ทั้ง​ที่ log end offset อยู่​ที่ 7 เพราะ​ท่อน​แรก​จบ​ที่ offset 2 และ​คำขอ​นี้​ไม่​ข้าม​ไป​ท่อน​ถัด​ไป

หัวข้อ​นี้​อธิบาย​ว่า​ทำไม broker ถึง​ไม่​ตอบ​ว่าง และ​มัน​จอด​คำขอ​ไว้​ด้วย​อะไร

เอกสาร​ออกแบบ​ของ Kafka ตั้ง​ปัญหา​ไว้​ก่อน แล้ว​ค่อย​เฉลย ว่า "The deficiency of a naive pull-based system is that if the broker has no data the consumer may end up polling in a tight loop, effectively busy-waiting for data to arrive."

ทาง​แก้​ใน​ประโยค​ถัด​มา​คือ "To avoid this we have parameters in our pull request that allow the consumer request to block in a 'long poll' waiting until data arrives"

long polllong pollการ​ที่ broker จอด​คำขอ fetch ที่​ยัง​ไม่มี​ข้อมูล​ไว้ จนกว่า​จะ​มี record ใหม่​หรือ​หมด​เวลา แทน​การ​ตอบ​กลับ​ว่าง จึง​ไม่ใช่ feature ของ client มัน​คือ​การ​ที่​ฝั่ง broker ยอม​ค้าง​คำขอ​ไว้​ใน​มือ แทนที่​จะ​รีบ​ตอบ​ว่าง​แล้ว​ให้​ผู้​อ่าน​วน​กลับ​มา​ถาม​ใหม่

ที่พัก​ของ​คำขอ​ที่​ถูก​ค้าง​ไว้​แบบ​นั้น Kafka เรียก​ว่า purgatorypurgatoryที่พัก​ของ​คำขอ​ที่​ถูก​จอด​ไว้ รอ​เงื่อนไข​ครบ​หรือ​รอ​หมด​เวลา ก่อน​ถูก​ปลุก​ขึ้น​มา​ตอบ ของ kaen-log มี​ขนาด​เล็ก​ที่สุด​เท่า​ที่​ยัง​ทำงาน​ได้ คือ Mutex<u64> กับ Condvar หนึ่ง​คู่​ต่อ​หนึ่ง partition

src/partition.rs · ที่​จอด​ถือ u64 ตัว​เดียว
/// ที่จอดของคำขอที่รออยู่ · **หนึ่ง `Condvar` ต่อหนึ่ง partition**
/// สิ่งที่มันถือได้มีแค่ `u64` ตัวเดียวคือ log end offset
pub struct PartitionWaiters {
pub lock: Mutex<u64>,
pub cv: Condvar,
}

สอง field นี้​กำหนด​ขอบเขต​ของ​ทั้ง​บท ทุก​อย่าง​ที่​ที่​จอด​ตัว​นี้​ตอบ​ได้ ต้อง​คิด​ออก​มา​จาก​จำนวนเต็ม​ตัว​เดียว

serve_fetch เป็น function ที่​จอด​คำขอ และ​ผล​ของ​มัน​มี​สาม​ช่อง

src/fetch.rs · ผล​ของ​คำขอ​หนึ่ง​ครั้ง
pub struct FetchOutcome {
/// เข้าไปรอใน `wait_timeout` อย่างน้อยหนึ่งครั้งหรือเปล่า
pub parked: bool,
/// ออกมาเพราะเส้นตายถึง ไม่ใช่เพราะได้ของครบ
pub timed_out: bool,
pub bytes: Vec<u8>,
}

parked กับ timed_out เป็น​คนละ​คำถาม​กัน ข้อ​แรก​ถาม​ว่า​เคย​เข้าไป​รอ​ไหม ข้อ​ที่​สอง​ถาม​ว่า​ออก​มา​เพราะ​หมด​เวลา​หรือ​เพราะ​ได้​ของ

เส้น​ตาย​ถูก​ตรึง​ก่อน​เข้า loop แล้ว​นับ​ถอย​หลัง​จาก​มัน

หัวข้อ​ที่​มีชื่อ​ว่า “เส้น​ตาย​ถูก​ตรึง​ก่อน​เข้า loop แล้ว​นับ​ถอย​หลัง​จาก​มัน”

หัวข้อ​นี้​กาง​รูป​ของ​การ​รอ​ออก​มา​ดู แล้ว​บอก​ตาม​ตรง​ว่าการ​กาง​มัน​ออก​มา​เป็นการ​เลือก​ของ​บท​เรียน

serve_fetch ตรึง​เส้น​ตาย​ไว้​ก่อน​แตะ Condvar แล้ว​เข้า loop ทุกร​อบ​มัน​ตรวจ​เงื่อนไข​ก่อน ถ้า​ครบ​ก็​ออก​ทันที​โดย​ไม่​รอ​เลย

src/fetch.rs · เส้น​ตาย​ถูก​ตรึง​ไว้​ก่อน​เข้า loop (ตัด​มา​บาง​ส่วน)
let deadline = Instant::now() + max_wait;
let mut parked = false;
let mut timed_out = false;
let mut end = p.waiters.lock.lock().expect("waiters ถูก poison");
loop {
if records_between(fetch_offset, *end) >= min_records {
break;
}

การ​ตรวจ​ก่อน​รอ​ไม่ใช่​ราย​ละเอียด มัน​คือ​สิ่ง​ที่​ทำให้​คำขอ​ที่​มี​ของ​อยู่​แล้ว​ไม่​ต้อง​แตะ Condvar เลย​สัก​ครั้ง และ​เป็น​เหตุผล​ที่ parked ยัง​เป็น false ได้​แม้​จะ​เข้า​มา​ถึง loop แล้ว

ท่อน​ที่​เหลือ​ของ loop คือ​การ​นับ​ถอย​หลัง

src/fetch.rs · นับ​ถอย​หลัง​จาก​เส้น​ตาย​เดิม​ทุกร​อบ (ตัด​มา​บาง​ส่วน)
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
timed_out = true;
break;
}
parked = true;
let (guard, _timeout) =
p.waiters.cv.wait_timeout(end, remaining).expect("waiters ถูก poison");
end = guard;
}

remaining ถูก​คิด​ใหม่​ทุกร​อบ​จาก​เส้น​ตาย​เดิม​ตัว​เดิม ไม่ใช่​จาก max_wait ทั้ง​ก้อน เวลา​รวม​ของ loop จึง​ถูก​จำกัด​ด้วย​เส้น​ตาย​นั้น ไม่​ว่า​จะ​ถูก​ปลุก​กี่​ครั้ง

การ​ตรวจ​เงื่อนไข​ซ้ำ​ทุก​ครั้ง​ที่​ตื่น​ก็​จำเป็น​เหมือน​กัน เอกสาร​ของ Condvar เขียน​ไว้​เอง​ว่า "This function is susceptible to spurious wakeups." และ​ตาม​ด้วย​ว่า predicate "must always be checked each time this function returns"

Instant เป็น​ชนิด​ข้อมูล​เดียว​ใน​เรื่อง​เวลา​ที่​ทั้ง crate ยอม​ให้​ใช้ และ​ใช้ได้​ที่ src/fetch.rs file เดียว ที่​เหลือ​รับ​นาฬิกา​เข้า​มา​เป็น now_ms จาก​ผู้​เรียก​ทั้งหมด

เหตุผล​ของ​ข้อ​ยกเว้น​ข้อ​นี้​อยู่​ที่​เอกสาร​ของ wait_timeout เอง มัน​บอก​ว่า​เวลา​ที่​รอ "is measured with a monotonic clock, and not affected by the changes made to the system time" เส้น​ตาย​ที่​วัด​ด้วย​นาฬิกา​แบบ​นั้น​จึง​ไม่​ขยับ​ตาม​การ​ปรับ​เวลา​ของ​เครื่อง

⚠ เหตุผล​ที่​ฟัง​ขึ้น แต่​ตรวจ​แล้ว​ไม่​จริง

คำ​อธิบาย​ที่​ได้ยิน​บ่อย​สำหรับ​การ​เขียน loop เอง​คือ wait_timeout_while ตั้ง​เวลา​ใหม่​เต็ม​จำนวน ทุก​ครั้ง​ที่​ถูก​ปลุก​เก้อ เวลา​รวม​ของ​มัน​จึง​ไม่​ถูก​จำกัด​ด้วย dur

ข้อ​อ้าง​นั้น​ไม่​จริง เอกสาร​ของ wait_timeout_while เขียน​ด้วย​ประโยค​เดียว​กับ wait_timeout ว่า thread "will be blocked for roughly no longer than dur"

และ source ของ std รุ่น 1.97.1 ซึ่ง​เป็น​รุ่น​ที่ crate นี้​ตรึง​ไว้ ก็​ทำ​เลขคณิต​ชุด​เดียว​กับ serve_fetch เป๊ะ คือ​จับ Instant::now() ไว้​ก่อน​เข้า loop แล้ว​คิด dur.checked_sub(start.elapsed()) ใหม่​ทุกร​อบ ถ้า​ลบ​ไม่​ได้​แล้ว​ก็​คืน​ค่า​ออก​มา​พร้อม​สถานะ​หมด​เวลา

เหตุผล​ผิด​ที่​พา​ไป​สู่​ข้อ​สรุป​ถูก คือ​ของ​ที่​ตรวจ​ยาก​ที่สุด​ใน​บท​เรียน​ทั้ง​ใบ

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

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

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

หัวข้อ​นี้​แสดง​เส้น​ทาง​ที่​ตัดสิน​ก่อน​ทุก​อย่าง และ​เหตุผล​ที่​มัน​ตัดสิน​ด้วย​ท่อน

คำขอ​ที่​อ่าน​ของ​เก่า​ไม่​ควร​จอด เพราะ​ของ​ที่​มัน​ขอ​อยู่​บน disk ครบ​แล้ว การ​รอ​จึง​ไม่​ได้​อะไร​เพิ่ม Kafka จัดการ​ข้อ​นี้​ด้วย Case F ใน​บรรดา​แปด​เงื่อนไข​ที่​ทำให้​คำขอ​ที่​จอด​อยู่​ถูก​ปลุก

หัวข้อ​ของ Case F ใน DelayedFetch.scala เขียน​ว่า "The fetch offset locates not on the last segment of the log" และ​เงื่อนไข​ที่แท้​จริง​คือ fetchOffset.onOlderSegment(endOffset)

สังเกต​ว่า​มัน​ตัดสิน​ที่​ระดับ​ท่อน ไม่ใช่​ที่​ระยะ​ห่าง​ของ offset kaen-log ลอก​ทรง​นี้​มา​ทั้ง​ดุ้น

src/fetch.rs · Case F มา​ก่อน​ทุก​อย่าง (ตัด​มา​บาง​ส่วน)
/// จอดคำขอไว้จนกว่าจะมี record ครบ `min_records` หรือจนกว่าจะหมดเวลา
pub fn serve_fetch(
p: &Partition,
fetch_offset: u64,
min_records: usize,
max_wait: Duration,
iso: FetchIsolation,
) -> io::Result<FetchOutcome> {
// Case F มาก่อนทุกอย่าง · คำขอที่อ่านของเก่าไม่เคยจอด
// เป็นการตรวจระดับ **ท่อน** ไม่ใช่ระดับระยะห่างของ offset — เหมือน Kafka
if !p.on_active_segment(fetch_offset) {
let top = { ceiling(&p.log.lock().expect("log ถูก poison"), iso) };
let bytes = read_range(p, fetch_offset, top, MAX_FETCH_BYTES)?;
return Ok(FetchOutcome { parked: false, timed_out: false, bytes });
}

การ return ตรง​นั้น​เกิด​ขึ้น​ก่อน​บรรทัด​ที่​แตะ PartitionWaiters แม้แต่​บรรทัด​เดียว คำขอ​ทาง​นี้​จึง​ไม่​เคย​รู้จัก Condvar เลย

expected/04-fetch.txt (ตัด​มา​บาง​ส่วน)
== รอบที่ 1 · Case F — คำขอที่อ่านของเก่าไม่เคยจอด ==
fetch จาก offset 0
จอด ไม่ใช่
หมดเวลา ไม่ใช่
byte ที่ได้ 411
record ที่ได้ 3
ครอบ offset 0 ถึง 2
ไม่แตะ Condvar เลยสักครั้ง · ตัดสินที่ระดับท่อน ไม่ใช่ระยะห่างของ offset
และหนึ่งคำขอถูกเสิร์ฟจากท่อนเดียว จึงได้แค่ record ที่อยู่ในท่อนแรก

รอบ​นี้​ขอ min_records ไว้​ที่ 2 และ​ได้​กลับ​มา 3 ตัว ผล​รัน​รอบ​เดียว​จึง​ยัง​ไม่​พิสูจน์ Case F เพราะ​เงื่อนไข​จำนวน record ก็​ครบ​พอดี​อยู่​แล้ว

ตัว​ที่​พิสูจน์​อยู่​ใน​เทสต์ มัน​ตั้ง min_records ไว้​ที่ 99 ซึ่ง​สูง​เกิน​จริง​จน​ไม่มี​ทาง​ครบ แล้ว​ยืนยัน​ว่า​คำขอ​ยัง​ไม่​จอด

src/fetch.rs · เทสต์​ที่​พิสูจน์​ว่า Case F ไม่​ได้​มา​จาก​จำนวน record (ตัด​มา​บาง​ส่วน)
assert!(p.segment_count() > 1, "ตั้งใจให้เกิดการ roll จริง");
assert!(!p.on_active_segment(0));
let outcome =
serve_fetch(&p, 0, 99, Duration::from_secs(60), FetchIsolation::HighWatermark).unwrap();
assert!(!outcome.parked, "อ่านของเก่าต้องไม่จอด แม้ min_records จะสูงเกินจริง");

สอง​บรรทัด​บน​สุด​คือ​สิ่ง​ที่​ทำให้​เทสต์​ตัว​นี้​ไม่​โกหก ถ้า​ท่อน​ไม่ roll จริง เทสต์​จะ​ล้ม​ที่​บรรทัด​แรก แทนที่​จะ​เขียว​โดย​เดิน​คนละ​ทาง​กับ​ที่​ชื่อ​ของ​มัน​อ้าง

demo เอง​ก็​มี​ด่าน​แบบ​เดียวกัน ถ้า​ตั้ง​ต้น​แล้ว​ท่อน​ไม่ roll มัน​คืน error ออก​มา​แทนที่​จะ​พิมพ์​ข้อ​อ้าง เรื่อง Case F ทั้ง​ที่​ไม่​เคย​เดิน​เข้าไป​ใน​สาขา​นั้น

หัวข้อ​นี้​ชี้​ว่า​ใคร​เป็น​คน​ขยับ​ตัวเลข​ใน​ที่​จอด และ​ปลุก​คน​ที่​รอ​อยู่

คำขอ​ที่​จอด​อยู่​รอ​ค่า​เดียว​คือ log end offset ตัว​ใหม่ และ​คน​เดียว​ที่​ขยับ​มัน​ได้​คือ​ทาง produce ของ​บท​ที่ 3

src/partition.rs · คน​ปลุก​คือ​ทาง produce
/// ประกาศ log end ตัวใหม่แล้วปลุกทุกคนที่จอดรออยู่
/// · นี่คือ `DelayedFetch.tryComplete` ของ Kafka ในรูปที่เล็กที่สุดที่ยังจริง
pub fn publish_log_end(&self, new_end: u64) {
let mut end = self.waiters.lock.lock().expect("waiters ถูก poison");
*end = new_end;
self.waiters.cv.notify_all();
}

สาม statement นี้​คือ tryComplete ทั้ง​ดุ้น​ของ kaen-log ขอ lock ของ​ที่​จอด เขียน​ตัวเลข​ใหม่ แล้ว​ปลุก​ทุก​คน

notify_all ไม่ใช่ notify_one เพราะ​ผู้​อ่าน​หลาย​ราย​รอ​ที่ offset คนละ​ที่​ได้ การ​ปลุก​ราย​เดียว​อาจ​ปลุก​โดน​ราย​ที่​เงื่อนไข​ยัง​ไม่​ครบ แล้ว​ราย​ที่​ครบ​แล้ว​นอน​ต่อ

sequenceDiagram
    participant K as kitchen · ตามทันปลาย log
    participant B as broker · kaen-log
    participant P as producer
    participant A as analytics · อ่านจากต้น log
    K->>B: fetch จาก offset 10 · ขออย่างน้อย 2 record
    Note over B: ยังไม่มีของให้ตอบ จึงจอดไว้บน Condvar<br/>เส้นตายถูกตรึงไว้ตั้งแต่ก่อนเข้า loop
    A->>B: fetch จาก offset 0
    B-->>A: ตอบทันที เพราะ offset 0 อยู่บนท่อนเก่า จึงไม่เคยจอด
    P->>B: produce เข้ามาบน worker ของ pool
    Note over B: publish_log_end ขยับตัวเลข แล้ว notify_all
    B-->>K: ปลุกแล้วตอบ ครอบ offset 10 ถึง 11

คำ​บรรยาย​ภาพ: เส้น​ทาง​ของ analytics ไม่​เคย​แตะ Condvar เลย เพราะ​มัน​อ่าน​ของ​เก่า ส่วน​เส้น​ทาง​ของ kitchen จอด​รอ​ค่าที่ producer เป็น​คน​ขยับ · การ​ปลุก​กับ​การ​ตอบ​เป็น​คนละ​ก้าว เพราะ guard ถูก​ปล่อย​คืน​ก่อน​การ​อ่าน​จะ​เริ่ม

ผล​รัน​รอบ​ที่ 4 เดิน​เส้น​ทาง​นี้​จริง ฝั่ง​อ่าน​รอ​อยู่​บน thread ของ​ตัว​เอง ส่วน produce วิ่ง​บน pool

expected/04-fetch.txt (ตัด​มา​บาง​ส่วน)
== รอบที่ 4 · produce มาถึงระหว่างที่ fetch รออยู่ ==
fetch จอดรออยู่ที่ offset 10 · produce วิ่งบน pool คนละ thread
record ที่ได้กลับมา 2
ครอบ offset 10 ถึง 11
log end offset หลังรอบนี้ 12
รอบนี้ไม่พิมพ์ว่า `จอด` หรือเปล่า เพราะคำตอบขึ้นกับว่า thread ไหนถึงก่อน
ส่วน record ที่ได้กลับมาไม่ขึ้นกับลำดับ · `expected/` บันทึกได้เฉพาะอย่างหลัง

สอง​บรรทัด​สุดท้าย​คือ​ส่วน​ที่​สำคัญ​ที่สุด​ของ​ทั้ง​รอบ รอบ​นี้​เป็น​รอบ​เดียว​ที่ parked กับ timed_out มี​โอกาส​ตอบ​ไม่​ตรง​กัน และ​มัน​คือ​รอบ​เดียว​ที่​ผล​รัน​ไม่​ยอม​พิมพ์ parked ออก​มา

เหตุผล​คือ​ค่า​นั้น​ขึ้น​กับ​ว่า thread ไหน​ถึง​ก่อน ผล​ที่​ขึ้น​กับ​การ​สลับ thread ไม่ใช่​ข้อ​เท็จ​จริง​ที่​บันทึก​ซ้ำ​ได้ ส่วน record ที่​ได้​กลับ​มา​เป็น​ข้อ​เท็จ​จริง​แบบ​นั้น จึง​บันทึก​ไว้​ได้

คำขอ​ที่​หมด​เวลา​แล้ว​ตอบ​ว่าง ไม่ใช่​ความ​ผิด​พลาด

หัวข้อ​ที่​มีชื่อ​ว่า “คำขอ​ที่​หมด​เวลา​แล้ว​ตอบ​ว่าง ไม่ใช่​ความ​ผิด​พลาด”

หัวข้อ​นี้​แสดง​รอบ​ที่​จอด​จริง​แล้ว​หมด​เวลา​จริง แล้ว​บอก​ว่า​คำ​ตอบ​ว่าง​แปล​ว่า​อะไร

รอบ​ที่ 2 ของ demo ไม่มี producer เลย คำขอ​จึง​จอด​จน​เส้น​ตาย​ถึง แล้ว​ออก​มา​มือ​เปล่า

expected/04-fetch.txt (ตัด​มา​บาง​ส่วน)
== รอบที่ 2 · จอดแล้วหมดเวลา — ไม่มี producer ในรอบนี้ ==
fetch จาก offset 7
จอด ใช่
หมดเวลา ใช่
byte ที่ได้ 0
record ที่ได้ 0
ครอบ offset (ไม่มี)
จอดจริงและหมดเวลาจริง เพราะรอบนี้ไม่มีใครเขียนอะไรเลย
คำตอบว่างเปล่าเป็นคำตอบที่ถูกต้อง ไม่ใช่ความผิดพลาด

demo มี​ด่าน​ของ​ตัว​เอง​ก่อน​รอบ​นี้ ถ้า​มี record พอ​อยู่​แล้ว​ตั้งแต่​ต้น มัน​คืน error ออก​มา เพราะ​คำขอ​จะ​ไม่​จอด และ​ข้อ​อ้าง​ของ​รอบ​นี้​จะ​ว่าง​เปล่า​ทั้ง​ที่​ผล​รัน​ดู​สวยงาม

รอบ​ที่ 3 เป็น​ด้าน​ตรง​ข้าม producer มา​ก่อน​ที่​คำขอ​จะ​มา​ถึง เงื่อนไข​จึง​ครบ​ตั้งแต่​ตรวจ​ครั้ง​แรก

expected/04-fetch.txt (ตัด​มา​บาง​ส่วน)
== รอบที่ 3 · producer มาก่อน — ไม่ต้องจอด ==
fetch จาก offset 7
จอด ไม่ใช่
หมดเวลา ไม่ใช่
byte ที่ได้ 289
record ที่ได้ 3
ครอบ offset 7 ถึง 9
log end offset หลังรอบนี้ 10

สาม​รอบ​นี้​กับ​รอบ​ที่ 4 รวม​กัน​จึง​ครอบ​ทาง​เดิน​ทั้งหมด​ที่ serve_fetch มี คือ​ไม่​แตะ Condvar เลย จอด​แล้ว​หมด​เวลา เงื่อนไข​ครบ​ตั้งแต่​ตรวจ​ครั้ง​แรก และ​จอด​แล้ว​ถูก​ปลุก​จน​เงื่อนไข​ครบ

drop(end) เขียน​แยก​บรรทัด เพราะ​กับ​ดัก​นี้​เคย​กัด​มา​แล้ว

หัวข้อ​ที่​มีชื่อ​ว่า “drop(end) เขียน​แยก​บรรทัด เพราะ​กับ​ดัก​นี้​เคย​กัด​มา​แล้ว”

หัวข้อ​นี้​ชี้​บรรทัด​เดียว​ที่​กัน​ไม่​ให้​บท​นี้​ทำ​ผิด​ใน​สิ่ง​ที่​คอร์ส​ที่ 22 สอน​ไว้​แล้ว

คอร์ส kaen-kvstore สอน​กับ​ดัก​การ​ถือ guard คร่อม I/O ที่ block ไว้​แล้ว คอร์ส​นี้​ไม่​สอน​ซ้ำ แต่​ต้อง​แสดง​ว่า​เลี่ยง​มัน​ตรง​ไหน

จุด​ที่​เผลอ​ง่าย​ที่สุด​คือ​จุด​นี้​พอดี เพราะ​ตอน​ออก​จาก loop guard ของ​ที่​จอด​ยัง​อยู่​ใน​มือ และ​การ​อ่าน disk อยู่​บรรทัด​ถัด​ไป

src/fetch.rs · ปล่อย guard ก่อน​แตะ disk (ตัด​มา​บาง​ส่วน)
// *** บรรทัดที่บทนี้มีไว้เพื่อสอน ***
// ห้ามถือ guard คร่อมการอ่าน disk · #22 บทที่ 5 สอนกับดักนี้ไปแล้ว และ function
// ที่จะเผลอทำก็คือ function ที่บทนี้ตั้งชื่อตาม · เขียนแยกบรรทัดไว้เลย
// เพื่อไม่ให้ใครลบทิ้งเพราะคิดว่าไม่จำเป็น
drop(end);
let top = { ceiling(&p.log.lock().expect("log ถูก poison"), iso) };
let bytes = read_range(p, fetch_offset, top, MAX_FETCH_BYTES)?;
Ok(FetchOutcome { parked, timed_out, bytes })
}

ถ้า​ไม่มี​บรรทัด​นั้น guard จะ​มี​ชีวิต​จน​จบ function และ lock ของ​ที่​จอด​จะ​ถูก​ถือ​ไว้​ตลอด​การ​อ่าน disk

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

สอง lock คนละ​ตัว และ​บรรทัด​นี้​ปล่อย​แค่​ตัว​เดียว

drop(end) ปล่อย lock ของ PartitionWaiters เท่านั้น

บรรทัด​ถัด​มา​ขอ lock ของ Log ใหม่​เพื่อ​หา​เพดาน แล้ว read_range ก็​ขอ​มัน​อีก​ครั้ง​เพื่อ​วางแผน​อ่าน lock ตัว​นั้น​ถูก​ถือ​สั้นๆ สอง​ครั้ง ไม่ใช่​ไม่​ถูก​ถือ​เลย

ข้อ​สรุป​ว่า “การ​อ่าน​ไม่​ถือ lock อะไร​เลย” จึง​ผิด ข้อ​ที่​ถูก​คือ ไม่มี lock ตัว​ไหน​ถูก​ถือ​คร่อม read_at

หัวข้อ​นี้​อธิบาย deadlock ที่​เกิด​จาก​การ​เอา pool ของ​คอร์ส​ที่ 22 มา​ใช้​กับ​ทาง fetch ตรงๆ

pool ของ worker ที่​คอร์ส​ที่ 22 สร้าง​ไว้​ถูก​ยก​มา​ใช้​ใน kaen-log ทั้ง​ดุ้น แต่​ใช้​กับ​ทาง produce เท่านั้น

เหตุผล​เขียน​ไว้​ที่​หัว file ของ​มัน​เอง

src/pool.rs · เหตุผล​ที่ทาง fetch ไม่​วิ่ง​บน pool (ตัด​มา​บาง​ส่วน)
//! pool ของ worker แบบมีเพดาน — **ใช้ซ้ำจากคอร์ส #22 บทที่ 5 ไม่ได้คิดขึ้นใหม่**
//!
//! kaen-log ให้ pool นี้รับใช้ **ทาง produce เท่านั้น** · คำขอ fetch ที่จอดรอ
//! วิ่งบน thread ของตัวเอง · เหตุผลอยู่ที่ `src/fetch.rs`: fetch ที่จอดรออยู่บน
//! worker ของ pool ที่มี N ตัว จะกิน worker จนหมดทั้ง N ตัว แล้ว produce ที่เป็น
//! คนเดียวที่ปลุกมันได้จะไม่เหลือ worker ให้รัน · broker ค้างทั้งตัวโดยไม่มี lock
//! ตัวไหนถูกถือไว้เลย
//!
//! **crate นี้ไม่มี code ที่แตะ network สักบรรทัด** · `std::net` ไม่ถูกอ้างที่ไหนเลย
//! ข้ออ้าง "thread ต่อหนึ่งการเชื่อมต่อ" จึงเป็นข้ออ้างที่วัดจากที่นี่ไม่ได้ · สิ่งที่
//! demo ของบทที่ 4 แสดงได้จริงคือ fetch ที่จอดอยู่กิน thread ของตัวเองหนึ่งตัว
//! ขณะที่ produce วิ่งบน pool

ประโยค​สุดท้าย​คือ​ส่วน​ที่​ทำให้​ข้อ​นี้​อันตราย ไม่มี lock ตัว​ไหน​ถูก​ถือ​ไว้​เลย เครื่อง​มือ​ที่​ตรวจ deadlock จาก​การ​วน lock จึง​ไม่มี​อะไร​ให้​เห็น

รูป​ของ​มัน​คือ​วงจร​ระหว่าง​ทรัพยากร​กับ​เงื่อนไข ไม่ใช่​วงจร​ระหว่าง lock สอง​ตัว

  • worker ทุก​ตัว​ถูก​ยึด​โดย​คำขอ​ที่​รอ​เงื่อนไข​อยู่
  • เงื่อนไข​นั้น​ครบ​ได้​จาก​ทาง produce ทาง​เดียว
  • ทาง produce ต้องการ worker เพื่อ​จะ​รัน และ​ไม่​เหลือ worker แล้ว

ผล​รัน​ปิด​ท้าย​ด้วย​การ​พูด​ข้อ​นี้​ออก​มา​ตรงๆ พร้อม​จำนวน worker ที่ demo ใช้​จริง

expected/04-fetch.txt (ตัด​มา​บาง​ส่วน)
และ fetch ที่จอดอยู่ กิน thread ของตัวเองหนึ่งตัว ไม่ใช่ worker ของ pool
pool มี worker 2 ตัว · ถ้า fetch ที่จอดกิน worker ครบทุกตัว
producer ที่เป็นคนเดียวที่ปลุกมันได้จะไม่เหลือ worker ให้รัน

ทางออก​ของ kaen-log ตรง​ไป​ตรง​มา คือ​คำขอ fetch ที่​จอด​อยู่​ไม่​วิ่ง​บน pool มัน​วิ่ง​บน thread ของ​ตัว​เอง demo ของ​บท​นี้​แสดง​ข้อ​นั้น​ออก​มา​ตรงๆ fetch ที่​จอด​รอ​อยู่​กิน thread ของ​ตัว​เอง​หนึ่ง​ตัว ขณะ​ที่ produce เดิน​บน worker ของ pool แล้ว​เข้าไป​ปลุก​มัน

⚠ ขอบเขต​ของ​ข้อ​อ้าง​นี้ · crate นี้​ไม่มี network เลย

kaen-log ไม่มี code ที่​แตะ network สัก​บรรทัด std::net ไม่​ถูก​อ้าง​ที่ไหน​ใน src/ เลย

บท​นี้​จึง​พูด​ว่า “thread ต่อ​หนึ่ง​การ​เชื่อม​ต่อ” ไม่​ได้ เพราะ​ไม่มี​การ​เชื่อม​ต่อ​ให้​นับ​ตั้งแต่​ต้น สิ่ง​ที่​วัด​ได้​จริง​จาก demo คือ fetch ที่​จอด​อยู่​บน thread ของ​ตัว​เอง ส่วน produce วิ่ง​บน pool และ​นั่น​คือ​ขอบเขต​ทั้งหมด​ของ​ข้อ​อ้าง

ของ​จริง​ที่​ต่อ socket เข้า​มา​ด้วย​จะ​ต้อง​ตัดสิน​อีก​ชั้น​ว่า thread ที่​จอด​นั้น​ผูก​กับ​อะไร คอร์ส​นี้​ไม่​ได้​สร้าง​ชั้น​นั้น จึง​ไม่​พูด​ถึง​มัน

Kafka แก้​ปัญหา​คนละ​แบบ เอกสาร Network Layer เขียน​ว่า "The threading model is a single acceptor thread and N processor threads which handle a fixed number of connections each."

processor เหล่า​นั้น​เป็น NIO ที่​ไม่ block ส่วน​คำขอ​ที่​ถูก​จอด​ไม่​ได้​ถือ thread ไว้​เลย มัน​เป็น object ใน​ที่พัก std::net เขียน​ทรง​นั้น​ไม่​ได้ และ​คอร์ส​นี้​เลือก​ไม่​ไป​หา runtime แบบ async มา​ช่วย

หัวข้อ​นี้​ไล่​ความ​ต่าง​สาม​ข้อ​ที่​ห้าม​กลบ พร้อม​เหตุผล​เชิงกล​ไก​ของ​แต่ละ​ข้อ

ข้อ​แรก หน่วย​ที่​นับ​ไม่​เหมือน​กัน ธง​ของ kaen-log ชื่อ --min-records และ​มัน​นับ record จริงๆ ส่วน MinBytes ของ Kafka นับ byte

ความ​ต่าง​นี้​ไม่ใช่​เรื่อง​หน่วย แต่​เป็น​เรื่อง​ของ​ที่​ที่​จอด​ถือ​อยู่

src/fetch.rs · min_records นับ record ไม่ใช่ byte
/// จำนวน record ระหว่างสอง offset · **นับ record ไม่ใช่ byte**
///
/// Kafka เทียบด้วย `endOffset.positionDiff(fetchOffset)` ซึ่งเป็นผลต่างของ **byte**
/// และต้องใช้ `LogOffsetMetadata` (offset + ท่อน + ตำแหน่งในท่อนนั้น) จึงจะคิดได้
/// · ที่จอดของ kaen-log ถือ `u64` ตัวเดียว จึงคิดค่านั้นไม่ได้ และธงของมัน
/// ชื่อ `--min-records` ด้วยเหตุผลนี้ · ห้ามบทไหนเรียกมันว่า `min_bytes`
pub fn records_between(from: u64, to: u64) -> usize {
u64_to_len(to.saturating_sub(from)).unwrap_or(usize::MAX)
}

Kafka คิด​ค่า​นั้น​จาก endOffset.positionDiff(fetchOffset) ซึ่ง​ต้อง​รู้​ว่า​ปลาย log อยู่​ท่อน​ไหน และ​อยู่​ตำแหน่ง​ที่​เท่าไร​ใน​ท่อน​นั้น ที่​จอด​ของ kaen-log ถือ u64 ตัว​เดียว จึง​คิด​ไม่​ได้

ข้อ​ที่​สอง ขอบเขต​ของ​การ​เทียบ​ไม่​เหมือน​กัน เกณฑ์ min_records ของ kaen-log เทียบ​ที​ละ partition เพราะ​มี Condvar หนึ่ง​ตัว​ต่อ​หนึ่ง partition

ของ Kafka รวม​ทุก partition ใน​คำขอ​เดียวกัน​ก่อน​เทียบ DelayedFetch บวก​ค่า​เข้า accumulatedSize ทีละ partition แล้ว​เทียบ​กับ params.minBytes ครั้ง​เดียว​ตอน​ท้าย

ข้อ​ที่​สาม ตัว​จับ​เวลา​ไม่​เหมือน​กัน Condvar หนึ่ง​ตัว​รับคำ​ขอ​ที่​จอด​อยู่​ได้​เท่า​ที่ thread จะพอ ส่วน purgatory ของ Kafka เป็น hierarchical timing wheel

บทความ​ที่​อธิบาย​การ​ออกแบบ​นั้น​บอก​เหตุผล​ไว้​ว่า timing wheel มี "O(1) cost for insert/delete (start-timer/stop-timer) whereas priority queue based timers, such as java.util.concurrent.DelayQueue and java.util.Timer, have O(log n) insert/delete cost"

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

expected/04-fetch.txt (ตัด​มา​บาง​ส่วน)
== สองข้อที่ kaen-log ต่างจาก Kafka และไม่ควรกลบ ==
1. min_records นับ record · min_bytes ของ Kafka นับ byte
ที่จอดของ kaen-log ถือ u64 ตัวเดียว จึงคิดผลต่างเป็น byte ไม่ได้
2. เพดานนี้เป็นราย partition เพราะมี Condvar หนึ่งตัวต่อหนึ่ง partition
ส่วน Kafka รวมทุก partition ในคำขอเดียวกันก่อนเทียบ

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

เงื่อนไข​ปลุก​ของ Kafka มี​แปด​ข้อ ของ kaen-log มี​สอง

DelayedFetch ไล่​เงื่อนไข​ไว้​ตั้งแต่ Case A ถึง Case H เช่น broker ไม่​ได้​เป็น leader แล้ว replica ไม่​อยู่​บน​เครื่อง​นี้​แล้ว หรือ​พบ epoch ที่​แตก​กิ่ง​ออก​ไป

kaen-log มี​สอง​ข้อ คือ​จำนวน record ครบ​ตาม​ที่​ขอ และ​คำขอ​อ่าน​ของ​เก่า​อยู่​บน​ท่อน​ที่​ไม่ใช่​ท่อน​สุดท้าย บวก​เส้น​ตาย​ที่​ทำให้​ออก​มา​ได้​เสมอ

ข้อ​ที่​หาย​ไป​ทั้งหมด​เป็น​เงื่อนไข​ที่​มี​ความ​หมาย​เมื่อ​มี​หลาย replica เท่านั้น บท​ที่ 8 จะ​สร้าง​โลก​ใบ​นั้น​ขึ้น​มา แล้ว​เงื่อนไข​บาง​ข้อ​ใน​นั้น​จะ​กลับ​มา​มี​ความ​หมาย

หัวข้อ​นี้​ประกาศ​ขอบเขต​ของ​ทาง fetch ไว้​ล่วง​หน้า

  • ไม่มี quota และ​ไม่มี​การ​หน่วง​ผู้​อ่าน​ที่​ดูด​แรง​เกิน​ไป Kafka มี​กลไก​นั้น kaen-log ไม่มี​เลย คำขอ​ที่​ขอ​มาก​ที่สุด​ได้​ไป​มาก​ที่สุด​เสมอ
  • ไม่มี sendfile kaen-log อ่าน byte ขึ้น​มา​ไว้​ใน​หน่วย​ความ​จำ​ก่อน​แล้ว​ค่อย​ส่ง ส่วน Kafka ส่ง​จาก page cache ลง​สาย​ได้​โดย​ไม่​ผ่าน buffer ของ process
  • ไม่มี​การ​รวม​หลาย partition ใน​คำขอ​เดียว คำขอ​หนึ่ง​ครั้ง​ของ kaen-log แตะ partition เดียว ซึ่ง​เป็น​เหตุผล​ที่​เกณฑ์ min_records ของ​มัน​เทียบ​ที​ละ partition ไป​ด้วย
  • ไม่มี TxnCommitted enum ของ kaen-log มี​สอง​ค่า และ​คอม​เมน​ต์​เหนือ​มัน​บอก​ไว้​เอง​ว่า​ทาง​ที่​สาม ถูก​เอ่ย​ชื่อ​ใน​บท​ที่ 7 แต่​ไม่​เคย​ถูก​สร้าง

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


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • FetchRequest.json tag 4.0.0 (ตรวจ​แล้ว 2026-09-03) — คำ​อธิบาย​ของ MaxWaitMs MinBytes และ FetchOffset ที่​ยก​มา​ทั้ง​สาม​ประโยค และ​ประโยค​ของ ReplicaId ที่​บอก​ว่า -1 แปล​ว่า​มา​จาก consumer
  • FetchIsolation.java tag 4.0.0 (ตรวจ​แล้ว 2026-09-03) — การ​แตก​สาม​ทาง​ด้วย if else if else ที่​แม็ป replicaId กับ isolationLevel ไป​เป็น LOG_END TXN_COMMITTED หรือ HIGH_WATERMARK
  • DelayedFetch.scala tag 4.0.0 (ตรวจ​แล้ว 2026-09-03) — รายการ Case A ถึง Case H ประโยค​ของ Case F ที่​ยก​มา เงื่อนไข fetchOffset.onOlderSegment(endOffset) และ​การ​บวก accumulatedSize ก่อน​เทียบ​กับ params.minBytes ครั้ง​เดียว​ตอน​ท้าย
  • Apache Kafka 4.0 — Design (ตรวจ​แล้ว 2026-09-03) — ย่อหน้า​ที่​ตั้ง​ปัญหา​เรื่อง​การ​วน​ถาม​ใน​ลูป​แคบ แล้ว​เฉลย​ด้วย​คำ​ว่า long poll ทั้ง​สอง​ประโยค​ที่​ยก​มา​อยู่​ติด​กัน​ใน​ย่อหน้า​เดียว
  • ConsumerConfig.java tag 4.0.0 (ตรวจ​แล้ว 2026-09-03) — ค่า​ปริยาย​ฝั่ง client ที่​บท​นี้​อ้าง​ถึง​ใน​ฐานะ​ที่มา​ของ​ธง​ใน demo DEFAULT_FETCH_MIN_BYTES เป็น 1 DEFAULT_FETCH_MAX_WAIT_MS เป็น 500 และ DEFAULT_MAX_PARTITION_FETCH_BYTES เขียน​ไว้​เป็น 1 * 1024 * 1024
  • Apache Kafka 4.0 — Implementation › Network Layer (ตรวจ​แล้ว 2026-09-03) — ประโยค​เรื่อง acceptor thread ตัว​เดียว​กับ processor thread N ตัว ที่​ยก​มา​เทียบ​กับ​ทรง​ของ kaen-log ซึ่ง​มี pool สำหรับ produce และ thread ต่อ​หนึ่ง fetch ที่​จอด​รอ · ห้าม​เรียก​ทรง​นี้​ว่า thread ต่อ​หนึ่ง​การ​เชื่อม​ต่อ เพราะ crate นี้​ไม่มี​การ​เชื่อม​ต่อ​ให้​นับ ตาม​ที่​หัวข้อ​ข้าง​บน​วัด​ไว้​แล้ว
  • Apache Kafka, Purgatory, and Hierarchical Timing Wheels (Yasuhiro Matsuda เผย​แพร่ 2015-10-28 · ตรวจ​แล้ว 2026-09-03) — ประโยค​เรื่อง O(1) ของ timing wheel เทียบ​กับ O(log n) ของ​ตัว​จับ​เวลา​ที่​ใช้ priority queue ตัวเลข throughput ใน​บทความ​นั้น​เป็น​ของ​เครื่อง​ปี 2015 บท​นี้​จึง​ไม่​ยก​มา
  • Rust std — struct Condvar (ตรวจ​แล้ว 2026-09-03) — ประโยค​เรื่อง spurious wakeup ประโยค​ที่​บอก​ว่า​ต้อง​ตรวจ predicate ทุก​ครั้ง ประโยค​เรื่อง​นาฬิกา monotonic และ​ประโยค "will be blocked for roughly no longer than dur" ที่​เขียน​ไว้​เหมือน​กัน​ทั้ง​ใน wait_timeout และ wait_timeout_while
  • rust-lang/rust 1.97.1 — condvar.rs (ตรวจ​แล้ว 2026-09-03) — source ของ wait_timeout_while ที่​จับ Instant::now() ไว้​ก่อน​เข้า loop แล้ว​คิด dur.checked_sub(start.elapsed()) ใหม่​ทุกร​อบ ซึ่ง​เป็น​หลัก​ฐาน​ว่า​เวลา​รวม​ของ​มัน​ถูก​จำกัด

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

ข้อ 1 / 3

ผู้อ่านรายหนึ่งหายไปนาน แล้วกลับมาขอ record ต่อจากที่เคยอ่านค้างไว้ broker หาสถานะของมันจากที่ไหน