מדריך מעשי ל-FileSystemWatcher — התמודדות עם פספוסים וכפילויות

· עודכן בתאריך: · · FileSystemWatcher, C#, .NET, פיתוח Windows, העברת קבצים, תכנון

FileSystemWatcher הוא ה-API שנחשב לראשון מועמד כשרוצים לנטר שינויי קבצים ב-‎.NET על Windows. אפשר לקבל כאירועים יצירה, שינוי, מחיקה ושינוי שם של קבצים ותיקיות, וזה נוח, אבל אם משתמשים ב-Created וב-Changed בהנחה שהם הודעת סיום, בהחלט קורות תקלות שגרתיות של פספוסים, התראות כפולות, וקריאה שגויה של קובץ באמצע.

במאמר הזה נסדר את אופן השימוש ואת נקודות התשומת לב של FileSystemWatcher, בהנחה בעיקר של העברת קבצים ב-‎.NET על Windows. יחד עם זה, גם רעיון בקרת ההרשאות הבלעדיות שמונח בבסיס אפשר לעיין בו ב-ידע בסיסי על בקרת הרשאות בלעדיות בהעברת קבצים — נעילת קבצים ושיטות עבודה מומלצות ל-claim אטומי.

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

לכן, עמוד השדרה של התכנון הוא זה.

  • ההתראה היא רק טריגר
  • האמת היא סריקה חוזרת של הספרייה
  • הבעלות נקבעת על ידי claim אטומי
  • בסוף קולטים עם אידמפוטנטיות

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

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

filesystemwatcher-safe-basics - komurasoft-blog-samples (GitHub)

קהל היעד וההנחות

המאמר נכתב עבור מפתחים שכותבים ב-‎.NET על Windows עיבוד שמנטר תיקיית קבלה וקולט קבצים. דוגמאות הקוד מניחות C# / ‎.NET 8 ואילך, אבל הרעיון עצמו לא תלוי שפה.

המאמר הזה משתמש באותו מינוח כמו המאמר הקודם שקושר למעלה (בקרת ההרשאות הבלעדיות בהעברת קבצים). ‏claim,‏ idempotency,‏ manifest,‏ bundle וכדומה יופיעו מפרק 4 ואילך בלי הסבר נוסף, ולכן נרכז אותם קודם שורה-שורה, כדי שאפשר יהיה לעקוב גם בלי לקרוא את המאמר הקודם.

מונחים לדעת מראש

מונח משמעות
claim תפיסת בעלות מסוג “אני זה שיעבד את הקובץ הזה”, בצורה שלא מאפשרת לעובד אחר להתפרץ. כמימוש, משתמשים ב-rename מ-incoming/ ל-processing/<worker>/, ורק תהליך אחד שהצליח ב-rename הופך לבעלים (4.3)
אידמפוטנטיות (idempotency) תכונה שבה עיבוד של אותו יעד פעמיים או יותר נותן תוצאה זהה לעיבוד פעם אחת. מכיוון שמניחים מראש התראות כפולות וסריקה חוזרת, בסוף זה מה שקולט את זה (4.5)
manifest קובץ קטן שמתאר את התוכן, שמונח יחד עם גוף הנתונים. אם שמים בו מספר רשומות, hash,‏ IdempotencyKey וכדומה, צד הקבלה יכול להחליט “האם זה כבר עובד”
bundle יחידה שמאגדת יחד העברה אחת. אם שמים גוף + manifest + קבצי עזר בתיקייה אחת, אפשר לתפוס claim לתיקייה כולה ב-rename אחד (4.3)
full rescan לא להסתמך על אירועים, אלא למנות מחדש מאפס את תיקיית המעקב, ולבדוק מחדש את מה שמותר לעבד (4.4)
overflow מצב שבו המאגר הפנימי של FileSystemWatcher גולש, ומאבדים התראות בודדות. מתקבלת התראה על כך באירוע Error (2.3)
ready מצב שבו נקבע ש”כבר מותר לקרוא”. לא מנחשים אלא קובעים לפי קיום שם final או done / manifest (4.2)

