FileSystemWatcher בפועל: miss ו-duplicate

· עודכן בתאריך: · · FileSystemWatcher, C#, .NET, פיתוח Windows, file handoff, תכנון

היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 10 Mar 2026)
פרסום ראשון
לצטט את המאמר הזה(DOI: 10.5281/zenodo.22173333)

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

Go Komura (2026). FileSystemWatcher בפועל: miss ו-duplicate. KomuraSoft LLC. https://doi.org/10.5281/zenodo.22173333 https://comcomponent.com/he/blog/filesystemwatcher-safe-basics/

DOI (הגרסה האחרונה)
10.5281/zenodo.22173333
DOI (הגרסה הזו)
10.5281/zenodo.22173334

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

המאמר מסדר איך להשתמש ב-FileSystemWatcher ועל מה לשים לב, בעיקר בהנחת file handoff ב-.NET על Windows. את רעיון ה-locking שמונח בבסיס אפשר לראות גם ב-Locking ב-file handoff: file lock ו-atomic claim בפועל.

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

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

  • ה-notification הוא רק trigger
  • האמת היא rescan של התיקייה
  • ownership נקבע ב-atomic claim
  • בסוף תופסים כפילות עם idempotency

בגוף המאמר נעבור לפי הסדר על המכשולים בשילוב FileSystemWatcher ל-file handoff לפי הגישה הזו.

הקוד שמופיע כאן מפורסם ב-GitHub כסט דוגמאות שאפשר לבנות ולהריץ: ספרייה, הדגמת console שרצה על תיקייה זמנית, ו-unit tests שיוצרים ומשנים קבצים בפועל כדי לאמת אירועים.

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

למי זה מיועד, ומה מניחים

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

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

מונחים שכדאי להכיר

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

תוכן עניינים

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

ב-diagram, solid line מציינת relation שתמיד מתקיים ו-dashed line מציינת relation מותנה (התנאים מופיעים בהסבר של כל relation ב-detail page). הרשימה המלאה של ה-relations (סה”כ 26, כולל evidence ו-certainty) וההגדרות של ה-concepts המרכזיים נמצאות ב-detail page של ה-knowledge map (ביפנית). Data: JSON-LD / Turtle

1. המסקנה בקצרה

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

במילים אחרות: לא מתייחסים ל-FileSystemWatcher כאל “זרם היסטוריה אמיתי”. ה-notification נשאר רק אות של “כדאי ללכת לבדוק”. ככה זה נשבר פחות.

עמוד השדרה של התכנון במאמרה-notification הוא רק trigger, האמת היא rescan של התיקייה, ownership נקבע ב-atomic claim, ובסוף תופסים כפילות עם idempotency.ה-notification הוא triggerהאמת היא rescan של התיקייהownership נקבע ב-atomic claimבסוף תופסים עם idempotency

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

1.1. קודם קוד מינימלי שרץ

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

// C# / .NET 8 console. רק מוודאים שה-notifications מגיעות
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 לבד לא מפעיל כלום
  • ה-lifetime של watcher הוא ה-lifetime של האפליקציה. אם משתנה מקומי יוצא מ-scope ונהרס, ה-notifications נעצרות. אם זה צריך לרוץ כל הזמן, מחזיקים בשדה או במקום אחר שנשאר חי
  • ברירת המחדל של NotifyFilter היא השילוב LastWrite | FileName | DirectoryName (FileSystemWatcher.NotifyFilter Property במקורות בפרק 8). עדיף לציין במפורש מה קולטים, כדי שאחר כך לא יתבלבלו בקריאה מחדש

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

2. דפוסי טעות נפוצים עם FileSystemWatcher (תרשים)

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

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

המקבלFileSystemWatcherwatched dirהשולחהמקבלFileSystemWatcherwatched dirהשולחעדיין באמצע ההעתקהחוסר שורות / JSON פגום / ZIP פגוםיוצר את orders.csvCreatedOnCreatedפותח את orders.csv וקוראכותב את השארChangedChanged

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

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

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

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

FileSystemWatcherAV / indexerwatched dirהאפליקציה ששומרתFileSystemWatcherAV / indexerwatched dirהאפליקציה ששומרתלא בהכרח פעם אחת, ולא בהכרח בסדר הזהמתחיל לשמור את report.xlsxCreatedChangedrename מקובץ זמניRenamedChangedscan / קריאת attributesChanged

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

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

