ידע בסיסי על בקרת הרשאות בלעדיות בהעברת קבצים — נעילת קבצים ושיטות עבודה מומלצות ל-claim אטומי

· עודכן בתאריך: · · העברת קבצים, בקרת הרשאות בלעדיות, תכנון, פיתוח Windows

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

במאמר הזה נבחן את בקרת ההרשאות הבלעדיות בהעברת קבצים סביב נעילת קבצים, claim אטומי,‏ temp -> rename ואידמפוטנטיות.

נסדר מונחים קודם

בתחום הזה יש הרבה מילים שמושרשות באנגלית, וקשה לקרוא כשהמשמעות נשארת מעורפלת. נקבע מראש את המשמעות שלהן במאמר הזה.

מונח המשמעות במאמר הזה
אטומי (atomic) פעולה שהמצב שבאמצע ביצועה לא נראה מבחוץ. יש רק שתי אפשרויות: הצלחה, או שלא קרה כלום
claim תפיסת זכות עיבוד - “אני זה שיעבד את הקובץ הזה”. במאמר הזה הכוונה בעיקר לצורה שבה רק הצד שהצליח לבצע rename מ-incoming ל-processing/<worker>/ הופך לבעלים
claim אטומי ביצוע ה-claim בפעולה אחת. אם ה”בדיקה” וה”תפיסה” נפרדות, תהליך אחר יכול להתפרץ ביניהן (3.1)
lease בעלות עם תוקף. כותבים ב-lock file “מי” ו”עד מתי”, כדי שכשהתוקף פג, עובד אחר יוכל לקחת את הבעלות (4.4)
stale מצב של lock או claim שנשאר אחרי שהבעלים הסתיים באופן חריג. אם אי אפשר לקבוע אם הוא חי או מת, כולם נעצרים (2.3)
manifest קובץ נפרד מהקובץ עצמו שמתאר את התוכן. כותבים בו שם קובץ, גודל, hash, מספר רשומות וכדומה, ומשתמשים בו לאימות בצד המקבל. קובץ done הוא הגרסה המינימלית שלו (4.2)
אידמפוטנטיות (idempotency) תכונה שבה עיבוד חוזר של אותו קלט לא משנה את התוצאה (4.5)
advisory lock נעילה שפועלת רק אם כל המשתתפים שומרים על ההסכם. מערכת ההפעלה לא אוכפת אותה, ולכן אפשר גם לכתוב תוכנית שמתעלמת ממנה וקוראת/כותבת בכל זאת. flock ב-Linux הוא מהסוג הזה
byte-range lock נעילה שמתייחסת לא לכל הקובץ אלא רק לטווח מסוים. LockFileEx ב-Windows הוא הדוגמה המובהקת, ומערכת ההפעלה אוכפת אותה. עם זאת, יש חריג (3.5)

מפת הידע של המאמר

המאמר הזה מסדר גישה שבה מתכננים העברת קבצים דרך תיקיות משותפות או תהליכי אצווה לילי לא בהסתמכות על נעילת מערכת ההפעלה, אלא כפרוטוקול מסירה. תופסים את זכות העיבוד ב-claim אטומי לפני הקריאה, כולאים את הקובץ שנוצר תחת שם temp, ואחרי close מפרסמים אותו ב-rename לשם final, ומציינים סיום לא בניחוש לפי גודל או חותמת זמן אלא במפורש עם done/manifest. אם משתמשים ב-lock file, הופכים אותו לצורת lease עם ownerId ו-expiresAt כדי להיערך ל-stale lock, ותוך התחשבות בכך ש-byte-range lock של Windows מתעלם ממנו כשמדובר בקובץ ממופה לזיכרון, ושadvisory lock לא יעיל מול צד שלא שומר על ההסכם, המסקנה היא שבפועל בשטח חזק יותר תכנון שבו בסוף אידמפוטנטיות קולטת גם עיבוד חוזר של אותו קלט.

