שדרוג נתונים ועדכון תוכנה¶
גרסה חדשה יכולה לקרוא פורמט חדש, אבל מה יקרה אם נצטרך לחזור ל־binary הישן אחרי שהפורמט כבר השתנה? החלפת executable היא פעולה קטנה ביחס לשינוי נתונים עמידים. נפריד בין תאימות קוד, תאימות schema ופרוטוקול בחירת הדור הפעיל, ונבדוק קריסה בכל שלב של ההגירה.
נשתמש בשיטה של דורות נפרדים. קובץ generation חדש נכתב ונבדק בלי לשנות את הישן. רק אחרי שהוא סונכרן נעדכן manifest קטן שמצביע אליו. כך אפשר להבחין בין יצירת נתונים לבין פרסום הבחירה. הדוגמה הבאה היא תוכנית מלאה שמפרסמת דור חדש בתיקיית מעבדה קיימת ומהימנה; היא אינה פועלת על נתיבי משתמש לא מהימנים.
use std::fs::{File, OpenOptions};
use std::io::{self, Write};
use std::path::Path;
fn publish(directory: &Path, generation: &str, bytes: &[u8]) -> io::Result<()> {
if generation.is_empty() || !generation.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid generation"));
}
let mut data = OpenOptions::new().write(true).create_new(true)
.open(directory.join(generation))?;
data.write_all(bytes)?;
data.sync_all()?;
File::open(directory)?.sync_all()?;
let temporary = directory.join(format!("manifest-new-{}", std::process::id()));
let mut manifest = OpenOptions::new().write(true).create_new(true).open(&temporary)?;
writeln!(manifest, "{generation}")?;
manifest.sync_all()?;
std::fs::rename(&temporary, directory.join("manifest"))?;
File::open(directory)?.sync_all()?;
Ok(())
}
fn main() -> io::Result<()> {
let directory = std::env::args().nth(1).ok_or_else(||
io::Error::new(io::ErrorKind::InvalidInput, "existing lab directory required"))?;
publish(Path::new(&directory), "generation-2", b"schema=2\ncount=42\n")
}
ה־create_new מונע דריסה שקטה של דור או קובץ זמני קיים. אם הרצה קודמת השאירה קובץ, הבדיקה הבאה תיכשל ותדרוש חקירה; לא נמחק אותו אוטומטית בלי לדעת מי יצר אותו. ה־directory sync של הנתונים מקדים את פרסום ה־manifest כדי שלא נפרסם שם שאינו עמיד. ה־rename נותן שינוי אטומי של שם במסגרת החוזה של אותה מערכת קבצים, ואחריו נדרש סנכרון התיקייה כדי להשלים את התחייבות הפרסום.
אם סנכרון התיקייה האחרון נכשל, התוצאה אינה בהכרח "השדרוג לא קרה". הבחירה החדשה יכולה להיות גלויה או עמידה באופן שלא קיבלנו עליו אישור. נעצור את העדכון ונבצע recovery שקורא ומאמת את manifest ואת הדור שאליו הוא מפנה. אין לנסות rollback עיוור שמוחק את הדור החדש, מפני שהוא עשוי להיות הדור היחיד שה־manifest התקף מצביע אליו.
הפורמט עצמו צריך magic, version, אורך ודרך לאתר corruption לפי האיום הרלוונטי. ה־checksum מזהה תקלות מקריות מסוימות ואינו מוכיח מקור מהימן. חתימה על חבילת תוכנה מאמתת שרשרת אמון מוגדרת, אבל אינה מוכיחה שה־migration משמר משמעות נתונים. נבדוק invariants כמו מספר רשומות, קשרי מפתחות ותוצאות חישוב לפני הפרסום.
החזרה לאחור יכולה להיות אפשרית רק עד נקודה מסוימת. אם השירות החדש התחיל לכתוב שדות שהגרסה הישנה אינה מבינה, חזרה ל־binary הישן עשויה לדרוש export לפורמט הישן או שחזור backup, עם אובדן שינויים מאוחר יותר. נציין RPO ו־RTO מראש, ונבדוק את נתיב השחזור על עותק נפרד. צילום snapshot של קובץ פתוח בלי תיאום יכול להכיל דור לא עקבי גם אם כלי הגיבוי הצליח להעתיק אותו.
התיעוד על rename ועל fsync מגדיר את אבני הבניין. ההגירה מוסיפה מעליהן סדר פעולות ו־recovery שמסביר כל prefix אפשרי של הביצוע, ולא רק את המסלול שבו כל הקריאות הצליחו.
נניח שדור 1 מכיל ספירה כמספר עשרוני ודור 2 מוסיף לכל רשומה מזהה ו־checksum. ההגירה תקרא את כל דור 1 תחת snapshot או נעילת כתיבה מתאימה, תחשב תוצאות ותוודא שסכום הרשומות נשמר לפני פרסום דור 2. אם השירות ממשיך לקבל כתיבות בזמן ההעתקה, צריך גם יומן שינויים או פרוטוקול dual-write עם התאוששות; העתקת קבצים ברקע לבדה אינה snapshot עקבי. במעבדה נבחר תחילה חלון תחזוקה שבו אין כתיבות, נמדוד את משכו, ורק אחר כך נציע הגירה מקוונת. הבחירה הפשוטה מאפשרת הוכחה מלאה במקום הבטחה של זמינות שלא נבדקה.