תוכן עניינים

  1. קודם המסקנה (במשפט אחד)
    • 1.1. קודם קוד מינימלי שעובד
  2. תבניות טעות שקורות עם FileSystemWatcher (תרשים)
    • 2.1. חושבים ש-Created הוא הודעת סיום
    • 2.2. סומכים על מספר הפעמים והסדר של Changed
    • 2.3. מאבדים שינויים בגלישת המאגר הפנימי
  3. אנטי-דפוסים
    • 3.1. מעבדים ישירות בתוך מטפל האירוע
    • 3.2. מנסים לשחזר את המצב האמיתי מרצף האירועים
    • 3.3. מתייחסים לעצירת Changed כסיום
    • 3.4. חושבים שהעלאת InternalBufferSize פותרת הכול
    • 3.5. מתעלמים מ-Error ורק רושמים ביומן
  4. שיטות עבודה מומלצות
    • 4.1. מקפלים את ההתראות ל”בקשת סריקה חוזרת”
    • 4.2. תנאי הסיום מסומן במפורש בצד השולח
    • 4.3. צד הקבלה תופס claim באופן אטומי
    • 4.4. מבצעים full rescan בהפעלה / overflow / חיבור מחדש
    • 4.5. מניחים אידמפוטנטיות מראש
  5. פסאודו-קוד (קטעים)
    • 5.1. תבנית כשל טיפוסית
    • 5.2. דוגמה לכיוון נכון (בגדול, ככה)
  6. חלוקה גסה בין השימושים
  7. סיכום
  8. מקורות

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

המאמר הזה מציע לא להתייחס לאירועי Created/Changed של FileSystemWatcher כהודעת סיום, להימנע מאובדן התראות עקב overflow של המאגר הפנימי ומתכנון ששוחזר מרצף אירועים, ולקפל את כל ההתראות לסוג אחד של בקשת סריקה חוזרת שבודקת את המצב בפועל עם full rescan. הסיום מסומן במפורש על ידי הצד השולח עם temp->rename או done/manifest, וצד הקבלה תופס claim אטומי למועמדים במצב ready שנמצאו בסריקה החוזרת, ובהנחה שהולכים לבדוק כמה פעמים, קולט זאת עם אידמפוטנטיות. כשלא ניתן להריץ כל הזמן, או כשפספוסים אסורים, ממקם את יומן USN change journal כאפשרות נוספת.

מפת הידע של המדריך המעשי ל-FileSystemWatcherתרשים המראה שההתראות של FileSystemWatcher הן רק סימן לשינוי ולא הודעת סיום, שההתראות מקופלות לבקשת סריקה חוזרת ומשולבות עם full rescan ו-claim, ואת הקשר בין אובדן התראות עקב overflow של המאגר הפנימי לבין החלופה של יומן USN.עלול לגרום למשתמש במענה מומלץ למענה מומלץ לשימוש לא מומלץ לעלול לגרום לעלול לגרום לעלול לגרום למענה מומלץ למשתמש במשתמש במענה מומלץ למענה מומלץ למענה מומלץ לעלול לגרום למונעמשתמש במשתמש במענה מומלץ לצריך לקדום לצריך לקדום לעלול לגרום למצמצםמענה מומלץ למחייבמחייבFileSystemWatcher‏full rescan (סריקה חוזרת של כל הספרייה)אובדן התראות עקב גלישת החוצץ הפנימיסריקת ספרייה מחזוריתאובדן התראות שינויכוונון InternalBufferSizeאנטי-דפוס של רישום אירוע Error ביומן והתעלמות ממנופירוש שגוי של Created כהודעת סיוםתקלה של קריאת קובץ שכתיבתו טרם הושלמהסימון מפורש של תנאי הסיום בצד השולחפרסום בסדר temp ‏-> close ‏-> rename/replaceקובץ done/manifestקיפול ההתראות לכדי בקשת סריקה חוזרתאנטי-דפוס של הסתמכות על מספר אירועי Changed וסדרםאנטי-דפוס של שחזור מצב מתוך רצף אירועיםעיבוד כפול (חיוב כפול, שליחה כפולה, אובדן עדכון)תפיסה אטומית (claim)bundle (ספריית יחידת השילוב)עיבוד שמניח אידמפוטנטיותשינויים שהוחמצו בזמן שה-watcher היה מושבתיומן USN (יומן השינויים)NTFSהרשאות מנהל

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

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

  • ההתראות של FileSystemWatcher הן לא הודעת סיום, אלא סימן לתחילת שינוי
  • Created /‏ Changed /‏ Renamed יכולים להגיע כפולים, בסדר שונה מהצפוי, ולהיעלם ב-overflow
  • יציב יותר לא לבצע עיבוד כבד במטפל האירוע, אלא רק לצבור בקשות סריקה חוזרת
  • העיקרון הוא לקבוע סיום במפורש עם temp -> close -> rename / replace או done / manifest
  • אם יש כמה עובדים, צריך לתפוס claim באופן אטומי לפני הקריאה
  • כוונון InternalBufferSize הוא רק אמצעי עזר. בסוף מה שעוזר הוא full rescan ואידמפוטנטיות

במילים אחרות, לא מתייחסים ל-FileSystemWatcher כ”זרם היסטוריה אמיתי”. יציב יותר להשאיר את ההתראה רק בתור אות של “כדאי ללכת לבדוק בקרוב”.

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

איור 1: עמוד השדרה של התכנון. לא מתייחסים לאירוע כהיסטוריה אמיתית, אלא משאירים אותו רק כ”כדאי ללכת לבדוק בקרוב”.

1.1. קודם קוד מינימלי שעובד

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

// אפליקציית קונסולה C# / .NET 8. הצורה המינימלית שרק מוודאת שההתראה מגיעה
using System.IO;

using var watcher = new FileSystemWatcher(@"C:\incoming")
{
    Filter = "*.csv",
    NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite,
};

watcher.Created += (_, e) => Console.WriteLine($"Created: {e.FullPath}");
watcher.Changed += (_, e) => Console.WriteLine($"Changed: {e.FullPath}");
watcher.Renamed += (_, e) => Console.WriteLine($"Renamed: {e.OldFullPath} -> {e.FullPath}");
watcher.Error += (_, e) => Console.WriteLine($"Error: {e.GetException().Message}");

watcher.EnableRaisingEvents = true; // כאן מתחיל המעקב
Console.WriteLine("להקשה על Enter כדי לסיים");
Console.ReadLine();

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

  • עד ש-EnableRaisingEvents = true לא מגיע אף אירוע. רק רישום handler לא מפעיל דבר
  • אורך חיי ה-watcher הוא אורך חיי האפליקציה. אם היקף המשתנה המקומי נגמר וה-watcher מושמד, ההתראות נעצרות שם. אם רוצים שהוא יישאר קבוע, שומרים אותו במקום שממשיך לחיות, כמו שדה
  • ברירת המחדל של NotifyFilter היא השילוב LastWrite | FileName | DirectoryName (ראו FileSystemWatcher.NotifyFilter Property במקורות בפרק 8). כדאי לציין במפורש מה תופסים, כדי לא להתלבט בקריאה חוזרת בהמשך

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

2. תבניות טעות נפוצות באופן השימוש ב-FileSystemWatcher (תרשים)

2.1. חושבים ש-Created הוא הודעת סיום

זו המוקש הברור ביותר. בהעתקה או בהעברה, Created יכול להישלח ברגע שהקובץ נוצר, ואחריו יכול להמשיך Changed פעם אחת או יותר.

קריאת קובץ פגום כשקוראים ל-Created כהודעת סיוםתרשים רצף המראה שהצד השולח יוצר את orders.csv ומקבל Created מיד, אך כשהצד המקבל פותח וקורא באותו רגע ההעתקה עדיין נמשכת, ומגיעים עוד אירועי Changed בזמן שהקובץ נקרא בחוסר שורות או פגום.הצד המקבלFileSystemWatcherwatched dirהצד השולחהצד המקבלFileSystemWatcherwatched dirהצד השולחעדיין באמצע ההעתקהחוסר שורות / JSON פגום / ZIP פגוםיוצר את orders.csvCreatedOnCreatedפותח וקורא את orders.csvכותב את השארChangedChanged

איור 2: גם באמצע ההעתקה נשלח Created. אם קוראים ברגע שהוא מתקבל, נתקלים בנתונים פגומים.

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

2.2. סומכים על מספר הפעמים והסדר של Changed

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

ריבוי אירועי Changed בשמירה רגילה עם מגעים חיצונייםתרשים רצף המראה שגם שמירה רגילה של report.xlsx מייצרת Created ו-Changed, שאחרי rename מקובץ זמני מתקבלים גם Renamed וגם Changed, ושסריקת אנטי-וירוס או אינדקסר מוסיפה עוד Changed - לא בהכרח פעם אחת ולא בהכרח באותו סדר.FileSystemWatcherאנטי-וירוס / אינדקסרwatched dirאפליקציה ששומרתFileSystemWatcherאנטי-וירוס / אינדקסרwatched dirאפליקציה ששומרתלא בהכרח פעם אחת, ולא בהכרח בסדר הזהמתחיל לשמור את report.xlsxCreatedChangedמבצע rename מקובץ זמניRenamedChangedסריקה / קריאת תכונותChanged

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

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

הערה:

  • Changed עלול להישלח גם בגלל rename של קובץ
  • RenamedEventArgs.Name יכול להיות null אם מערכת ההפעלה לא הצליחה להתאים בין old/new
  • גם קובץ מוסתר (hidden) לא מתעלמים ממנו. “זה שם temp מוסתר אז לא ייראה” לא נכון
  • אם מבצעים rename לתיקייה המנוטרת עצמה, השינוי הזה לא מתקבל כהתראה

2.3. מאבדים שינויים בגלישת המאגר הפנימי

ל-FileSystemWatcher יש מאגר פנימי. אם השינויים מתרכזים בזמן קצר, הוא גולש ומאבדים התראות בודדות.

מ-overflow של המאגר הפנימי ועד full rescanתרשים המראה שהרבה שינויים בזמן קצר מצטברים במאגר הפנימי, ואם העיבוד לא מדביק מתרחש overflow שמייצר אירוע Error, ובגלל שאי אפשר לסמוך על שלמות ההיסטוריה הבודדת מבצעים full rescan לספרייה.כןלאהרבה שינויים בזמן קצרההתראות מצטברות במאגר הפנימיהעיבוד מדביק?מעבדים כל אירוע לפי הסדרoverflowאירוע Errorלא סומכים על שלמות ההיסטוריה הבודדתמבצעים full rescan לספרייה

איור 4: כשפרץ ההתראות עולה על המאגר הפנימי מתרחש overflow, ושלמות רצף האירועים הבודדים נשברת.

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

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

3.1. מעבדים ישירות בתוך מטפל האירוע

זה מטיל יותר מדי אחריות על האירוע - גם קביעת סיום וגם תפיסת בעלות.

watcher.Created += (_, e) =>
{
    using var stream = File.OpenRead(e.FullPath);
    Import(stream); // אולי עדיין באמצע העתקה
};

watcher.Error += (_, e) =>
{
    Console.WriteLine(e.GetException()); // רק מדפיס
};

יש שתי בעיות.

  • בזמן Created, ייתכן שהתוכן עדיין לא הושלם
  • אין התאוששות מכישלון או מ-overflow

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

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

איור 5: המטפל צריך להיות קל. לא מטילים על האירוע גם קביעת סיום וגם תפיסת בעלות.

3.2. מנסים לשחזר את המצב האמיתי מרצף האירועים

תכנון של “ב-Created מוסיפים למילון, ב-Changed מעדכנים, ב-Deleted מוחקים, וב-Renamed מחליפים מפתח” נראה נקי במבט ראשון. אבל כשמעורבים כפילות, פיצול, overflow והפרעות חיצוניות, ההיגיון הפנימי הופך בהדרגה מפוקפק.

switch (e.ChangeType)
{
    case WatcherChangeTypes.Created:
        state[e.FullPath] = Pending;
        break;
    case WatcherChangeTypes.Changed:
        state[e.FullPath] = Modified;
        break;
    case WatcherChangeTypes.Deleted:
        state.Remove(e.FullPath);
        break;
}

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

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

איור 6: המטרה היא לא לשחזר את היסטוריית האירועים, אלא למצוא את היעד שמותר לעבד עכשיו.

3.3. מתייחסים לעצירת Changed כסיום

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

if (lastChangedAt + TimeSpan.FromSeconds(10) < DateTime.UtcNow)
{
    return Ready;
}

זה יוצר בעיה, לדוגמה, במקרים כאלה.

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

יציב יותר לקבוע סיום במפורש, לא בניחוש.

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

איור 7: שקט לא מהווה הוכחה לסיום. קובעים סיום במפורש, לא בניחוש.

3.4. חושבים שהעלאת InternalBufferSize פותרת הכול

כוונון InternalBufferSize חשוב, אבל זה לא גוף התכנון.

  • ברירת המחדל היא 8192 בייט
  • אי אפשר להקטין מתחת ל-4096 בייט, ולא ניתן לחרוג מ-64 KB
  • המאגר משתמש בזיכרון non-paged, ולכן ככל שמגדילים, זה לא עניין קל דעת

כלומר, גם אם מעלים עד 64 KB, ברגע שפרץ ההתראות עולה על זה - זה נגמר. ובנוסף, זה לא פותר ולו במעט את שאלת “האם זו הודעת סיום”.

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

  • לצמצם את מטרת המעקב עם Filter /‏ Filters
  • לצמצם את NotifyFilter למינימום הנדרש
  • לא להפוך IncludeSubdirectories ל-true בלי סיבה
  • להקל על מטפל האירוע
  • להכניס full rescan ואידמפוטנטיות
מה עושים קודם, לפני הגדלת המאגרתרשים המראה שגם אם מעלים את InternalBufferSize עד 64KB, אם הפרץ עולה על זה עדיין יש פספוסים, ולכן כדאי לצמצם קודם עם Filter ו-NotifyFilter, להקל על המטפל, ולהכניס full rescan ואידמפוטנטיות.דברים לטפל בהם קודםצמצום עם Filter ו-NotifyFilterהקלה על מטפל האירועfull rescan ואידמפוטנטיותכוונון InternalBufferSizeרק אמצעי עזר אחרון