מפת הידע של בקרת ההרשאות הבלעדיות בהעברת קבציםתרשים המראה איך פרוטוקול המסירה משלב claim אטומי, פרסום בסדר temp->rename, done/manifest, קובץ lock מסוג lease, ואידמפוטנטיות, ולאיזה אנטי-דפוס כל אחד מהם מתאים כמענה למניעת תקריות כמו עיבוד כפול או קריאת קובץ שכתיבתו לא הושלמה.משתמש במשתמש במשתמש במשתמש במשתמש במשתמש במשתמש במונעעלול לגרום למענה מומלץ למענה מומלץ לעלול לגרום למונעעלול לגרום למענה מומלץ לעלול לגרום למענה מומלץ למענה מומלץ למחייבמשתמש במצמצםשימוש לא מומלץ לשימוש לא מומלץ לאינו מתיישב עםעלול לגרום לעלול לגרום למענה מומלץ למענה מומלץ לעלול לגרום לפרוטוקול מסירת הקבציםתפיסה אטומית (claim)פרסום בסדר temp ‏-> close ‏-> rename/replaceקובץ done/manifestקובץ נעילה מבוסס leaseעיבוד שמניח אידמפוטנטיותנעילת קובץ של מערכת ההפעלהיצירה אטומית (CreateNew / O_CREAT|O_EXCL)עיבוד כפול (חיוב כפול, שליחה כפולה, אובדן עדכון)בדיקה דו-שלבית של Exists ואחריה Createכתיבה ישירה אל שם הקובץ הסופיתקלה של קריאת קובץ שכתיבתו טרם הושלמהקביעת סיום לפי התייצבות גודל הקובץאנטי-דפוס של עדכון הדדי של קובץ משותףstale lockbyte-range lock (נעילת טווח)שילוב בין מערכות שונותadvisory lockנפילה חזרה ל-rename חוצה כוננים (העתקה + מחיקה)העברת קבצים דרך תיקייה משותפת (SMB)‏rename שנכשל רק משום שהקובץ פתוחחוסר האמינות של חותמות הזמן של קבציםסריקת ספרייה מחזוריתאובדן התראות שינוי

בתרשים, קו מלא מציין קשר שמתקיים תמיד וקו מקווקו מציין קשר מותנה (תנאי ההתקיימות מפורטים בהסבר של כל קשר בעמוד המפורט). רשימת כל הקשרים (סך הכול 29, עם אסמכתה ורמת ודאות) והגדרות המושגים המרכזיים מרוכזות בעמוד המפורט של מפת הידע (ביפנית). נתונים: JSON-LD / Turtle

תוכן עניינים

  1. קודם המסקנה (במשפט אחד)
  2. תבניות התנגשות שקורות בהעברת קבצים (תרשים)
    • 2.1. קוראים קובץ שכתיבתו טרם הושלמה
    • 2.2. כמה עובדים תופסים את אותו קובץ בו-זמנית
    • 2.3. כולם נעצרים בגלל stale lock
  3. אנטי-דפוסים
    • 3.1. בדיקה דו-שלבית של Exists -> Create
    • 3.2. כותבים ישירות לשם הקובץ הסופי
    • 3.3. מתייחסים לעצירת גודל הקובץ כסיום
    • 3.4. כולם מעדכנים קובץ משותף
    • 3.5. חושבים ש-API של נעילה הוא פתרון-על
  4. שיטות עבודה מומלצות
    • 4.1. מפרסמים בסדר temp -> close -> rename / replace
    • 4.2. מציינים שלמות במפורש עם done / manifest
    • 4.3. צד הקבלה תופס claim באופן אטומי
    • 4.4. אם מסתמכים על lock file, הופכים אותו ל-lease
    • 4.5. מניחים אידמפוטנטיות מראש
  5. פסאודו-קוד (קטעים)
  6. חלוקה גסה בין השימושים
  7. סיכום
  8. מקורות

העברת קבצים היא תחום שבו “הסכם המסירה” נשבר בקלות רבה יותר מהקוד עצמו. זה עובר בבדיקות יחידה, אבל נשבר מדי פעם דווקא בתיקייה המשותפת של הסביבה החיה או בתהליך האצווה הלילי. וקשה לשחזר. זה די שכיח.

רוב הגורמים אינם ב-API של קלט/פלט הקבצים עצמו, אלא בכך ששלושת הדברים הבאים מעורפלים:

  • מתי מותר לקרוא
  • מי מחזיק בזכות העיבוד
  • איך מתאוששים כשיש כישלון

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

נציין שהקוד שמופיע במאמר הזה מפורסם ב-GitHub כערכת דוגמה מלאה שאפשר לבנות ולהריץ (ספרייה, הדגמה של תחרות claim בין שני עובדים ותפיסת lease, ובדיקות יחידה ששוחזרות בהן תחרות, שחיתות נתונים ו-stale lock).

file-integration-locking-best-practices-komurasoft-style - komurasoft-blog-samples (GitHub)

1. קודם המסקנה (במשפט אחד)

  • הדבר הכי חשוב בהעברת קבצים הוא ליצור מצב שבו ברגע ששם הקובץ הסופי נראה, “כבר מותר לקרוא”
  • לייצג בהפרדה, באמצעות שם קובץ או תיקייה, את המצבים בתהליך יצירה / פורסם / בעיבוד / עובד
  • אם יש כמה עובדים, לתפוס claim באופן אטומי לפני הקריאה
  • להשתמש ב-lock file ובנעילת מערכת ההפעלה כאמצעי עזר, ובסוף לקלוט הכול באמצעות אידמפוטנטיות

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

2. תבניות התנגשות שקורות בהעברת קבצים (תרשים)

2.1. קוראים קובץ שכתיבתו טרם הושלמה

אם מתחילים לכתוב ישירות לשם הקובץ הסופי, קורית התקרית הזו. ב-JSON יחסר הסוגר הסוגר, ב-CSV יחסרו שורות, וב-ZIP הקובץ פשוט יהיה פגום.

תקלת קריאת קובץ שכתיבתו לא הושלמהתרשים רצף המראה שהצד השולח יוצר את הקובץ ישירות בשם הסופי וכותב אליו בהדרגה, בעוד הצד המקבל מזהה את הקובץ ומתחיל לקרוא אותו כשהוא עדיין באמצע, וכתוצאה מכך חלק מהשורות חסרות או שהניתוח נכשל.הצד המקבלתיקייה משותפתהצד השולחהצד המקבלתיקייה משותפתהצד השולחעדיין באמצעחוסר שורות / כישלון ניתוח / עיבוד חלקי בלבדיוצר את orders.csv בשם הסופיכותב שורות 1 עד 5000מזהה את orders.csvמתחיל לקרוא כפי שהואכותב את שאר השורות

2.2. כמה עובדים תופסים את אותו קובץ בו-זמנית

בזרימה שבה “מסתכלים ברשימה, ואם לא עובד פותחים”, שני עובדים יכולים לתפוס את אותו קובץ. זו ההתחלה של חיוב כפול או שליחה כפולה.

שני עובדים תופסים את אותו קובץתרשים רצף המראה ששני עובדים מוצאים את אותו קובץ בתיקיית incoming ומתחילים לקרוא אותו במקביל, מה שגורם לעיבוד כפול של אותו קלט.incomingעובד 2עובד 1incomingעובד 2עובד 1אותו קלט מעובד פעמייםמוצא את a.csvמוצא את a.csvמתחיל לקרואמתחיל לקרוא

2.3. כולם נעצרים בגלל stale lock

תכנון שרק מניח lock file נוטה להיתקע בסיום חריג. אם לא ברור של מי ה-lock, האם הוא עדיין חי, ועד מתי הוא תקף - מי שממתין אחריו ימתין לנצח.

כולם נעצרים כי אי אפשר לקבוע אם ה-lock עדיין תקףתרשים רצף המראה שעובד A יוצר lock ומסתיים באופן חריג, ועובד B מוצא את הקובץ, נמנע מלהתחיל לעבד וממשיך להמתין, מכיוון שאי אפשר לקבוע אם ה-lock הוא stale.עובד Bקובץ lockעובד Aעובד Bקובץ lockעובד Aמסתיים כאן באופן חריגאי אפשר לקבוע אם זה stale, וכולם נעצריםיוצר lockבודק שה-lock קייםנמנע מלהתחיל לעבדממשיך להמתין

3. אנטי-דפוסים

3.1. בדיקה דו-שלבית של Exists -> Create

הבעיה כאן היא שה”בדיקה” וה”תפיסה” הן שתי פעולות נפרדות. תהליך אחר יכול להתפרץ ביניהן, ולכן זה לא יוצר בלעדיות.

הבדיקה הדו-שלבית מאפשרת לשני תהליכים להתקדםתרשים רצף המראה שתהליך A ותהליך B בודקים שניהם שאין lock, שניהם מקבלים תשובה שאין, ולכן שניהם יוצרים lock ומתקדמים בו-זמנית.מערכת הקבציםתהליך Bתהליך Aמערכת הקבציםתהליך Bתהליך Aשניהם מצליחים להתקדםבודק שאין lockבודק שאין lockאיןאיןיוצר lockיוצר lock

דוגמה טיפוסית לא טובה נראית כך.

if (!File.Exists(lockPath))
{
    File.WriteAllText(lockPath, Environment.ProcessId.ToString());
    ProcessFile();
}

מה שנדרש הוא להפוך את “אם אין - ליצור” לפעולה אחת. ב-‎.NET משתמשים במשפחת FileMode.CreateNew, ובמערכות POSIX ביצירה אטומית כמו O_CREAT | O_EXCL.

3.2. כותבים ישירות לשם הקובץ הסופי

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

הכשל שנגרם מכתיבה ישירה לשם הסופיתרשים המראה שברגע ששם הקובץ הסופי נראה, צד הקבלה מזהה אותו בעוד צד השליחה עדיין באמצע כתיבה, וכתוצאה מכך נקראים נתונים לא שלמים.שם final נראהצד הקבלה מזההצד השליחה עדיין כותבנקראים נתונים לא שלמים
using var writer = OpenForWrite(finalPath); // כאן finalPath כבר נראה
foreach (var row in rows)
{
    writer.WriteLine(row);
}

השיטה הזו מזמינה בעצמה את התקרית מסעיף 2.1.

3.3. מתייחסים לעצירת גודל הקובץ כסיום

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

זיהוי שגוי של סיום לפי התייצבות גודל הקובץתרשים רצף המראה שהצד השולח מתחיל להעתיק קובץ ומשתהה לרגע באמצע, בעוד צד הקבלה רואה שהגודל לא השתנה 10 שניות ומזהה זאת בטעות כסיום ומתחיל לקרוא, בעוד ההעתקה עדיין לא הושלמה.הצד המקבלתיקייה משותפתהצד השולחהצד המקבלתיקייה משותפתהצד השולחמזהה בטעות כסיוםמתחיל להעתיק את data.zipמשתהה זמנית באמצעהגודל לא משתנה 10 שניותמתחיל לקרואממשיך בהעתקה
if (currentLength == lastLength && stableSeconds >= 10)
{
    return Ready;
}

אם קובעים סיום על סמך ניחוש, זה יכול להכשיל בתיקיות משותפות או בקבצים גדולים. עדיף לייצב את זה על ידי ציון סיום במפורש עם manifest או קובץ done.

3.4. כולם מעדכנים קובץ משותף

תכנון שבו כולם קוראים ומעדכנים status.csv או counter.json יחיד, בדרך כלל מסתיים בכך שמי שכתב אחרון מנצח. כשמתחילים להשתמש בהעברת קבצים כמסד נתונים פשוט, כאן זה נהיה קשה.

עדכון הדדי בקובץ משותף מאבד עדכוןתרשים רצף המראה שאצווה A ואצווה B שתיהן קוראות את אותה גרסה v1 של status.csv וכל אחת כותבת גרסה משלה, כשגרסה B נכתבת אחרונה ומוחקת את העדכון של A.status.csvאצווה Bאצווה Astatus.csvאצווה Bאצווה Aהעדכון של A נעלםקורא v1קורא v1כותב v2-Aכותב v2-B

יש גם רעיון לברוח ל-append-only, אבל המשמעות שלו משתנה לפי מערכת הקבצים ואופן הפריסה. אם נדרש עדכון משותף, עדיף לא להתאמץ יתר על המידה עם העברת קבצים כאן.

3.5. חושבים ש-API של נעילה הוא פתרון-על

‏API של נעילה חשוב, אבל הוא יעיל רק כשכל המשתתפים פועלים לפי אותו הסכם. בשילוב בין מערכות שונות, בטוח יותר לא לסמוך עליו יתר על המידה.

הערה:

  • flock ב-Linux הוא advisory lock, ולכן אפשר בקלות לכתוב צד שמתעלם מההסכם
  • ‏byte-range lock ב-Windows מתעלם ממנו כשמדובר בקובץ ממופה לזיכרון
  • כלומר, עדיף לא להטיל על נעילת מערכת ההפעלה לבדה גם את התכנון של הודעת סיום ובעלות

הנקודה השנייה מפורשת במפרט של Windows. ב-Locking and Unlocking Byte Ranges in Files של Microsoft Learn, מיד אחרי המשפט שגישה של תהליך אחר לטווח נעול תמיד תיכשל (כלומר נעילת הטווח ב-Windows אינה advisory אלא נאכפת), מופיעה הערה שכאשר משתמשים בקובץ ממופה לזיכרון, ה-byte-range lock מתעלם ממנה. אם הצד השני נוגע באותו קובץ דרך CreateFileMapping, הנעילה שלכם פשוט לא רלוונטית עבורו.

אם רוצים לתפוס נעילת טווח ב-‎.NET, זה FileStream.Lock / Unlock (במקרה של Windows).

using var stream = new FileStream(
    path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite);

// נועלים באופן בלעדי רק את הבייט הראשון, כ"תג" של בעיבוד
stream.Lock(0, 1);
try
{
    // כאן מתבצעת הקריאה/כתיבה של הגוף עצמו
}
finally
{
    // תמיד משחררים לפני הסגירה
    stream.Unlock(0, 1);
}

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

4. שיטות עבודה מומלצות

נציג תחילה את ההתאמה לאנטי-דפוסים בפרק 3. אם יש לכם תחושה שאתם עושים אחד מהם, אפשר גם לקרוא ישר את הסעיף המתאים.

אנטי-דפוס מה קורה הפתרון המתאים
3.1. בדיקה דו-שלבית של Exists -> Create תהליך אחר מתפרץ בין הבדיקה לתפיסה, ושני תהליכים מתקדמים בו-זמנית 4.3 תופסים claim באופן אטומי (rename או FileMode.CreateNew)
3.2. כותבים ישירות לשם הקובץ הסופי צד הקבלה קורא קובץ שכתיבתו טרם הושלמה 4.1 מפרסמים בסדר temp -> close -> rename / replace
3.3. מתייחסים לעצירת גודל הקובץ כסיום השהיה זמנית בהעתקה מזוהה בטעות כסיום 4.2 מציינים סיום במפורש עם done / manifest
3.4. כולם מעדכנים קובץ משותף הצד שכותב מאוחר יותר דורס, והעדכון נעלם 4.3 מצמצמים לכותב יחיד, ו-4.5 קולטים עיבוד כפול. אם זה עדיין לא מספיק - החלטת נסיגה בפרק 6
3.5. חושבים ש-API של נעילה הוא פתרון-על נשבר מול צד שלא שומר על ההסכם או ניגש דרך מיפוי לזיכרון 4.4 lock file בתור lease, ו-4.5 אידמפוטנטיות שקולטת את זה

4.1. מפרסמים בסדר temp -> close -> rename / replace

זו הדרך הסטנדרטית. כולאים את הקובץ שנוצר תחת שם temp, ואחרי ה-close מחליפים אותו לשם final. צד הקבלה מסתכל רק על שם final.

חמשת השלבים של פרסום בטוחתרשים המראה את חמשת השלבים של פרסום בטוח - יצירת שם temp ייחודי, כתיבת כל התוכן ל-temp, ביצוע flush/close, rename/replace לשם final באותה תיקייה, וצד הקבלה עוקב רק אחרי שם final.יוצרים שם temp ייחודיכותבים את כל התוכן ל-tempמבצעים flush / closeמבצעים rename / replace לשם final באותה תיקייהצד הקבלה עוקב רק אחרי שם final

נקודות מרכזיות:

  • ‏temp ו-final נמצאים באותה תיקייה, לפחות באותו כרך / מערכת קבצים
  • ב-Windows / ‎.NET אפשר לשקול את משפחת File.Replace
  • ההסכם הוא שברגע ששם final נראה, התוכן כבר הושלם

אם temp נמצא בכונן אחר, ה-rename עלול להיות שקול להעתקה בלבד, או ש-Replace עלול להיכשל. ההנחה הזו נראית לא בולטת, אבל היא חשובה מאוד.

דרך תיקייה משותפת (SMB), עוד ארבע נקודות מתנודדות. זה בדיוק שדה הקרב המרכזי של המאמר הזה, ולכן נכתוב אותן בנפרד.

  • ‏rename בתוך אותה תיקייה של אותו שיתוף מתבצע בצד השרת. ולכן התכונה של “לא נראה שם ביניים” נשמרת כפי שהיא. לעומת זאת, כשחוצים שיתופים, כמו מ-\\server\shareA ל-\\server\shareB, זה נחשב לכרך נפרד, וכאשר MoveFileEx ב-Windows מקבל MOVEFILE_COPY_ALLOWED, הוא מחליף את המעבר בהעתקה ומחיקה. כלומר זה מפסיק להיות אטומי, ונראה מצב ביניים. ההנחה שממקמים את temp ו-final, ואת incoming ו-processing, בתוך אותו שיתוף, חשובה עוד יותר מאשר במצב מקומי
  • ‏rename נכשל רק בגלל ש”מישהו פותח את הקובץ”. לתיקייה משותפת ניגשים גורמים שלא הכרתם, כמו אנטי-וירוס, מנוע אינדוקס לחיפוש, או לקוחות מסניפים אחרים. מעשי להתייחס לכישלון rename של publish ו-claim לא כחריגה אלא כענף רגיל, עם המתנה קצרה וניסיון חוזר
  • חותמת הזמן לא יכולה לשמש כלי החלטה. ב-File Times של Microsoft Learn כתוב שמה שמובטח לגבי זמני קובץ הוא רק ש”הוא ישקף נכון את השינוי בזמן שסוגרים את ה-handle שביצע אותו”. זמן העדכון האחרון בזמן כתיבה לא מתעדכן במלואו עד שכל ה-handle-ים לכתיבה נסגרים. גם הגרנולריות תלויה במערכת הקבצים - זמן העדכון האחרון ב-FAT מתעדכן ביחידות של 2 שניות, וזמן הגישה האחרון ב-NTFS מתעדכן באיחור של עד שעה. בנוסף, דרך SMB הזמן מסומן לפי שעון השרת, ולכן אם יש הפרש בין שעון הלקוח לשרת, גם קביעה כמו “לעבד N דקות אחרי העדכון” תתעוות בהתאם. זו הסיבה שקביעת סיום לא נעשית לפי זמן או גודל, אלא עם done / manifest מסעיף 4.2
  • גם התראות שינוי מפספסות. לא כדאי להסתמך רק על התראות אירועים לניטור תיקייה משותפת - שילוב עם סריקת ספרייה מחזורית מייצב את זה. הנושא הזה מסוכם ב-מדריך מעשי ל-FileSystemWatcher — התמודדות עם פספוסים וכפילויות

4.2. מציינים שלמות במפורש עם done / manifest

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

זרימת פרסום עם done ומעקב manifestתרשים המראה שיוצרים data.tmp, מפרסמים אותו כ-data.csv, יוצרים את data.done או manifest.json, צד הקבלה מזהה את קובץ ה-done או ה-manifest, ומאמת שם קובץ, גודל ו-hash.יוצרים את data.tmpמפרסמים כ-data.csvיוצרים data.done / manifest.jsonצד הקבלה מזהה done / manifestמאמתים שם קובץ, גודל ו-hash

הפריטים שכדאי לכלול ב-manifest הם בערך אלה.

  • שם הקובץ הרלוונטי
  • גודל
  • hash
  • מספר רשומות
  • מזהה העברה / idempotency key
  • זמן יצירה

גם הסדר חשוב. אם done מונח לפני פרסום גוף הנתונים, זו לא הודעת סיום אלא הודעה מוקדמת על תקרית.

4.3. צד הקבלה תופס claim באופן אטומי

אם כמה עובדים מסתכלים על אותו incoming, קל להבין את הגישה של “להעביר לעצמך לפני שקוראים”. רק העובד שהצליח לבצע rename מ-incoming ל-processing/<worker>/ מעבד.

רק העובד שמצליח ב-rename תופס בעלותתרשים רצף המראה ששני עובדים מוצאים את אותו קובץ ומנסים לבצע rename ל-processing, כשרק מי שמצליח ראשון תופס בעלות על העיבוד.processingincomingעובד 2עובד 1processingincomingעובד 2עובד 1רק מי שהצליח ראשון תופס בעלותמוצא את a.csvמוצא את a.csvמבצע rename ל-a.csvמבצע rename ל-a.csv

מבחינה תפעולית, קל יותר לעקוב אם גם מפרידים תיקיות.

מסלול התיקיות מ-temp עד archive או errorתרשים המראה שקובץ עובר מ-temp דרך פרסום ל-incoming, משם דרך claim ל-processing, ולבסוף בהצלחה ל-archive או בכישלון ל-error.פרסוםclaimהצלחהכישלוןtempincomingprocessingarchiveerror

גם ה-rename עבור ה-claim, ההנחה היא שהוא מתבצע על אותה מערכת קבצים.

4.4. אם מסתמכים על lock file, הופכים אותו ל-lease

אם משתמשים ב-lock file, כדאי שהוא יהיה מידע בעלות עם תוקף, ולא רק קובץ ריק. ‏lock שלא ברור מי תפס אותו, בהכרח יגרום לבעיות בהמשך.

השדות של קובץ ה-lease ב-lock.jsonתרשים המראה שקובץ lock.json מכיל שישה שדות - ownerId, host, pid, acquiredAt, expiresAt ו-heartbeatAt.lock.jsonownerIdhostpidacquiredAtexpiresAtheartbeatAt

נקודות מרכזיות:

  • היצירה מתבצעת באופן אטומי
  • הפסקת עדכונים משמשת כלי לקביעת stale
  • המחיקה מתבצעת, בעיקרון, רק על ידי היוצר
  • קובעים מראש נוהל התאוששות, בהנחה שיש דליפות של אי-שחרור

‏lock file הוא בסך הכול תג לצורך תיאום. אם מנסים להבטיח באמצעותו לבדו שלמות מלאה, זה בדרך כלל נהיה קשה.

4.5. מניחים אידמפוטנטיות מראש

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

תהליך שמניח אידמפוטנטיותתרשים המראה שקלט עם idempotency key נבדק אם כבר עובד, ואם כן מטופל כהצלחה בלי ריצה כפולה, ואם לא - מתבצע העיבוד ונרשם ביומן העיבודים.כןלאקלט + idempotency keyכבר עובד?מתייחסים כהצלחה בלי ריצה כפולהמבצעים את העיבודרושמים ביומן העיבודים

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

5. פסאודו-קוד (קטעים)

MakeTempPathSameDirectory ו-TryClaimBundleByRename שמופיעים כאן הם שמות פונקציה בדיוניים שמוצגים כדי להראות את הסדר. המימוש שבאמת פועל נמצא בערכת הדוגמה שהוצגה בפתיחה.

המימוש של הפסאודו-קוד הזה (ספרייה, הדגמת תחרות claim בין שני עובדים, בדיקות יחידה) - komurasoft-blog-samples (GitHub)

5.1. תבנית כשל טיפוסית

var lockPath = finalPath + ".lock";

if (!File.Exists(lockPath))
{
    File.WriteAllText(lockPath, "");
    using var writer = OpenForWrite(finalPath); // כותב ישירות לשם הסופי
    WritePayload(writer);

    File.Delete(lockPath);
}

יש שלוש בעיות.

  • Exists ו-WriteAllText הן שתי פעולות נפרדות
  • finalPath נראה כבר באמצע הכתיבה
  • lock נשאר בסיום חריג

5.2. דוגמה לכיוון נכון (בגדול, ככה)

var tempPath = MakeTempPathSameDirectory(finalPath);
WritePayload(tempPath);
FlushAndClose(tempPath);

PublishByRenameOrReplace(tempPath, finalPath); // בהנחת אותה מערכת קבצים / אותו volume
PublishDoneFile(finalPath + ".done", new
{
    FileName = Path.GetFileName(finalPath),
    Size = GetFileSize(finalPath),
    Hash = ComputeHash(finalPath),
    IdempotencyKey = integrationId
});
if (!TryClaimBundleByRename(baseName, incomingDir, processingDir))
{
    return; // עובד אחר תפס קודם
}

var manifest = ReadDoneFile(Path.Combine(processingDir, baseName + ".done"));
VerifyPayload(Path.Combine(processingDir, baseName), manifest);

if (AlreadyProcessed(manifest.IdempotencyKey))
{
    MoveBundle(processingDir, archiveDir, baseName);
    return;
}

Process(Path.Combine(processingDir, baseName));
RecordProcessed(manifest.IdempotencyKey);
MoveBundle(processingDir, archiveDir, baseName);

כאן הסדר חשוב יותר מפרטי המימוש. כשלא מערבבים “לכתוב”, “לפרסם”, “לתפוס בעלות” ו”לתעד עיבוד” - זה נשבר פחות.

6. חלוקה גסה בין השימושים

  • עם writer יחיד, reader יחיד ואותה מכונה, גם temp -> rename בלבד כבר יציב למדי
  • אם יש כמה consumer, מוסיפים claim rename מ-incoming ל-processing
  • בשילוב בין מערכות שונות, NAS או תיקייה משותפת, בטוח יותר להוסיף גם manifest / done וגם אידמפוטנטיות
  • אם כמה writer רוצים לעדכן את אותו מצב לוגי, כדאי לשקול מסד נתונים או תור, ולא להתאמץ יתר על המידה עם העברת קבצים
  • נעילת מערכת ההפעלה יעילה בתוך אותה קבוצת אפליקציות ואותן הנחות, אבל היא לא תחליף לפרוטוקול מסירה

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

7. סיכום

בקרת הרשאות בלעדיות בהעברת קבצים היא לא קריאה לפונקציית נעילה, אלא קביעת מעברי מצב. זה עצם הרעיון של המאמר הזה. מייצגים בשם או בתיקייה את המצבים בתהליך יצירה / פורסם / בעיבוד / עובד, ונמנעים מבדיקה דו-שלבית של Exists -> Create, מכתיבה ישירה לשם הקובץ הסופי, מהמתנה להתייצבות גודל, מעדכון הדדי של קובץ משותף, ומהסתמכות יתר על API של נעילה. מעבר לכך, שילוב של temp -> close -> rename / replace,‏ done / manifest,‏ claim rename,‏ lease ואידמפוטנטיות מונע תקריות רבות בהעברת קבצים דרך תיקיות משותפות.

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

8. מקורות

מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.

העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.

המאמר קשור ישירות לשירותים הבאים.

שאלות נפוצות

שאלות נפוצות בפניות בנושא המאמר.

האם בקרת ההרשאות הבלעדיות בהעברת קבצים מספיקה עם API של נעילה בלבד?
לעיתים קרובות זה לא מספיק. flock ב-Linux הוא advisory lock, ולכן אפשר בקלות לכתוב תוכנית שמתעלמת מההסכם, ו-byte-range lock ב-Windows מתעלם ממנו כאשר מדובר בקובץ ממופה לזיכרון. נעילת מערכת ההפעלה יעילה בתוך אותה קבוצת אפליקציות ואותן הנחות, אבל כדאי להשתמש בה כאמצעי עזר, ולהפוך את תכנון פרוטוקול המסירה - temp -> rename,‏ done/manifest,‏ claim אטומי ואידמפוטנטיות - לגוף העיקרי.
איך נמנעים מכך שיקראו קובץ שכתיבתו טרם הושלמה?
הדרך הסטנדרטית היא לפרסם בסדר temp -> close -> rename/replace. סוגרים את הקובץ בשלב היצירה תחת שם temp, ואחרי ה-close מחליפים לשם הסופי (final) באותה תיקייה, כשצד הקבלה מסתכל רק על השם הסופי. ה-temp וה-final חייבים להיות באותה תיקייה, לפחות באותו כרך/מערכת קבצים, וההסכם הוא שברגע שהשם הסופי נראה - התוכן כבר הושלם.
איך מונעים מכמה עובדים לעבד את אותו קובץ בו-זמנית?
תופסים את ה-claim באופן אטומי לפני הקריאה. באופן קונקרטי, רק העובד (worker) שהצליח לבצע rename מ-incoming ל-processing/<worker>/ הוא זה שמעבד. בדיקה דו-שלבית של Exists ואחריה Create אינה יוצרת בלעדיות, כי ה"בדיקה" וה"תפיסה" הן שתי פעולות נפרדות שתהליך אחר יכול להתפרץ ביניהן. אם נדרשת יצירה אטומית, משתמשים ב-FileMode.CreateNew של ‎.NET או ב-O_CREAT | O_EXCL של POSIX.
האם יש נקודות לתשומת לב כשמשתמשים ב-lock file?
כדאי להפוך אותו לא לקובץ ריק פשוט, אלא ל-lease (מידע בעלות) עם תוקף, שמכיל ownerId,‏ host,‏ pid,‏ acquiredAt,‏ expiresAt ו-heartbeatAt. היצירה מתבצעת באופן אטומי, הפסקת עדכונים משמשת אינדיקציה ל-stale, המחיקה מתבצעת בעיקרון רק על ידי היוצר, ומכינים מראש נהלי התאוששות בהנחה שיש דליפות של אי-שחרור. בפועל בשטח, עדיף לא לנסות להבטיח שלמות מלאה עם קובץ נעילה אחד בלבד, אלא לתכנן כך שבסוף האידמפוטנטיות תקלוט את זה.

פרופיל הכותב

עמוד היכרות עם כותב המאמר.

Go Komura

מנהל KomuraSoft LLC

מתמחה בפיתוח תוכנה עבור Windows, ייעוץ טכני וחקירת תקלות, בעיקר בפרויקטים עם מערכות קיימות ובאגים שקשה לשחזר.

קישורים ציבוריים

חזרה לבלוג