- PostHog เขียน SQL parser ภาษา C++ ที่ใช้ ANTLR ขึ้นใหม่ผ่าน Claude Code หลายเซสชัน จนได้ parser ภาษา Rust 16K บรรทัด เครื่องมืออีก 5K บรรทัด และเทสต์หลายพันบรรทัด พร้อมความเร็วเพิ่มขึ้นราว 70 เท่าบนแล็ปท็อป
- การทำงานใหม่มีแกนหลักเป็น predictive recursive descent parser และ Pratt expression core โดยใช้ lookahead แบบจำกัดและ speculative backtracking เฉพาะจุดที่จำเป็น เพื่อตัดต้นทุนการเดินกราฟแบบทั่วไปของ ANTLR ออกไป
- ใช้ parser C++ เดิมเป็น implementation อ้างอิง แล้วผสาน property-based testing, query จริงที่ anonymize แล้ว, regression test, การสร้างเทสต์ที่นำทางด้วย code coverage และการย่อเคสด้วย ShrinkRay เพื่อไล่กำจัดความไม่ตรงกันระหว่าง parser ทั้งสองซ้ำ ๆ
- ในโหมด shadow บน production ผลการ parse หลายล้านครั้งไม่เคยต่างจาก parser เดิมเลย จึงย้ายทราฟฟิกภายในไม่กี่ชั่วโมง และทำความเร็วได้เพิ่มขึ้น เฉลี่ย 454 เท่าใน production
- แนวทางที่ parser generator ให้ทั้ง grammar และ implementation อ้างอิง แล้วให้ LLM ตรวจสอบความเท่าเทียมด้วย fuzzing แสดงให้เห็นความเป็นไปได้ที่จะลดงานพัฒนา parser ประสิทธิภาพสูงที่เคยต้องใช้ความเชี่ยวชาญและเวลาหลายเดือนให้เหลือเพียงไม่กี่วัน
ทำไม PostHog จึงต้องมี SQL parser
- PostHog แปลง SQL ที่ผู้ใช้ป้อนให้เป็น ClickHouse SQL แบบดิบ เพื่อให้ผู้ใช้ เข้าถึงข้อมูลได้โดยตรงด้วย SQL
- ให้ มุมมองข้อมูลเชิงตรรกะ ที่เป็นอิสระจากการจัดวางจริงของฐานข้อมูล
- ป้องกันไม่ให้ query เดิมพัง แม้จะเปลี่ยนชั้นฐานข้อมูล
- เพิ่มการปรับแต่งประสิทธิภาพและการควบคุมการเข้าถึงในขั้นตอนการแปลง
- เครื่องมือส่วนใหญ่ของ PostHog เช่น product analytics, session replay และ error tracking ก็ส่ง query ที่เขียนด้วย SQL ผ่านกระบวนการแปลงเดียวกัน
- การจะแปลง SQL ต้องเปลี่ยนมันเป็น abstract syntax tree (AST) ก่อน แล้วจึงแปลง tree นี้กลับเป็น ClickHouse SQL
- Parser เป็นคอมโพเนนต์แรกที่ประมวลผล input query ที่ไม่น่าเชื่อถือ
- การควบคุมการเข้าถึงและการปรับแต่งหลังจากนั้นล้วนพึ่งพา tree ที่ parser สร้างขึ้น
โครงสร้างและต้นทุนของ ANTLR parser เดิม
- ก่อนยุค AI coding การเขียนและดูแล parser เองทำได้ยากมาก PostHog จึงใช้ parser generator แบบโอเพนซอร์สอย่าง ANTLR
- เมื่อให้ grammar ในรูปไฟล์ .g4 แบบ declarative แล้ว ANTLR จะสร้างโค้ด parser ส่วนใหญ่ให้
- Parser เดิมก็ถูกสร้างเป็น C++ อยู่แล้ว ดังนั้นความเร็วที่เพิ่มขึ้นครั้งนี้ไม่ได้เกิดจากการเปลี่ยนภาษา implementation เป็น Rust อย่างเดียว
- ANTLR ทรงพลังและยืดหยุ่น แต่ต้องทำงานมากกว่าเมื่อประมวลผลแต่ละ token
- คอมไพล์ grammar ให้เป็นรูปแบบใกล้เคียงกับ augmented transition network (ATN) หรือ nondeterministic finite automaton (NFA) ที่มี stack
- ตอน runtime จะมี interpreter ทั่วไปเดินกราฟนี้
- แทนที่จะเป็นฟังก์ชันที่เขียนตรง ๆ เช่น
parseExpression()ต้องผ่าน abstraction และชั้น indirect call เพิ่มเติม
- ยังรองรับ arbitrary dynamic lookahead ด้วย ดังนั้นเมื่อมีหลายทางเลือก จะจำลองการตีความทั้งหมดพร้อมกันจนกว่าจะเหลือทางที่ valid เพียงทางเดียว
- แม้ ANTLR ที่ปรับแต่งดีพอแล้ว ก็ยากที่จะเร็วกว่า recursive descent parser ที่เขียนเองโดยตรงได้ เพราะโครงสร้างยังเป็น interpreter ที่เดินกราฟ
ทดลอง implementation ของ parser สองแนวทางแบบขนาน
- การใช้ AI ทำให้การเขียนและดูแล parser ที่ทำเองง่ายขึ้น แต่แค่ขอให้ Claude สร้าง Rust parser ที่ไร้ข้อผิดพลาดยังไม่เพียงพอ
- Claude ทำผิดพลาดจำนวนมาก
- ตั้งข้อสงสัยซ้ำ ๆ ว่าการเขียนใหม่เป็นไปได้จริงหรือไม่
- มีแนวโน้มจะพยายามปิดงานหลังจบรอบการ coding แต่ละครั้ง
- ทดลองสองแนวทางแบบขนานใน Claude Code session ที่รันยาวหลายชุด
- แนวทางเน้นประสิทธิภาพ ใช้ recursive descent parser และ Pratt expression loop แล้วเพิ่ม lookahead กับ backtracking เฉพาะจุดที่จำเป็น
- แนวทางเน้นโอกาสสำเร็จ พยายามทำตามพฤติกรรมของ ANTLR ให้มากที่สุด แต่แทนที่จะเดินกราฟทั่วไป ก็ implement transition เป็นโค้ดชัดเจน
- สุดท้ายทั้งสองแนวทางทำงานได้ในระดับใกล้เคียงกัน แต่ใช้เวลาหลายวันกว่าจะยืนยันได้
- เป้าหมายคือให้ตรงกับ parser C++ เดิมอย่างสมบูรณ์ใน query ที่สมจริงทุกแบบ และให้ผลใกล้เคียงที่สุดเท่าที่เป็นไปได้แม้กับ query ที่สร้างขึ้นแบบประดิษฐ์
- SQL ที่ดูผิดปกติแต่ valid เช่น
SELECT SELECT FROM FROM WHERE WHERE AND ANDก็รวมอยู่ในชุดทดสอบด้วย
- SQL ที่ดูผิดปกติแต่ valid เช่น
- ใช้ parser C++ เดิมเป็น implementation อ้างอิง (oracle) แล้วพัฒนาด้วยแนวทาง test-driven โดยค้นหา SQL ที่ถูกประมวลผลต่างกัน แก้ parser ใหม่ แล้วนำมาเปรียบเทียบอีกครั้ง
สร้างความไม่ตรงกันด้วย property-based testing
- หลังจากผ่าน regression test ที่สะสมมาจากกระบวนการพัฒนา parser เดิมแล้ว จึงสร้างความไม่ตรงกันใหม่ ๆ อย่างเป็นระบบ
- ใน property-based testing (PBT) ที่ใช้ Hypothesis กำหนด property ที่ต้องตรวจสอบว่า “parser ใหม่ตรงกับ implementation อ้างอิง”
- input คือ SQL query
- Hypothesis จะสำรวจหา query ที่ผลลัพธ์ของ parser ทั้งสองไม่ตรงกัน
- เพื่อสร้าง SQL ที่น่าสนใจ จึงทำเครื่องมือร่วมกับ Claude ที่ code-generate ตัวสร้าง SQL จากไฟล์ grammar ของ ANTLR
- เขียน parser แยกต่างหากที่อ่านไฟล์
.g4เองด้วย - จากนั้นรวมการแปลงอย่างการสลับ token หรือเพิ่มวงเล็บเข้าไปในขั้นตอนสร้าง
- เขียน parser แยกต่างหากที่อ่านไฟล์
ออกแบบ prompt เพื่อป้องกันการแก้แบบเปราะบาง
- PBT สร้างเทสต์ใหม่ได้อย่างเสถียร แต่ Claude มักทำ การแก้แบบเปราะบาง ซ้ำ ๆ เช่น แก้เคสเฉพาะด้วย lookahead หนึ่ง token แล้วภายหลังพบว่าจริง ๆ ต้องใช้ lookahead สอง token
- หน้าต่าง context มักเต็มและถูกบีบอัดอยู่บ่อยครั้ง จึงมีโอกาสที่มันแก้โดยลืม grammar จริงหรือพฤติกรรมของ parser อ้างอิงไปแล้ว
- บรรเทาปัญหานี้ด้วยการสั่งให้โหลดไฟล์ grammar และซอร์สโค้ด C++ ที่เกี่ยวข้องทั้งหมดเข้า context ก่อนเขียนโค้ดเพื่อแก้ความไม่ตรงกันแต่ละรายการ
ให้ CPU และ Claude ทำงานต่อเนื่องร่วมกัน
- ตั้งค่าเครื่องมือให้ PBT รันต่อใน background บันทึกเคสที่ล้มเหลวลงไฟล์ และให้ Claude ดึงเคสนั้นมาเมื่อไม่มีงานอื่น
- เคสล้มเหลวถูกรวบรวมจากหลายเส้นทาง
- regression test เดิม
- SQL ที่สร้างด้วย PBT
- query ที่ anonymize แล้วจาก production query logs
- เคสที่สร้างโดยขอให้ background agent “คิดถึง edge case ให้ลึกจริง ๆ”
- Parser สองตัวที่พัฒนาแบบขนานใช้ ชุด regression test ร่วมกัน ทำให้ความล้มเหลวที่พบใน session หนึ่งสะท้อนไปยังอีก session ทันที
- Hypothesis สามารถย่อเคสที่มันสร้างเองให้เหลือ minimal reproduction ได้ แต่ใช้กับ SQL จากภายนอกไม่ได้ จึงใช้ ShrinkRay สำหรับเคสเหล่านั้น
- ต่อมาเพิ่มการสร้างเทสต์ที่นำทางด้วย code coverage ด้วย
- ตรวจจับองค์ประกอบ SQL ที่ยังไม่เคยถูก execute แล้วปรับให้โครงสร้างนั้นถูกสร้างบ่อยขึ้น
- ไม่ใช่สิ่งจำเป็นต่อการไปถึงความแม่นยำ 100% บนชุด production query แต่ช่วยหาเคสที่ละเอียดอ่อนมากได้
วงจรอัตโนมัติในการค้นหาและแก้ failure
- Development loop ขั้นสุดท้ายทำซ้ำขั้นตอนต่อไปนี้แบบอัตโนมัติ
- สร้าง failure ใหม่จาก PBT, ชุด query จริง, regression test และการสำรวจ edge case
- ย่อ failure แล้วเพิ่มเข้าไปใน รายการ regression test ที่ขยายขึ้นเรื่อย ๆ
- ตรวจสอบวิธีที่ grammar และ implementation อ้างอิง C++ ประมวลผล โดยให้ความสำคัญกับวิธีแก้ทั่วไปก่อน
- หลังแก้แล้ว พิมพ์สรุปหนึ่งย่อหน้าที่มนุษย์อ่านได้
- รัน regression test ทั้งหมดเพื่อยืนยันว่าทุกเคสผ่าน
- ทำกระบวนการเดิมซ้ำแบบอัตโนมัติ
- เพราะ parser ใหม่เร็วกว่าอย่างมาก จึงสามารถรันควบคู่กับ parser C++ เดิมใน production ใน โหมด shadow และรายงานความแตกต่างของผลลัพธ์ได้
การตรวจสอบบน production และการสลับใช้งาน
- การเปรียบเทียบล่วงหน้าด้วย production query logs ทดสอบ query ราว 50,000 รายการ
- ในโหมด shadow รันการ parse ได้หลายล้านครั้งอย่างรวดเร็ว และไม่พบความไม่ตรงกันกับ parser เดิมแม้แต่ครั้งเดียว
- เดิมวางแผนจะคงโหมด shadow ไว้หลายวัน แต่ผลลัพธ์แข็งแรงพอจึงย้าย production traffic ไปยัง parser ใหม่ภายในไม่กี่ชั่วโมง
- หลังสลับแล้ว ยังคง reverse shadow 0.1% เพื่อเปรียบเทียบกลับไปยัง parser เดิม
- Parser ใหม่ไม่ได้ให้ output เฉพาะ AST แต่ยังให้ ข้อมูลตำแหน่งในซอร์ส เหมือนกับ C++ ANTLR parser ด้วย
เร็วขึ้น 70 เท่าบนแล็ปท็อป, 454 เท่าใน production
- ใน benchmark บนแล็ปท็อป ทำความเร็วได้มากกว่า parser เดิมประมาณ 70 เท่า
- สำหรับ production query เร็วขึ้นเฉลี่ย 454 เท่า
- ใน production มักประมวลผล SQL ที่ยาวกว่าและไม่ hit parser cache ทำให้ความต่างด้านประสิทธิภาพมากกว่าบนแล็ปท็อป
- ผลลัพธ์สุดท้ายประกอบด้วยโค้ด parser ราว 16K บรรทัด เครื่องมือราว 5K บรรทัด และเทสต์หลายพันบรรทัด
- สำหรับ query ที่สมจริง ผลลัพธ์เทียบเท่ากับ parser เดิม และความต่างจำกัดอยู่แค่ query จำนวนน้อยมากที่สร้างขึ้นแบบประดิษฐ์
โครงสร้าง parser ขั้นสุดท้ายและวิธีพัฒนาที่เปลี่ยนไป
- Parser ขั้นสุดท้ายมีโครงสร้างดังนี้
- implement ส่วนใหญ่เป็น predictive recursive descent parser
- ใช้ Pratt parser core สำหรับการประมวลผล expression
- cursor พื้นฐานเป็น LL(2) และขยายขอบเขตด้วยการสำรวจ lookahead แบบจำกัดที่ไม่ consume input เฉพาะบางจุด
- ใช้ local speculative backtracking แบบ ordered choice เฉพาะการตัดสินใจจำนวนน้อยที่จำเป็นจริง ๆ
- Parser ทั้งหมดเขียนด้วย Rust โดย Claude Opus 4.7 ในเดือนพฤษภาคม 2026 และไม่ได้เขียนโค้ดด้วยมือโดยตรง
- การตั้งค่า PBT ที่สร้าง input จาก grammar และนำทางการสร้างด้วย code coverage ใกล้เคียงกับแนวทางสมัยใหม่ของ parser fuzzing และแตกต่างจาก “vibe coding” แบบง่าย ๆ
- งานที่แม้แต่นักพัฒนาที่มีความรู้เฉพาะด้าน parser ก็อาจต้องใช้เวลาหลายเดือน ถูกทำเสร็จภายในไม่กี่วัน
- ต่อไป parser generator อย่าง ANTLR อาจถูกใช้เพื่อให้ implementation อ้างอิง แล้วให้ LLM เขียน parser ที่ implement โดยตรงและเร็วกว่า โดยปรับความเท่าเทียมด้วย PBT และ fuzzing จนกลายเป็นแนวทางทั่วไปได้
ยังไม่มีความคิดเห็น