איור 8: הגדלת המאגר היא לא גוף התכנון. הצמצום ומנגנון ההתאוששות קודמים.

3.5. מתעלמים מ-Error ורק רושמים ביומן

Error הוא לא סוג של התראה מסוג “מדי פעם מופיע אבל לא צריך לשים לב”. כאן מופיעים buffer overflow או מצב שבו נכשל המשך המעקב.

watcher.Error += (_, e) =>
{
    _logger.LogError(e.GetException(), "watcher error");
    // אם זה נגמר כאן, שמים לב לפספוס אבל לא מתאוששים
};

לפחות, כדאי לעשות עד כאן.

  • לבקש full rescan
  • אם המשך המעקב מוטל בספק, לשקול גם יצירה מחדש של ה-watcher
  • להכין אפשרות לעיבוד חוזר אידמפוטנטי בהנחה שיש פספוסים

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

4.1. מקפלים את ההתראות ל”בקשת סריקה חוזרת”

אם מחברים ישירות Created /‏ Changed /‏ Deleted /‏ Renamed /‏ Error כל אחד לעיבוד עסקי נפרד, הבהירות נפגעת. קודם מקפלים את כולם לסוג אחד של אות - “לך לבדוק”.

קיפול כל האירועים לבקשת סריקה חוזרת אחתתרשים המראה שאירועי Created/Changed/Deleted/Renamed, אירועי Error/overflow, וגם ה-startup, כולם מקופלים לאות scan request אחד, שמפעיל סריקה חוזרת של הספרייה, מונה מועמדים במצב ready, ומנסה claim.Created / Changed / Deleted / Renamedscan requestError / overflowstartupסריקה חוזרת של הספרייהמונים מועמדים במצב readyמנסים claim

איור 9: כל התראה וגם ה-startup מקופלים לסוג אחד של scan request, הסריקה החוזרת מחפשת מועמדים במצב ready, ומנסים claim.

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

  • במטפל האירוע רק קובעים dirty = true ומרימים signal
  • הסריקה מרוכזת ב-worker אחד
  • בזמן פרץ, מרכזים כ-100 עד 300ms ואז מבצעים סריקה אחת
  • אם מגיעה התראה נוספת בזמן הסריקה, מבצעים עוד סריקה אחת אחרי שהיא מסתיימת

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

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

לדוגמה, אם סריקה אחת מסתיימת ב-50ms והזיהוי מותר תוך שנייה, הטווח 100 עד 300ms מתאים בדיוק. לעומת זאת, אם יש הרבה קבצים וסריקה אחת אורכת כמה שניות, יעיל יותר לבדוק מחדש את מבנה הסריקה (צמצום היעד, הסתכלות רק על done, פיצול תיקיות משנה) לפני שמאריכים את ההמתנה.

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

4.2. תנאי הסיום מסומן במפורש בצד השולח

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

הדרך הסטנדרטית היא כמובן זו.

  • כותבים את כל התוכן לשם temp
  • מבצעים close
  • מבצעים rename / replace באותה מערכת קבצים
  • אם צריך, מניחים done / manifest בסוף
פרוטוקול הפרסום עם קובץ temp ו-renameתרשים המראה שהצד השולח כותב את כל התוכן ל-data.tmp, מבצע flush וסגירה, מבצע rename או replace לשם הסופי data.csv, ומניח data.done או manifest.json, כך שהצד המקבל מסתכל רק על שם ה-final או ה-done.כותבים את כל התוכן ל-data.tmpflush / closerename / replace ל-data.csvמניחים data.done / manifest.jsonצד הקבלה מסתכל רק על שם final או done

איור 10: הצד השולח כותב את כל התוכן ל-temp, סוגר, מפרסם עם rename, ואם צריך מניח done / manifest בסוף.

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

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

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

תפיסת claim אטומי כשכמה עובדים מתחרים על אותו מועמדתרשים רצף המראה שה-scanner מוצא את order-123 ומנסה rename אליו גם מ-worker1 וגם מ-worker2, ורק מי מהשניים שהצליח ראשון ב-rename מחזיק בבעלות על העיבוד.processing/worker2processing/worker1incomingscannerprocessing/worker2processing/worker1incomingscannerרק מי שהצליח ראשון מחזיק בבעלותמוצא את order-123מבצע rename ל-order-123מבצע rename ל-order-123

איור 11: גם אם כמה עובדים מוצאים את אותו מועמד, רק מי שהצליח ב-rename מחזיק בבעלות.

כפי שהוזכר גם במאמר הקודם, ה-rename מ-incoming -> processing/<worker>/ ברור למדי. במיוחד אם מרכזים גוף + manifest + קבצי עזר בתיקייה אחת, נוח לתפוס claim ברמת ה-bundle.

incoming/
  order-123/
    payload.csv
    manifest.json

בצורה הזו, אפשר לתפוס בעלות ב-rename אחד של תיקיית ה-bundle.

4.4. מבצעים full rescan בהפעלה / overflow / חיבור מחדש

זה חשוב מאוד.

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

לכן, כדאי להכניס full rescan לפחות בעיתויים האלה.

  • בזמן ההפעלה
  • בקבלת Error
  • מיד אחרי יצירה מחדש של ה-watcher
  • כביטוח תקופתי, במרווח קבוע

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

העיתויים להכניס full rescanתרשים המראה שארבעה עיתויים - בזמן ההפעלה, בקבלת Error, מיד אחרי יצירה מחדש של ה-watcher, וכביטוח תקופתי במרווח קבוע - מכניסים בהם full rescan כדי להתאושש לשינויים שהאירועים לא תפסו.בזמן ההפעלהfull rescanבקבלת Errorמיד אחרי יצירה מחדש של ה-watcherכביטוח תקופתיהתאוששות לשלמות

איור 12: ה-watcher הוא רמז לדיפרנציאל, ו-full rescan הוא ההתאוששות לשלמות. מכניסים אותו בארבעת העיתויים האלה תמיד.

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

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

באופן קונקרטי, זה נראה כך.

  • מכניסים IdempotencyKey ל-manifest
  • אם כבר עובד, לא מריצים מחדש תופעות לוואי
  • אפשר להצליב מול archive-ed / מתועד ב-DB / נשלח
  • גם אחרי full rescan, זה רק “לראות שוב באופן בטוח את אותו הדבר”

לנסות ליצור exactly-once רק באמצעות האירועים די קשה. מקבלים at-least-once, וסוגרים בסוף עם אידמפוטנטיות - זה חזק יותר בפועל בשטח.

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

איור 13: לא יוצרים exactly-once באמצעות האירועים, אלא מקבלים at-least-once וסוגרים עם אידמפוטנטיות.

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

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

using var watcher = new FileSystemWatcher(incomingDir)
{
    Filter = "*.csv",
    IncludeSubdirectories = false,
    EnableRaisingEvents = true,
    InternalBufferSize = 64 * 1024
};

watcher.Created += (_, e) =>
{
    // חושבים ש-Created = הודעת סיום
    ProcessFile(e.FullPath);
};

watcher.Changed += (_, e) =>
{
    // מגיע כמה פעמים, אז מעבדים שוב בכל מקרה
    ProcessFile(e.FullPath);
};

watcher.Error += (_, e) =>
{
    Console.WriteLine(e.GetException());
    // לא מתאוששים
};

יש ארבע בעיות.

  • מחברים ישירות Created /‏ Changed לעיבוד עסקי
  • אין קביעת סיום
  • לא מבצעים full rescan בזמן overflow
  • אין מנגנון שעוצר עיבוד חוזר לאותו קובץ

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

private readonly SemaphoreSlim _scanSignal = new(0, int.MaxValue);
private int _scanRequested = 0;
private int _fullRescanRequested = 0;

void OnAnyChange(object? sender, FileSystemEventArgs e)
{
    RequestScan(full: false);
}

void OnRenamed(object? sender, RenamedEventArgs e)
{
    RequestScan(full: false);
}

void OnError(object? sender, ErrorEventArgs e)
{
    Log(e.GetException());
    RequestScan(full: true);
}

void RequestScan(bool full)
{
    if (full)
    {
        Interlocked.Exchange(ref _fullRescanRequested, 1);
    }

    if (Interlocked.Exchange(ref _scanRequested, 1) == 0)
    {
        _scanSignal.Release();
    }
}

async Task ScannerLoopAsync(CancellationToken cancellationToken)
{
    RequestScan(full: true); // startup scan

    while (!cancellationToken.IsCancellationRequested)
    {
        await _scanSignal.WaitAsync(cancellationToken);

        // מרכזים מעט את פרץ ההתראות
        await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);

        Interlocked.Exchange(ref _scanRequested, 0);
        bool full = Interlocked.Exchange(ref _fullRescanRequested, 0) == 1;

        foreach (var bundle in EnumerateReadyBundles(incomingDir, full))
        {
            var claimedPath = Path.Combine(processingDir, bundle.Name);

            if (!TryClaimByRename(bundle.Path, claimedPath))
            {
                continue; // עובד אחר תפס קודם
            }

            var manifest = ReadManifest(Path.Combine(claimedPath, "manifest.json"));

            if (AlreadyProcessed(manifest.IdempotencyKey))
            {
                MoveToArchive(claimedPath, archiveDir);
                continue;
            }

            ProcessBundle(claimedPath);
            RecordProcessed(manifest.IdempotencyKey);
            MoveToArchive(claimedPath, archiveDir);
        }

        if (Volatile.Read(ref _scanRequested) == 1)
        {
            _scanSignal.Release(); // לא מפספסים התראה שהגיעה בזמן הסריקה
        }
    }
}

מה שחשוב בדוגמה הזו הוא לא ה-API הפרטני, אלא הזרימה.

  • מקפלים את ההתראות ל-scan request
  • מוצאים ready בסריקה
  • תופסים claim
  • בודקים אידמפוטנטיות
  • מעבדים, מתעדים, ומעבירים ל-archive
הזרימה של הכיוון הנכון בעיבודתרשים המראה שמקפלים את ההתראות ל-scan request, מוצאים בסריקה מועמד ready, תופסים claim, בודקים אידמפוטנטיות, ואז מעבדים, מתעדים, ומעבירים ל-archive - הזרימה שמייצג הפסאודו-קוד.מקפלים התראות ל-scan requestמוצאים ready בסריקהתופסים claimבודקים אידמפוטנטיותמעבדים, מתעדים, ומעבירים ל-archive

איור 14: הזרימה הזו היא הגוף, יותר מה-API הפרטני. האירוע כאן הוא רק trigger.

האירועים של FileSystemWatcher, כאן, הם רק trigger.

נציין ש-EnumerateReadyBundles /‏ TryClaimByRename /‏ ReadManifest /‏ AlreadyProcessed וכדומה הן פונקציות ששמנו להן שם כדי להראות את הזרימה במאמר הזה, ולא API סטנדרטי של ‎.NET. הצורה שבאמת בונים ומריצים (ספרייה, הדגמת קונסולה שרצה על תיקייה זמנית, בדיקות יחידה שמאמתות אירועים) נמצאת בערכת הדוגמה המלאה שהוזכרה בפתיחה.

filesystemwatcher-safe-basics - komurasoft-blog-samples (GitHub)

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

  • עובד קבלה יחיד / שולטים גם בצד השולח קודם temp -> close -> rename וסריקת startup. זה בלבד כבר די יציב.

  • יש כמה עובדי קבלה בנוסף לעיל, טוב יותר להוסיף claim rename מ-incoming ל-processing.

  • תדירות התראות גבוהה מצמצמים Filter /‏ NotifyFilter /‏ IncludeSubdirectories, ומקטינים למינימום את מטפל האירוע. כוונון InternalBufferSize בא אחר כך.

  • קשה עם overflow / פספוסים אסורים מניחים full rescan כברירת מחדל, ואם זה עדיין לא מספיק, לא כדאי להמר על FileSystemWatcher בלבד. אם מוגבלים ל-Windows, גם USN change journal הוא אפשרות.

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

שני הפריטים האחרונים הם החלטת נסיגה חשובה למדי. ‏FileSystemWatcher נוח, אבל הוא לא גלאי אמת כל-יכול.

מה ההבדל של יומן USN change journal

יומן USN change journal הוא רשומת שינויים שה-NTFS מחזיק ברמת הכרך. התראת תיקייה כמו FileSystemWatcher לא מתקבלת אם האפליקציה לא רצה ברגע שהשינוי קרה, אבל ה-change journal משאיר רשומה בצד הכרך, ולכן גם שינויים שקרו בזמן שהאפליקציה הייתה כבויה אפשר לקרוא מחדש מהמיקום (USN) שנקרא בפעם הקודמת. גם בתיעוד של Microsoft, כחולשה של התראת תיקייה מוזכר ש”צריך להריץ את האפליקציה כל הזמן”, וכפתרון לזה מוסבר ה-change journal.

מצד שני, גם העומס גדל.

  FileSystemWatcher יומן USN change journal
יחידת המעקב התיקייה שצוינה (+ תיקיות משנה) הכרך כולו. את הטווח הנדרש מצמצמים בעצמכם
בזמן שהאפליקציה הייתה כבויה לא ידוע. משלימים עם full rescan אפשר לקרוא מחדש מהרשומה
פספוסים קורים ב-overflow של המאגר הפנימי כשעוברים את תקרת היומן, נמחקות הרשומות הישנות
מה נדרש רק API של ‎.NET handle לכרך וקריאה ל-FSCTL_*. לפעולות ניהול כמו יצירה/מחיקה של היומן נדרשות הרשאות מנהל

