התאוששות בלי לנחש מה קרה¶
נניח שהשירות קיבל בקשה, שלח אותה לרכיב אחר וקרס רגע לפני שענה ללקוח. אחרי האתחול הלקוח שולח שוב את אותה בקשה. אם נבצע אותה שוב אולי נשנה מצב פעמיים, ואם נדחה אותה אולי נאבד פעולה שלא בוצעה בכלל.
הבעיה אינה נפתרת באמצעות try נוסף. צריך לזהות את הפעולה, לדעת איזה state שרד את הקריסה ולהגדיר מה אפשר לברר אצל הצד שביצע את השינוי. בשיעור הזה נחבר את הדברים שכבר בנינו ליומן התאוששות אחד.
מפרידים בין כוונה לתוצאה¶
היומן המקומי שומר intent לפני שליחת הפעולה. אחרי שהצד המבצע עונה, שומרים result ורק אחר כך מאשרים ללקוח. עדיין נשאר חלון שבו הפעולה בוצעה אבל result לא נרשם מקומית. החלון הזה אינו נעלם בגלל שהintent נכתב היטב.
הצד המבצע צריך לספק חוזה מתאים: פעולה idempotent לפי מזהה ותוכן, אפשרות query אמינה, או transaction משותפת שמכסה את השינוי ואת הרישום. אם אין אף אחד מהם, יש מקרים שבהם לא ניתן להכריע אוטומטית. מערכת כנה מציגה מצב לא ידוע ומסלול טיפול מוגדר.
בודקים כל מקום שבו אפשר ליפול¶
המודל הבא מפריד בין journal מקומי לבין יעד שמספק deduplication עמיד. אנחנו מניחים שכל פעולה במפות המצוינות כעמידות היא transaction אטומית. אלה הנחות של המודל, ולא תכונה קסומה של std::map או של כתיבה לקובץ.
#include <cassert>
#include <cstdint>
#include <map>
#include <optional>
#include <stdexcept>
struct Request { std::uint64_t id; std::uint32_t amount; };
struct Remote {
std::map<std::uint64_t,std::uint32_t> committed;
std::uint64_t total = 0;
std::uint32_t apply(Request request) {
auto found = committed.find(request.id);
if (found != committed.end()) {
if (found->second != request.amount) throw std::logic_error("id conflict");
return found->second;
}
// One atomic durable transaction in the model.
committed.emplace(request.id, request.amount);
total += request.amount;
return request.amount;
}
};
struct Journal {
std::optional<Request> intent;
std::optional<std::uint32_t> result;
};
enum class Cut { before_intent, after_intent, after_effect, after_result, none };
bool attempt(Journal& journal, Remote& remote, Request request, Cut cut) {
if (cut == Cut::before_intent) return false;
if (journal.intent && (journal.intent->id != request.id ||
journal.intent->amount != request.amount))
throw std::logic_error("journal conflict");
journal.intent = request;
if (cut == Cut::after_intent) return false;
if (!journal.result) {
auto answer = remote.apply(request);
if (cut == Cut::after_effect) return false;
journal.result = answer;
}
if (cut == Cut::after_result) return false;
return true;
}
int main() {
for (Cut cut : {Cut::before_intent,Cut::after_intent,Cut::after_effect,
Cut::after_result,Cut::none}) {
Journal journal;
Remote remote;
bool first_ack = attempt(journal,remote,{7,100},cut);
if (first_ack) assert(journal.result == 100);
bool retry_ack = attempt(journal,remote,{7,100},Cut::none);
assert(retry_ack && journal.result == 100);
assert(remote.total == 100 && remote.committed.size() == 1);
}
}
הבדיקה החשובה היא after_effect. היעד כבר הגדיל את total, אבל הjournal עדיין לא מחזיק result. הניסיון הבא פונה שוב עם אותו מזהה, והיעד מחזיר את התוצאה הקיימת. אם נחליף את המזהה, נשבור את ההוכחה גם אם כל השאר נשאר נכון.
המודל מכיל פעולה אחת בכל journal כדי שהחלונות יהיו ברורים. במערכת של כמה לקוחות המפתח כולל גם זהות אמינה, כמו בשיעור הקודם. יש להגדיר retention, קיבולת והתנהגות כשמזהה ישן כבר אינו ניתן לבירור.
איך עוברים מהמודל לדיסק¶
הjournal מפרק האחסון נותן מסגרת עם אורך, גרסה ובדיקת תקינות. במסלול השמירה צריך להגדיר מה נחשב commit ומה מובטח אחרי flush לפי מערכת הקבצים והאחסון שנבחרו. כיבוי תהליך באמצע כתיבה אינו אותו ניסוי כמו אובדן חשמל, ולכן מדווחים איזה סוג כשל נבדק.
הrecovery קורא רק רשומות שלמות שהחוזה מאפשר לקבל. רשומה חלקית בזנב יכולה להיות כתיבה שנקטעה; השחתה באמצע רצף שכבר אושר דורשת טיפול אחר. אסור לדלג על כל שגיאת checksum ולהמשיך כאילו אפשר לדעת מה הלקוח כבר קיבל.
שדרוג משנה גם את מי שמבין את הנתונים¶
נניח שגרסה 2 מוסיפה יחידת מידה לשדה amount. אם נחליף רק את executable וניתן לגרסה הישנה לקרוא את הנתונים החדשים אחרי rollback, היא עלולה לפרש bytes תקינים במשמעות שגויה. לכן גרסת schema והחוזה של rollback הם חלק מאותה החלטה.
מסלול פשוט למעבדה עוצר קבלת עבודה חדשה, מרוקן עבודה פעילה, מעתיק snapshot לgeneration חדש, מאמת אותו ורק אז מפרסם manifest שמצביע עליו. הgeneration הישן נשמר עד שהמדיניות מאפשרת לפנותו. כל עוד נכשלים לפני הפרסום, הוא נשאר מקור האמת.
אם מאפשרים כתיבה במקביל להעתקה, חייבים להוסיף מנגנון שמביא את השינויים שהתרחשו בזמן ההעתקה. העתקת קובץ תוך כדי עבודה אינה snapshot עקבי מעצמה. אפשר להשתמש בלוג שינויים עם cutover מוגדר, אבל צריך להוכיח שלא אבד שינוי ולא הוחל פעמיים.
מתי rollback כבר אינו החלפת קובץ¶
אחרי שהגרסה החדשה אישרה עבודות בפורמט שהישנה אינה מבינה, חזרה בינארית בלבד אינה מספיקה. האפשרויות הן תאימות לאחור שנבנתה מראש, reverse migration שאפשר להוכיח, או החלטה לעצור ולתקן קדימה. את הבחירה קובעים לפני הכשל.
הבדיקה הסופית כוללת נפילה לפני intent, אחרי intent, אחרי effect, אחרי result ואחרי פרסום generation חדש. בכל נקודה רושמים מה הלקוח כבר שמע, מה כל רכיב זוכר ומה מותר לבצע לאחר restart. כך המילה ״עמיד״ מקבלת משמעות שאפשר לבדוק.