לדלג לתוכן

חוזי פרוטוקול כאשר התוצאה אינה ידועה

לקוח שולח בקשה, השירות מבצע commit ואז החיבור נקטע לפני שהתגובה מגיעה. הלקוח רואה timeout, אבל הפעולה כבר קרתה. אם נשלח שוב בלי מזהה, נוכל לבצע אותה פעמיים. אם לא נשלח שוב, הלקוח לא ידע אם התוצאה קיימת. נבנה חוזה שמאפשר לברר את המצב בלי לנחש על פי תוצאת write או read.

ה־request ID יהיה חלק מן הנתון העמיד, ולא רק שדה בלוג. השירות ישמור גם את תוכן הבקשה או fingerprint שמתאים למודל האמון, ואת התוצאה. בקשה חוזרת עם אותו ID ואותו תוכן תחזיר את אותה תוצאה; אותו ID עם תוכן אחר יחזיר Conflict. שימוש ב־hash קצר ללא טיפול בהתנגשות אינו הוכחה ששתי בקשות זהות.

המודל הבא הוא תוכנית מלאה בזיכרון. הוא מדגים את הסמנטיקה לפני שנחבר אליה את היומן. במערכת העמידה פעולת הכנסה למפה תתרחש רק אחרי commit מוצלח לפי החוזה.

use std::collections::BTreeMap;

#[derive(Debug, PartialEq)]
enum Reply { New(u64), Existing(u64), Conflict }
#[derive(Default)]
struct Registry { entries: BTreeMap<u64, (Vec<u8>, u64)> }
impl Registry {
    fn submit(&mut self, id: u64, bytes: &[u8]) -> Reply {
        if let Some((old, result)) = self.entries.get(&id) {
            return if old == bytes { Reply::Existing(*result) } else { Reply::Conflict };
        }
        let result = bytes.iter().map(|&b| u64::from(b)).sum();
        self.entries.insert(id, (bytes.to_vec(), result));
        Reply::New(result)
    }
}
fn main() {
    let mut registry = Registry::default();
    assert_eq!(registry.submit(7, b"abc"), Reply::New(294));
    assert_eq!(registry.submit(7, b"abc"), Reply::Existing(294));
    assert_eq!(registry.submit(7, b"abd"), Reply::Conflict);
}

הטבלה עצמה יכולה לגדול לנצח. לכן נגדיר תקופת deduplication או מגבלת מספר בקשות, ונאמר ללקוח מה קורה אחרי שהרשומה פונתה. אם נמחק ID אחרי שעה אבל הלקוח רשאי לנסות שוב אחרי שבוע, ההבטחה המקורית כבר אינה מתקיימת. אפשר להשתמש ב־namespace של client ובמספר רצף, אך גם אז צריך לטפל באתחול client, retention ובאימות הזהות שלו.

התשובה Unknown אינה תמיד זהה ל־NotFound. אחרי כשל I/O במהלך fsync ייתכן שהרשומה נכתבה והפכה עמידה, אף שהשירות לא קיבל אישור ברור. לא נעדכן מפה בזיכרון ונמשיך לקבל כתיבות כאילו הכול ידוע. נוכל להעביר את רכיב האחסון למצב poisoned, לעצור admission ולבצע recovery לפני קבלת החלטות חדשות. אם הלקוח מקבל Unknown הוא רשאי לבצע query או retry עם אותו ID, ולא להמציא ID חדש.

הפרוטוקול יגדיר גם גרסה ויכולות. לקוח ישן צריך לקבל UnsupportedVersion עבור בקשה שאינו יכול להתפרש בבטחה, ולא טיפול חלקי בשדות שהשירות לא מבין. שדות אורך נבדקים לפני הקצאה ולפני חיבור מספרים. השגיאה הפנימית io::Error אינה חייבת להיחשף כלשונה למשתמש, אבל קוד התוצאה החיצוני צריך לשמר את ההבחנה בין קלט לא חוקי, עומס, כשל עמידות ותוצאה לא ידועה.

ה־timeout יימדד כ־deadline כולל באמצעות Instant עבור פעילות מקומית. אם נחדש timeout מלא אחרי כל byte, לקוח איטי יכול להחזיק משאב לנצח. זמן wall clock מתאים לתיעוד ולתוקף לפי מדיניות, אבל אינו השעון הנכון לחישוב יתרת המתנה שחייבת להיות יציבה מול שינוי השעה.

החוזה של fsync והחוזה של send עוזרים להבין מה כל שכבה מאשרת. הצלחת send אינה אישור שהלקוח עיבד את התוצאה, והצלחת write אינה בהכרח commit עמיד. ה־API של המוצר צריך להגדיר את המשמעות שמעל הפעולות האלה.

נריץ בדיקה שבה השרת מבצע commit עבור ID מספר 7 ואז סוגר את החיבור לפני reply. הלקוח שולח שוב ID מספר 7 עם אותו payload, ואנחנו בודקים לא רק שהתוצאה זהה אלא גם שמספר רשומות היומן לא גדל. לאחר מכן נשלח את אותו ID עם byte אחד שונה ונדרוש Conflict גם אחרי restart. הבדיקה הזאת מפרידה בין cache מקרי של תוצאה לבין חוזה deduplication עמיד. אם האינדקס נבנה רק בזיכרון ולא מן הרשומות, שתי ההרצות הראשונות באותו תהליך יכולות לעבור, ורק מבחן ה־restart יחשוף שההבטחה נעלמה.