כלומר, זו אפשרות למקרים שבהם “אי אפשר להריץ כל הזמן” או “רוצים לתפוס גם שינויים בזמן ההשבתה” נכנסים לדרישות. להפך, אם זה לא נדרש, FileSystemWatcher + full rescan פשוט יותר למימוש.

ההבדל בין FileSystemWatcher ליומן USN change journalתרשים המראה ש-FileSystemWatcher לא יודע על שינויים בזמן שהאפליקציה כבויה וממלא זאת עם full rescan, בעוד יומן USN change journal משאיר רשומה בצד הכרך ואפשר לקרוא מחדש מהמיקום הקודם גם שינויים בזמן ההשבתה.FileSystemWatcherלא יודע על שינויים בזמן ההשבתהממלא זאת עם full rescanיומן USN change journalהרשומה נשארת בצד הכרךאפשר לקרוא מחדש מה-USN הקודם

איור 15: כשנכנסות לתמונה דרישות של “אי אפשר להריץ כל הזמן” או “רוצים לתפוס גם שינויים בזמן השבתה”, ה-change journal הופך לאפשרות.

7. סיכום

FileSystemWatcher אינו תחליף להודעת סיום. האמת נמצאת לא ברצף האירועים, אלא במצב שנראה עכשיו על הדיסק. הסיום מסומן במפורש עם temp -> close -> rename / replace או done / manifest, והבעלות נקבעת בתפיסת claim באופן אטומי. גוף התכנון נמצא כאן.

עיבוד מיידי ב-Created, הסתמכות על מספר הפעמים או הסדר של Changed, התייחסות לעצירת Changed כסיום, הסתפקות ב-InternalBufferSize בלבד, ראיית Error בלי להתאושש - כל אלה תכנונים שכדאי להימנע מהם. במקום זאת, מקפלים את ההתראות לבקשת סריקה חוזרת, מכניסים full rescan בהפעלה / overflow / חיבור מחדש, תופסים בעלות עם claim rename, וקולטים כפילות וסריקה חוזרת עם אידמפוטנטיות.

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

8. מקורות

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

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

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

שאלות נפוצות

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

מותר לקרוא קובץ באירוע Created של FileSystemWatcher?
לא. Created רק מייצג ש"השם נראה", ולא מבטיח ש"כבר מותר לקרוא". בהעתקה או בהעברה, Created עשוי להישלח ברגע שהקובץ נוצר, ואחריו יכולים להגיע Changed פעם אחת או יותר. הסיום צריך להיות מסומן במפורש על ידי הצד השולח, למשל בסדר temp -> close -> rename/replace או עם done/manifest, וצד הקבלה מסתכל בעיקרון רק על השם הסופי או על ה-done.
האם FileSystemWatcher עלול לפספס התראות?
כן, יכול לקרות. אם המאגר הפנימי (ברירת מחדל 8192 בייט, לא ניתן להקטין מתחת ל-4096 בייט, ותקרה של 64KB) גולש, מפספסים התראות בודדות ומתקבל אירוע Error. כשקורה overflow, השלמות של רצף האירועים הבודדים עצמו הופכת מוטלת בספק, ולכן בטוח יותר לבצע full rescan לספרייה כדי לבדוק הכול מחדש. כדאי להכניס full rescan גם בזמן ההפעלה, בקבלת Error, מיד אחרי יצירה מחדש של ה-watcher, וגם כביטוח תקופתי.
למה אירוע Changed מגיע כמה פעמים?
כי גם פעולות רגילות כמו העברה או שמירה יכולות להיראות מחולקות לכמה אירועים, ובנוסף נתפסים גם מגעים של תוכנת אנטי-וירוס או אינדקסר. תכנון שסומך על מספר או סדר האירועים מסוכן. כדאי לקפל את ההתראות לסוג אחד של אות - "בקשת סריקה חוזרת" - לרכז את הסריקה לworker אחד, ולייצב על ידי ריכוז זמן של כ-100 עד 300ms לפני סריקה אחת בזמן פרץ התראות.
האם הגדלת InternalBufferSize פותרת פספוסים?
לא פותרת. גם אם מעלים עד 64KB, אם פרץ ההתראות עולה על זה, עדיין תיווצר החמצה, וזה לא פותר כלום בשאלה אם זו הודעת סיום. המאגר משתמש בזיכרון non-paged, ולכן הגדלתו אינה עניין קל דעת. הסדר הנכון הוא לצמצם קודם את מטרת המעקב עם Filter/NotifyFilter, לבדוק מחדש את IncludeSubdirectories, להקל על מטפל האירוע, ולהכניס full rescan ואידמפוטנטיות.

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג