- cola คือ Text CRDT สำหรับการแก้ไขร่วมกันแบบเรียลไทม์ที่เขียนด้วย Rust โดยมุ่งให้เอกสารที่ peer หลายรายแก้ไขพร้อมกันลู่เข้าสู่สถานะเดียวกันโดยไม่ต้องมีตัวกลางประสานงาน
- ตรึงตำแหน่งในเอกสารด้วย Anchor รูปแบบ
ReplicaId.nแทน offset แบบง่าย ๆ เพื่อหลีกเลี่ยงปัญหาความหมายของตำแหน่งสั่นคลอนเมื่อมีการแก้ไขพร้อมกัน - ความขัดแย้งของการแทรกจะถูกจัดการอย่างสอดคล้องด้วย Lamport timestamp และการเรียงตาม
ReplicaIdส่วนการลบจะจัดการเงื่อนไขการผสานรวมด้วย tombstone และ version vector แทนการลบจริง - ในแง่การ implement ใช้ RLE บนฐาน
EditRunและ G-tree ที่แสดงด้วยดัชนีของ Vec เพื่อมุ่งให้ค้นหา·แทรกได้ในเวลาเชิงลอการิทึมภายใต้โมเดล ownership ที่ปลอดภัยของ Rust - ใน benchmark cola ถูกเปรียบเทียบกับ
diamond-types,automerge,yrsและใน upstream เร็วกว่าdiamond-types1.4~2 เท่า ส่วน downstream ก็แสดงประสิทธิภาพที่เร็วมากเช่นกัน
ปัญหาการแก้ไขร่วมกันที่ cola จัดการ
- cola คือ Text CRDT ที่เขียนด้วย Rust และออกแบบมาให้ replica หลายตัวลู่เข้าสู่สถานะเดียวกันในการแก้ไขร่วมกันแบบเรียลไทม์โดยไม่มีอำนาจส่วนกลาง
- ในชั้นเครือข่ายมีเพียงสมมติฐานขั้นต่ำว่า การแก้ไขทั้งหมดจะไปถึง peer ทั้งหมดในท้ายที่สุด
- การแก้ไขอาจถูกส่งหลายครั้งได้
- การแก้ไขอาจมาถึงในลำดับใดก็ได้
- peer ทุกตัวต้องได้รับการแก้ไขทั้งหมดในท้ายที่สุด
- เป้าหมายไม่ใช่แค่ให้สถานะเอกสารเหมือนกันหลังได้รับการแก้ไขทั้งหมดเท่านั้น แต่ยังต้องให้เอกสารผลลัพธ์อยู่ในรูปแบบที่ผู้ใช้ยอมรับได้ด้วย
ตรึงตำแหน่งด้วย Anchor แทน offset
- การแก้ไขแบบอิง offset เช่น
insert "abc" at offset 8อาจเปลี่ยนความหมายได้หาก peer อื่นแก้ไขพื้นที่ด้านหน้าพร้อมกัน ทำให้ replica แตกต่างกัน - cola อ้างอิงตำแหน่งในเอกสารด้วยตัวระบุที่เสถียร ไม่ใช่เนื้อหาอักขระหรือ offset
- ค่า
nสามารถรับประกันความเป็นเอกลักษณ์ได้ด้วยการเพิ่ม counter ภายในเครื่อง แต่การรับประกันความเป็นเอกลักษณ์ทั่วโลกของReplicaIdโดยไม่มีเซิร์ฟเวอร์กลางทำได้ยาก- cola ตั้งอยู่บนสมมติฐานว่าใช้จำนวนเต็มสุ่มขนาดใหญ่แบบ UUID ซึ่งมีโอกาสชนกันต่ำจนแทบมองข้ามได้
- การแทรกกำหนดตำแหน่งด้วย Anchor หนึ่งตัว ส่วนการลบกำหนดช่วงด้วย Anchor สองตัวคือจุดเริ่มต้นและจุดสิ้นสุด
จัดเรียงความขัดแย้งของการแทรกให้เป็นลำดับเดียวกัน
- เมื่อมีการแทรกหลายรายการเข้ามาที่ Anchor เดียวกัน cola จะกำหนดลำดับด้วย Lamport timestamp
- Lamport clock จะอัปเดตตามกฎต่อไปนี้
- เมื่อแทรกข้อความภายในเครื่อง ให้เพิ่ม clock ขึ้น 1
- เมื่อได้รับการแทรกจากระยะไกล ให้ตั้งเป็น
max(current, remote_timestamp) + 1
- หากการแทรก
Aถูกสร้างขึ้นในสภาพแวดล้อมที่ได้ผสานรวมการแทรกBแล้ว Lamport timestamp ของAจะมากกว่าB - การแทรกที่ชนกันจะถูกเรียงตาม Lamport timestamp จากมากไปน้อย
- สำหรับ การแทรกพร้อมกัน ที่ทั้ง Anchor และ Lamport timestamp เหมือนกัน ไม่มีลำดับที่ถูกต้องกว่าในมุมมองผู้ใช้ จึงเรียงตาม
ReplicaIdจากน้อยไปมากเพื่อให้ peer ต่าง ๆ สอดคล้องกัน
การลบจัดการด้วย tombstone และ version vector
- การลบจะแปลง offset เริ่มต้น·สิ้นสุดเป็น Anchor แล้วเผยแพร่ไปยัง peer อื่น
- หาก peer สองตัวลบพื้นที่เดียวกันพร้อมกัน ผลลัพธ์จะเหมือนกับการลบเพียงครั้งเดียว ตราบใดที่ไม่พิจารณา undo
- ปัจจุบัน cola ยังไม่รองรับ undo ของการลบ
- การจัดการการลบมีความยากสามประการ
- หากลบข้อความที่ถูกลบออกไปอย่างสมบูรณ์ Anchor ของการแก้ไขที่ยังมาไม่ถึงอาจอยู่ในพื้นที่นั้น
- หากผสานรวมการลบจากระยะไกลเร็วเกินไป เนื้อหาที่ peer ผู้สร้างการลบเห็นอาจต่างจากเนื้อหาของ peer ผู้รับ ทำให้ replica แตกต่างกัน
- แม้อยู่ในช่วงการลบ ก็ไม่ควรลบอักขระที่ peer ผู้สร้างการลบยังไม่เคยเห็น
- ปัญหาแรกแก้ด้วย tombstone
- อักขระที่ถูกลบยังคงอยู่ในเอกสาร แต่ถูกทำเครื่องหมายว่าถูกลบแล้ว
- วิธีนี้เพิ่มการใช้หน่วยความจำ
- ปัญหาที่เหลือแก้ด้วยการใส่ version vector ในข้อความการลบ
- key คือ
ReplicaIdส่วน value คือ timestamp ของอักขระล่าสุดที่ peer ผู้สร้างการลบเห็นในขณะนั้น - peer ผู้รับจะรอผสานรวมการลบจนกว่า version vector ของตัวเองจะมากกว่าหรือเท่ากับ version vector ของข้อความการลบ
- อักขระที่มี timestamp มากกว่า timestamp ใน version vector ของการลบจะถูกข้าม ไม่ถูกลบ
- key คือ
- ใน cola เรียก version vector ว่า version map
การแยก Replica ออกจาก text buffer
- สถานะเอกสารภายในเครื่องของ peer แต่ละตัวใน cola แสดงด้วย
Replica - อัลกอริทึม CRDT ของ cola ไม่จำเป็นต้องรู้เนื้อหาสตริงจริง
- ฟังก์ชัน API ไม่รับสตริงเป็น argument
- cola จัดการเฉพาะบล็อกตัวเลข ไม่ใช่เนื้อหาเอกสาร
- การออกแบบนี้แยก กลไก CRDT ออกจากการ implement text buffer จริง
ลด metadata ด้วย RLE และ EditRun
- หากติด metadata ให้ทุกอักขระ จะสร้าง implementation ที่มีประสิทธิภาพดีได้ยาก ดังนั้น cola จึงรวมบล็อกที่มี timestamp ต่อเนื่องกันด้วย run-length encoding
- เช่น หากวางเนื้อหาทั้งหมดของหน้า Manhattan Project ใน Wikipedia ก็อาจแสดงเป็นบล็อกเดียวได้ ไม่ใช่ 107,000 บล็อก
- กรณีที่พิมพ์ประโยคทีละอักขระโดยไม่ย้าย cursor หรือลบ ก็สามารถแสดงเป็น run เดียวได้เช่นกันโดยไม่สร้างบล็อกทุกครั้งที่กดคีย์
- ใน cola บล็อกต่อเนื่องลักษณะนี้เรียกว่า
EditRunEditRunที่แตกไปแล้วจะคงที่ตลอดอายุเอกสารและจะไม่ขยายอีก- run ที่ยังไม่แตกจะอยู่ในสถานะ active
- หากแทรกข้อความกลาง
EditRunที่มีอยู่ จะแบ่ง run เป็นสองส่วนแล้วใส่ข้อความใหม่ไว้ระหว่างกลาง - เมื่อลบข้อความ จะตัดส่วนนั้นออกจาก run และทำเครื่องหมายเป็น tombstone
- run ที่ถูกลบก็ทำ RLE ได้ด้วยวิธีเดียวกัน จึงลดภาระหน่วยความจำของ tombstone
จาก linked list สู่ B-tree
- เส้นทาง upstream ของการแก้ไขภายในเครื่องคือกระบวนการแปลงการแก้ไขแบบอิง offset ให้เป็นการแก้ไขที่ส่งให้ peer อื่นได้
- ทั้งการแทรกและการลบต้องหา run ที่มี offset อยู่เพื่อสร้าง Anchor
- หากจำเป็น จะแบ่ง run สูงสุด 2 ส่วนในการแทรก และสูงสุด 3 ส่วนในการลบ
- ใน linked list การแบ่ง run เป็น
O(1)แต่การหา run ที่มี offset อยู่ต้องสแกนตั้งแต่ต้น จึงใช้ เวลาเชิงเส้น - หาก cache active run ปัจจุบันและ offset ของมันไว้ จะเร็วเมื่อแก้ไขซ้ำที่ตำแหน่งเดิม แต่กรณีแย่ที่สุดยังคงเป็นเชิงเส้น
- cola พิจารณาโครงสร้าง B-tree เพื่อประสิทธิภาพกรณีแย่ที่สุดแบบเวลาเชิงลอการิทึม
- run แสดงเป็น leaf ของ B-tree
- inode เก็บ child และผลรวมความยาวของ child
- tombstone run มีส่วนร่วมเป็นความยาว 0
- ความยาวของ root เท่ากับความยาวรวมของเอกสาร
- B-tree สามารถค้นหาและแทรกได้ใน
O(log n)ทำให้จัดการการแก้ไขภายในเครื่องได้ในเวลาเชิงลอการิทึม - อย่างไรก็ตาม เพื่อให้การแปลง
Anchor -> runในการแก้ไขจากระยะไกลมีประสิทธิภาพ ต้องหา leaf ที่มี Anchor นั้นอยู่
G-tree: B-tree ที่แสดงด้วยดัชนี Vec
- แค่ pointer ไปยัง leaf ของ B-tree ทั่วไปไม่พอให้รู้ว่าจะลงไปถึง leaf อย่างไรในกระบวนการแทรกแบบ top-down
- การดำเนินการแบบ bottom-up ต้องใช้ parent pointer ของแต่ละ node และในโมเดล ownership ที่ปลอดภัยของ Rust จะต้องใช้โครงสร้างอย่าง
Rc<RefCell<_>>ซึ่งอาจช้าและซับซ้อน - cola เก็บ node ทั้งหมดไว้ใน dynamic array อย่าง
Vecและแสดงการอ้างอิงระหว่าง node ด้วย ดัชนี แทน pointer- vector เป็นเจ้าของ node ทั้งหมด
- แต่ละ node สามารถเก็บ parent index ได้
- สามารถค้นหาได้สองทิศทางโดยไม่ใช้โค้ด
unsafe
- โครงสร้างนี้พึ่งพาสมมติฐานว่าดัชนี node จะไม่เปลี่ยน
- node ใหม่ถูก append ที่ท้าย vector
- cola ไม่ลบ run แต่ทำเครื่องหมายเป็น tombstone จึงไม่เกิดปัญหาดัชนี invalid
- โครงสร้าง grow-only tree-in-a-vector แบบนี้ใน cola เรียกว่า G-tree
- โค้ด Rust ของ G-tree คือรูปแบบที่คงโครงสร้าง parent-child ของ B-tree ไว้ แต่เปลี่ยนเฉพาะการแทนค่าในหน่วยความจำ
คุณลักษณะด้านประสิทธิภาพที่ G-tree สร้างขึ้น
- G-tree ทำการค้นหาและแทรกแบบ top-down ได้ในเวลาเชิงลอการิทึมเช่นเดียวกับ B-tree
- สามารถใช้
LeafIdxซึ่งเป็นดัชนี vector ของ leaf เป็นตัวระบุ leaf ที่เสถียรได้ - สามารถ cache active run ปัจจุบันด้วย
LeafIdxแทน pointer ได้- หากแก้ไขซ้ำที่ตำแหน่ง cursor เดิม จะขยาย active run และอัปเดตเฉพาะความยาวของ ancestor จนถึง root
- อาจจัดการได้ด้วยการเปรียบเทียบจำนวนเต็มไม่กี่ครั้งและการบวกจำนวนเต็ม 2~4 ครั้ง โดยไม่ต้องไล่ tree และไม่ต้อง allocation ใหม่
- G-tree ของ cola ใช้ branching factor 32 และ inode มีอัตราการใช้งานเฉลี่ยประมาณ 20 child
- เพียง 4 ระดับก็โดยทั่วไปสามารถเก็บ
EditRunที่แตกต่างกันได้ราว 160k รายการ - หลังประมวลผล editing trace ของ
automerge-paperแล้ว G-tree ของ cola มีEditRunประมาณ 15k รายการ - trace ดังกล่าวมีการแก้ไข 260k รายการ และถูกบันทึกตลอดหลายวัน
- เพียง 4 ระดับก็โดยทั่วไปสามารถเก็บ
- G-tree ทำให้ serialization และ deserialization เรียบง่ายด้วย เพราะ node ถูกเก็บในหน่วยความจำเชิงเส้นอยู่แล้ว
แปลง Anchor เป็น LeafIdx
- เส้นทาง downstream ของการแก้ไขจากระยะไกลคือกระบวนการแปลงการแก้ไขแบบอิง Anchor เช่น
insert 2.3..7 at 1.2,delete between 3.4 and 2.2ให้เป็นการแก้ไขแบบอิง offset ของเอกสารภายในเครื่อง - เนื่องจาก G-tree ค้นหาขึ้นลงได้ หากรู้
LeafIdxของ run ที่มี Anchor ใด ๆ อยู่ ก็สามารถคำนวณ offset นั้นได้ - หัวใจคือการแปลง Anchor -> LeafIdx
- การออกแบบแบบง่ายคือมี G-tree หรือ B-tree เสริม โดยเก็บ
ReplicaId, temporal range และLeafIdxของ G-tree หลักไว้ที่ leaf- leaf ถูกเรียงอย่างสมบูรณ์ตาม
ReplicaIdและ temporal range - สามารถไล่ลง tree โดยอิง Anchor เพื่อหา
LeafIdxที่ต้องการได้ - การค้นหาและแทรกเป็น
O(log n)ทั้งคู่
- leaf ถูกเรียงอย่างสมบูรณ์ตาม
- ซอร์สโค้ดจริงของ cola ไม่ได้ใช้วิธี G-tree เสริมนี้ตรง ๆ
- implementation จริงมีการค้นหาและแทรกเป็น
O(log f) fคือจำนวน fragment ที่EditRunซึ่งมี Anchor อยู่ถูกแบ่งออกไปตามเวลาfจะไม่เกินnเสมอ และโดยปกติน้อยกว่ามาก
- implementation จริงมีการค้นหาและแทรกเป็น
ระดับความสมบูรณ์ปัจจุบันและงานที่เหลือ
- การออกแบบของ cola มีพื้นฐานที่มุ่งเป้าเรื่องการลู่เข้า การรักษาเจตนา และประสิทธิภาพ
- เพื่อให้พร้อมสำหรับ production ยังเหลืองานเพิ่มเติมบางอย่าง รวมถึงการรองรับ undo/redo
Benchmark CRDT ของ Rust
- cola ถูกเปรียบเทียบกับ CRDT บน Rust 3 ตัว
- benchmark วัดเวลาที่ใช้ประมวลผล editing trace จริงแบบ character-by-character
- ใช้ไลบรารี benchmark ของ Rust ชื่อ criterion
- วัดทั้งสองทิศทางคือ upstream และ downstream
- โค้ด benchmark อยู่ที่ crdt-benches
- กราฟใช้ 100 เท่าของประสิทธิภาพ cola เป็นเส้นฐาน และไม่แสดงค่าที่ช้ากว่า cola 100 เท่าขึ้นไป
- สภาพแวดล้อมการรันคือ 2018 MacBook Pro, 2.2GHz 6-Core Intel Core i7
- ตัวเลขอาจต่างกันบนเครื่องอื่น แต่คาดว่าประสิทธิภาพสัมพัทธ์จะใกล้เคียงกัน
ผล benchmark
- ในทิศทาง upstream
yrsและautomergeเกินเส้นฐาน - ใน upstream cola เร็วกว่า
diamond-types1.4~2 เท่า - ในทิศทาง downstream
diamond-typescrash ในทุก trace จึงไม่ได้ค่าการวัด- มีหมายเหตุว่าหากเป็นเพราะใช้ไลบรารีผิด จะอัปเดตผลลัพธ์
- ใน downstream cola ช้ากว่าเดิมประมาณ 2 เท่า
- เป็นผลลัพธ์ที่คาดไว้ เพราะโดยทั่วไปการผสานรวมการแก้ไขจากระยะไกลแพงกว่าการสร้างการแก้ไขภายในเครื่อง
- cola ทำงานได้ในระดับใกล้เคียงหรือเร็วกว่าไลบรารี rope ที่เร็วทั้งสองทิศทาง และถูกประเมินว่าเป็น implementation ของ Text CRDT ที่เร็วที่สุดในปัจจุบัน
1 ความคิดเห็น
ความคิดเห็นใน Hacker News
ผมยังมองว่า G-tree ก็คือ B-tree ที่เพิ่มพอยน์เตอร์ไปยังพาเรนต์ อยู่ดี
การที่มันถูกเก็บในอาร์เรย์เป็นแค่เรื่องของวิธีแทนข้อมูล ไม่ได้เปลี่ยนโครงสร้างโดยพื้นฐาน
มันยังคงเก็บพอยน์เตอร์ เพียงแต่ไม่ได้เป็นหน่วยไบต์ แต่เป็นหน่วยขนาดของโหนด และเป็นตำแหน่งสัมพัทธ์จากสมาชิกตัวแรกของอาร์เรย์ ไม่ใช่จากจุดเริ่มต้นของ address space
ตัวอย่างเช่น ต้นไม้ทวิภาคสมบูรณ์ที่เก็บในอาร์เรย์โดยไม่มีการอ้างอิงแบบชัดเจน ก็เป็นแค่ การแทนแบบแฝง ที่ลูกของดัชนี x อยู่ที่ 2x + 1 และ 2x + 2 แต่เราก็ยังเรียกมันว่าต้นไม้ทวิภาคอยู่ดี
โดยเฉพาะในภาษาที่ชักนำให้ใช้การอ้างอิงแบบสัมพัทธ์และจัดการตัวเอง มากกว่าการอ้างอิงแบบ absolute
หนึ่งในวิธีแทนต้นไม้ที่ผมสนใจคือการเก็บโหนดไว้ในอาร์เรย์แบน ๆ ตามลำดับการสำรวจแบบ depth-first
ถ้าเป็นแบบอ่านอย่างเดียว และผู้อ่านตั้งใจจะไล่แบบ depth-first อยู่แล้ว ก็อาจมีประสิทธิภาพพอสมควร
ทำให้นึกถึง S-expression กับ HTML
ถ้าไม่ได้พลาดอะไรใน API ดูเหมือนว่านี่จะไม่รองรับ ช่วงการจัดรูปแบบ อย่างตัวหนา/ตัวเอียง
เท่าที่ผมรู้ ในอัลกอริทึม CRDT สำหรับ rich text ตอนนี้ Peritext ยังถือเป็นระดับแนวหน้า https://www.inkandswitch.com/peritext/
หวังว่าโปรเจกต์นี้จะนำความสามารถด้าน rich text ของอัลกอริทึม Peritext มาด้วย
คือให้ผู้ใช้กำหนดโมเดลหรือสคีมาที่อธิบายสถานะที่ถูกต้องตามความหมาย
ตัวอย่างเช่น ถ้า merge JSON แบบตรง ๆ อาจได้สถานะที่ถูกต้องตามไวยากรณ์ แต่ไม่มีความหมายที่สมเหตุสมผล
เหมือนที่อัลกอริทึม Peritext รู้ว่าตัวหนา/ตัวเอียง/ขีดเส้นใต้เป็นการดำเนินการที่เพิ่มทับกันได้ แต่สีไฮไลต์ไม่ใช่ ผมคิดว่าคงดีถ้าผู้ใช้ประกาศในสคีมาได้ว่า
state: notStartedกับcompletionDate: 2023-09-04อยู่ร่วมกันไม่ได้ถ้าอย่างนั้น เราจะแสดง ตัวการจัดรูปแบบไว้ในข้อความเอง แบบ HTML ไม่ได้หรือ?
ผมไม่ได้รู้จักการแทน rich text แบบอื่นดีนัก เลยไม่ค่อยแน่ใจ
อีกอย่าง หลังจากดูเอกสาร Peritext ที่ลิงก์ไว้ข้างบน ก็เห็นว่ามันกำลังจัดการความยากเฉพาะตัวของ RTF แบบนี้พอดี และอ่านแล้วน่าสนใจทีเดียว
เมื่อเทียบกับ Automerge หรือ Y.js/Yrs มีความต่างด้านประสิทธิภาพหรือฟีเจอร์อย่างไรบ้าง?
ไลบรารี cola นี้ดูค่อนข้างได้เปรียบในแง่ความเร็วของการดำเนินการ
ผมก็สงสัยเรื่องการใช้หน่วยความจำเหมือนกัน
อย่าสับสนกับงานชื่อเดียวกันของ Ian Piumarta
https://www.piumarta.com/software/cola/
https://citeseerx.ist.psu.edu/viewdoc/download;jsessionid=91...
เป็นงานที่ทำออกมาดี แต่ดูไม่เหมือน benchmark ที่เป็นธรรม
มันไม่ได้คำนวณและเก็บ operation และไม่ได้เก็บข้อความจริงด้วย
ถ้าจะทำ text CRDT ที่รองรับ delta update บนฐานนี้ ผู้ใช้ต้องเก็บ
OpID => Textไว้ในโครงสร้างแยกต่างหาก ซึ่งต้นทุนไม่ถูกถ้าใช้ crate
slotmap(https://docs.rs/slotmap/latest/slotmap/) จะรองรับการลบได้โดยไม่ต้องกังวลว่าดัชนีจะย้าย หรือ “ดัชนี” จะไปชี้ค่าอื่นใน slotmap จะเรียกสิ่งนี้ว่า key และใน key ก็มี หมายเลขเวอร์ชัน อยู่ด้วย
ผมสงสัยเพราะการกำหนดลำดับทำได้ยาก และเวอร์ชันอาจมีความหมายแค่ในเครื่องเท่านั้น
คิดว่าจะลองเอาไปใช้กับโปรเจกต์ที่ Etherpad กับ Word รับไม่ไหวดูสักครั้ง
ถ้าสมมติว่าไคลเอนต์ที่เชื่อมต่อทั้งหมดได้รับการแก้ไขทั้งหมด ทำไมไม่ใส่ แฮชที่คาดไว้ ของสถานะข้อความเดิมไว้หน้าคำสั่ง offset และ insert/delete/replace ล่ะ?
แบบนั้นแทบจะรับประกันได้ว่าการแก้ไขจะถูกนำไปใช้กับสถานะที่เหมาะสมเท่านั้น และการเปลี่ยนแปลงถัด ๆ ไปก็สามารถสะสมไว้ใน dictionary ที่ใช้ “แฮชของสถานะข้อมูลที่คาดว่าจะถูกนำไปใช้” เป็น key ได้
แน่นอนว่าต้นทุนจะสูง เพราะต้องคำนวณแฮชซ้ำบนข้อมูลเดียวกัน แต่เข้าใจและ implement ได้ง่ายมาก
ถ้าผมแก้เอกสารแบบ local และคุณแก้แบบ remote แฮช “สถานะที่คาดไว้” ของคุณจะไม่มีวันตรงกับสถานะเอกสารของผมอีกต่อไป
เพราะผมแก้ในเครื่องไปแล้ว
ถ้า CRDT จะรับประกันการ converge ก็จริงที่ไคลเอนต์ทุกตัวต้องได้รับการแก้ไขทั้งหมด แต่คุณสมบัติที่ว่าไม่จำเป็นต้อง apply update ตามลำดับเฉพาะนั้นสำคัญใน use case แบบ distributed ในโลกจริง
สงสัยว่ามีวิธีเปิด การแก้ไขฟอร์มร่วมกัน ในเบราว์เซอร์แบบง่าย ๆ ไหม
ถ้าคนสองคนเปิดหน้าเดียวกันหรือฟอร์มเดียวกัน แล้วเห็นได้ว่าอีกฝ่ายกำลังแก้ input field ไหนอยู่ และในช่องกรอกข้อความก็ใช้ text CRDT แบบนั้นคงดี
เคยลองทำอะไรคล้าย ๆ กันด้วย Yjs แต่ค่อนข้างยากและทำงานได้ไม่ดี
ดูเหมือนมันน่าจะเหมาะกับงานนั้นพอดี