עדכון אטומי ופרוטוקול התאוששות¶
נניח שקובץ תצורה מכיל גרסה תקינה, ואנחנו רוצים להחליף אותו בגרסה חדשה. פתיחה עם truncate ואז כתיבה יכולה להשאיר קובץ ריק או חלקי אם התהליך נכשל באמצע. נעדיף לבנות אובייקט חדש, להשלים אותו ורק אז לפרסם אותו בשם שהקוראים מכירים.
הפעולה rename בתוך מערכת קבצים מתאימה מאפשרת החלפה אטומית של שם מבחינת נראות הפעולה לפי החוזה. קורא שפותח את השם רואה אובייקט לפני ההחלפה או אחריה, ולא חצי שם. זאת אינה לבדה הבטחה שהשם החדש והתוכן שלו ישרדו כשל מערכת, ולכן נחבר אותה לסדר סנכרון מפורש.
הדוגמה הבאה היא תוכנית מלאה לתיקיית ניסוי פרטית חדשה. היא משתמשת בשם זמני קבוע רק משום שה־directory נוצר במיוחד וכל ניסיון נוסף נכשל ב־create_new; היא אינה פונקציית עדכון כללית לתיקייה שמשתמשים אחרים משנים.
use std::{fs::{self, File, OpenOptions}, io::{self, Write}, path::Path};
fn replace(directory: &Path, content: &[u8]) -> io::Result<()> {
let temporary = directory.join("state.new");
let destination = directory.join("state");
let mut file = OpenOptions::new().write(true).create_new(true).open(&temporary)?;
file.write_all(content)?;
file.sync_all()?;
fs::rename(&temporary, &destination)?;
File::open(directory)?.sync_all()?;
Ok(())
}
fn main() -> io::Result<()> {
fs::create_dir("replace-lab")?;
File::open(".")?.sync_all()?;
replace(Path::new("replace-lab"), b"generation=1\n")?;
replace(Path::new("replace-lab"), b"generation=2\n")?;
assert_eq!(fs::read("replace-lab/state")?, b"generation=2\n");
Ok(())
}
הקובץ הזמני מסונכרן לפני פרסום השם, והספרייה מסונכרנת אחרי ההחלפה. כשל אחרי rename ולפני סנכרון הספרייה מחייב דיווח זהיר: התהליך הנוכחי עשוי לראות את הגרסה החדשה, אבל אין לנו עדיין אישור מלא של הפרוטוקול מול הכשל שהגדרנו. אין להבטיח למשתמש שהפעולה לא קרתה רק משום שהפונקציה החזירה שגיאה בשלב מאוחר.
כשל מוקדם יכול להשאיר את הקובץ הזמני. זאת אינה סיבה למחוק אוטומטית כל קובץ בשם דומה בתיקייה כללית. תהליך התאוששות צריך לזהות קבצים שהוא עצמו יצר, לבדוק את מצבם ולהחליט אם להשלים, לשמור לאבחון או להסיר. בפרוטוקול עם כמה כותבים נדרשים שמות ייחודיים ותיאום נוסף.
החלפה של inode משנה גם שאלות metadata. הרשאות, ACL ותכונות של הקובץ הישן אינן עוברות קסם לחדש. אם המוצר רוצה לשמר אותן, עליו להכין את האובייקט החדש בהתאם לפני פרסום השם. מתאר שקורא החזיק לפני rename ממשיך לראות את האובייקט הישן, וזה יכול להיות יתרון עבור קורא שצריך גרסה יציבה.
עבור הרבה רשומות קטנות, החלפת קובץ שלם בכל פעולה יכולה להיות יקרה. יומן write-ahead מאפשר לרשום שינוי מתוכנן לפני עדכון מבנה אחר. כדי להתאושש צריך לזהות רשומה שלמה, סדר נכון וגבול התחייבות. אורך מפורש, מספר רצף ו־checksum יכולים לעזור לזהות זנב חלקי, אבל צריך להגדיר מה בדיוק נחשב commit.
לדוגמה, רשומה יכולה להכיל כותרת עם magic, גרסה, רצף ואורך, אחריה payload ובסוף checksum שמכסה את החלקים המוגדרים. קורא התאוששות בודק גבולות לפני הקצאה, מוודא שהרצף מתאים ועוצר כאשר הזנב אינו שלם. אם מופיע שיבוש באמצע יומן שאמור להכיל פעולות מאושרות, אין להתייחס אליו בשקט כאל זנב שלא התחייב.
הסדר בין היומן למבנה הנתונים הוא חלק מהפרוטוקול. אם אישרנו למשתמש לפני שהיומן נעשה עמיד, אין בסיס להבטיח שחזור. אם עדכנו מבנה נתונים לפני שהרשומה המתאימה נרשמה, הקריסה יכולה להשאיר מצב שההתאוששות אינה יודעת להסביר. את נקודת האישור נסמן במפורש בקוד ובמבחני הכשל.
מספר רצף מונע חלק מהבלבול בין סדרות, אבל צריך לטפל גם בגלישה, בהחלפת קובץ יומן ובמחזור גרסאות. ה־checksum מזהה שיבושים לפי תכונותיו ואינו הוכחה שלא הייתה כתיבה זדונית. כאשר המוצר דורש אותנטיות, צריך מנגנון אחר וחשבון איומים מתאים.
החוזה של החלפת שם מופיע בעמוד rename. בפרויקט הפרק נבנה יומן קטן ונזריק כשל בכל גבול בייט כדי לבדוק את parser ההתאוששות, ובנפרד נבדוק את סדר קריאות הסנכרון ואת גבול הכשל של האחסון.