קובץ הגדרות שעבד לפני חמש שניות הוא כעת שבר של 347 בתים של JSON שבור. כלי ה-CLI שלך לא יתחיל לפעול. המשתמש, שפשוט לחץ על Ctrl-C כי העדכון לקח יותר זמן מהצפוי, בוהה כעת ב-stack trace של שגיאה שהוא לא ביקש. שני הקילובייטים של ההגדרות התקינות שהיו קיימים לפני הכתיבה נעלמו, והוחלפו במקבילה הדיגיטלית של קבלה שהודפסה למחצה.

זה קורה מכיוון ש-writeFile אינו אטומי. הוא פותח את הנתיב הקיים, מקצץ אותו (truncates), מעביר נתונים מ-Node אל ה-page cache של הליבה (kernel), ובסופו של דבר סוגר את ה-file descriptor. ברגע שהקיצוץ מתרחש, התוכן הישן כבר אינו קיים. כל מה שקורה בין הקיצוץ הזה לבין ה-close הסופי הוא חלון של פגיעות. SIGINT, הפסקת חשמל, או סגירה חזקה של מכסה הלפטופ במהלך החלון הזה, ישאירו את מערכת הקבצים עם בלגן מקוטע. גם אם Node מדווח שה-Promise נפתר (resolved), מערכת ההפעלה עשויה עדיין לאגור כתיבות בזיכרון (buffering). מתודות נוחות מסתירות את הפער הזה, אך הן לא מסירות אותו.

הפתרון אינו לכתוב במקום הקיים. הפתרון הוא להפריד בין פעולת הכתיבה לבין פעולת הפרסום.

כתיבה לקובץ אחי, ואז החלפה

התבנית האמינה כוללת חמישה שלבים. אף אחד מהם אינו מסובך, אך יחד הם מצמצמים את חלון הכשל מכתיבה שלמה בזרם (streaming write) לפעולה בודדת של מטא-דאטה של מערכת הקבצים.

ראשית, בצע סריאליזציה (serialize) לכל המטען בזיכרון. עשה זאת לפני יצירת קובץ זמני כלשהו. אם JSON.stringify זורק שגיאה כי מישהו העביר אובייקט מעגלי, תרצה שהחריגה (exception) הזו תעלה למעלה לפני שתגע בדיסק.

שנית, כתוב את הנתונים המסריאליזציה לקובץ זמני שנמצא באותה תיקייה של היעד. השתמש בשם אקראי כדי ששתי הרצות בו-זמניות לא יתנגשו. שמירת הקובץ הזמני באותה תיקייה חשובה מכיוון ש-rename הוא אטומי רק בתוך מערכת קבצים אחת. אם הקובץ הזמני שלך נמצא במחיצה (partition) אחרת, מערכת ההפעלה תחזור לרצף של העתקה ומחיקה, מה שמכניס מצבי כשל משלו ואינו אטומי עוד.

שלישית, בקש מהליבה (kernel) לבצע flush לקובץ הזמני הזה לאחסון הפיזי. הפקודה fsync של Node, החשופה כאן כמתודת sync על filehandle, חוסמת את הביצועים עד שהבאפרים נכתבים לחומרה. זה איטי, אך כתיבות הגדרות מתרחשות לעיתים רחוקות מספיק כך שהעמידות (durability) שווה את המילישניות הללו.

רביעית, שנה את שם הקובץ הזמני לנתיב המקורי. הן במערכות POSIX והן ב-Windows, זוהי נקודת ה-commit. קוראים הפותחים את הנתיב המקורי יראו או את הקובץ הישן המלא או את הקובץ החדש המלא. אין רגע שבו קורא יכול לפתוח את הנתיב ולראות באפר שנכתב למחצה.

חמישית, בצע sync לתיקיית האב. זה תופס מקרה קצה עדין. ה-rename מעדכן את רשומת התיקייה, אך המטא-דאטה של התיקייה עצמה עשוי להימצא ב-page cache של הליבה. הפסקת חשמל פתאומית לאחר rename מוצלח עלולה לעיתים להשאיר את מערכת הקבצים במצב שבו הפניה ל-inode החדש מעולם לא נרש

הניקוי בבלוק ה-catch הוא הגנתי בכוונה. אם משהו נזרק לאחר פתיחת ה-filehandle, הקוד מנסה לסגור את ה-handle ולהסיר את הקובץ הזמני, תוך בליעת שגיאות משניות כדי שהחריגה (exception) המקורית תועבר בצורה נקייה. אינך רוצה ששגיאת הרשאות במהלך הניקוי תסתיר את הבאג האמיתי שגרם לכשל.

מתי התבנית הזו מפסיקה לעזור

החלפת קבצים אטומית מונעת כתיבות קרועות (torn writes). היא אינה מונעת עדכונים אבודים (lost updates). אם שני מופעים של ה-CLI שלך קוראים את אותה קונפיגורציה בו-זמנית, שניהם מבצעים את העריכות שלהם בזיכרון, שניהם כותבים קבצי זמניים חדשים, ושניהם מבצעים את שינויי השמות (renames) שלהם, שינוי השם השני מנצח. התהליך הראשון לא הבחין בשינויים של התהליך השני. בהתאם לאפליקציה שלך, זה יכול להוות מצב שבו משתמש אחד מוסיף הגדרה בטרמינל אחד ומשתמש אחר מסיר אותה בטרמינל אחר, כאשר הקובץ הסופי משקף רק את הכותב האחרון.

אם הכלי שלך צריך לתמוך בשינויים במקביל (concurrent mutators), אתה זקוק למנגנון תיאום מעל הכתיבה האטומית. קובץ נעילה מסוג advisory lock עובד במקרים פשוטים. וקטורי גרסאות (version vectors) או מספר גרסה מונוטוני בתוך הקונפיגורציה עצמה יכולים לעזור לזהות התנגשויות, כך שהכותב השני יוכל לנסות שוב. אלו מוסיפים מורכבות, ובהמורכבות מסתתרים הבאגים.

זו הסיבה שהגבול חשוב. בלוק JSON בודד שתהליך אחד מעדכן מדי פעם הוא מועמד מצוין לכתיבה אטומית של קובץ. ברגע שאתה מוצא את עצמך מנהל רשומות מרובות, אוכף סכמות (schemas), או דואג לשינויים במקביל, עברת את גבול היכולת של מערכת הקבצים. SQLite קיים בדיוק מהסיבה הזו. הוא מספק לך טרנזקציות אטומיות, יומני rollback וטיפול נאות בקוראים וכותבים במקביל, והכל בתוך קובץ מקומי בודד. פרוטוקול קבצים חכם אינו מסד נתונים, ואין עליך לבזבז תקציב תחזוקה על כך שתעמיד פנים אחרת.

השורה התחתונה

בפעם הבאה שתשתמש ב-writeFile בתוך כלי CLI, עצור לרגע. סריאליזציה היא לא החלק הקשה. עמידות (durability) היא החלק הקשה. קבצי קונפיגורציה קטנים מדי מכדי להזרים (stream) וחשובים מדי מכדי לקצץ (truncate). כתוב את כל המטען (payload) לקובץ אחי (sibling) נסתר, בצע לו flush, בצע לו commit באמצעות שינוי שם, ועדכן את הספרייה על כך. המשתמשים שלך יכולים ללחוץ על Ctrl-C, לנתק את כבל החשמל או לסגור את מכסה הלפטופ. כשהמכונה תחזור לפעולה, הקובץ יכיל או את העולם הישן או את החדש. שום דבר באמצע.