הערה:

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

2.3. מאבדים שינויים כשה-buffer הפנימי עולה על גדותיו

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

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

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

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

3. Anti-patterns

3.1. מעבדים ישירות בתוך ה-event handler

זה שם יותר מדי אחריות על האירוע: גם קביעת סיום וגם תפיסת ownership.

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

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

יש שתי בעיות.

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

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

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

איור 5: ה-handler נשאר קל. לא שמים עליו קביעת סיום ותפיסת ownership.

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

תכנון כמו “ב-Created מוסיפים למילון, ב-Changed מעדכנים, ב-Deleted מוחקים, ב-Renamed מחליפים מפתח” נראה נקי. אבל duplicate, פיצול, 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;
}

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

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

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

3.3. Changed נעצר, אז מתייחסים לזה כסיום

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

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

זה מכשיל במקרים כאלה, למשל.

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

סיום עדיף לציין במפורש, לא לנחש.

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

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

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

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

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

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

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

  • לצמצם יעד עם Filter / Filters
  • לצמצם NotifyFilter למינימום שנדרש
  • לא לשים IncludeSubdirectories = true סתם
  • להקל על ה-event handler
  • להכניס full rescan ו-idempotency
מה לעשות לפני שמגדילים bufferגם InternalBufferSize של 64KB לא עוזר אם הפרץ גדול יותר. קודם מצמצמים עם Filter ו-NotifyFilter, מקלים על ה-handler, ומכניסים full rescan ו-idempotency.קודם מטפלים בזהמצמצמים עם Filter ו-NotifyFilterמקלים על ה-handlerfull rescan ו-idempotencyכוונון InternalBufferSizeנשאר עזר בסוף

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

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

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

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

לפחות את זה כדאי לעשות.

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

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

4.1. מקפלים את ה-notifications לבקשת rescan

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

Created / Changed / Deleted / Renamedscan requestError / overflowstartuprescan של התיקייהמונים מועמדים readyמנסים claim

איור 9: כל notification וגם startup מתקפלים לסוג אחד של scan request. אחרי rescan מחפשים מועמדים ready ומנסים claim.

נקודות מימוש:

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

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

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

למשל, אם סריקה אחת נגמרת ב-50ms והזיהוי יכול לחכות עד שנייה, 100 עד 300ms נכנסים יפה. לעומת זאת, אם יש הרבה קבצים וסריקה אחת לוקחת כמה שניות, לפני שמאריכים המתנה עדיף לתקן את מבנה הסריקה: לצמצם יעד, להסתכל רק על done, לפצל subdirectories.

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

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

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

הדרך הסטנדרטית עדיין זו.

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

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

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

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

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

processing/worker2processing/worker1incomingscannerprocessing/worker2processing/worker1incomingscannerרק מי שהצליח ראשון מחזיק ownershipמוצא את order-123rename order-123rename order-123

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

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

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

כך מספיק rename אחד ל-bundle directory כדי לקחת ownership.

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

זה די חשוב.

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

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

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

המחשבה כאן: watcher הוא רמז להפרש, rescan הוא recovery של עקביות.

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

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

4.5. מניחים idempotency מראש

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

בפועל זה נראה כך.

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

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

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

איור 13: לא בונים exactly-once מאירועים. מקבלים at-least-once וסוגרים עם idempotency.

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());
    // אין recovery
};

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

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

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);

        // אוספים קצת פרץ notifications
        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; // worker אחר תפס קודם
            }

            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(); // לא מפספסים notification שהגיעה באמצע הסריקה
        }
    }
}

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

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

איור 14: הזרימה היא הגוף, לא ה-API הדק. האירוע הוא רק trigger.

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

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

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

6. חלוקה גסה לפי מצב

  • worker קבלה יחיד / גם צד השליחה אפשר לתקן קודם temp -> close -> rename ו-startup scan. כבר זה יציב למדי.

  • יש כמה workers בצד הקבלה בנוסף, כדאי להכניס claim rename מ-incoming ל-processing.

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

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

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

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

מה שונה ב-USN change journal

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

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

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

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

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

איור 15: אם אי אפשר להריץ כל הזמן, או שרוצים לקלוט שינויים בזמן עצירה, change journal נכנס כאפשרות.

7. סיכום

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

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

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

8. מקורות

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

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

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

שאלות נפוצות

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

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

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג