FileSystemWatcher בפועל: miss ו-duplicate
· עודכן בתאריך: · Go Komura · 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. קודם קוד מינימלי שרץ
- דפוסי טעות נפוצים עם
FileSystemWatcher(תרשים)- 2.1. חושבים ש-
Createdהוא הודעת סיום - 2.2. סומכים על מספר הפעמים והסדר של
Changed - 2.3. מאבדים שינויים כשה-buffer הפנימי עולה על גדותיו
- 2.1. חושבים ש-
- Anti-patterns
- 3.1. מעבדים ישירות בתוך ה-event handler
- 3.2. מנסים לשחזר את המצב האמיתי מרצף האירועים
- 3.3.
Changedנעצר, אז מתייחסים לזה כסיום - 3.4. חושבים שהעלאת
InternalBufferSizeפותרת הכול - 3.5. מתעלמים מ-
Errorורק רושמים ב-log
- שיטות עבודה מומלצות
- 4.1. מקפלים את ה-notifications לבקשת rescan
- 4.2. תנאי הסיום מסומן במפורש בצד השולח
- 4.3. צד הקבלה לוקח claim באופן אטומי
- 4.4. עושים full rescan בהפעלה / overflow / חיבור מחדש
- 4.5. מניחים idempotency מראש
- פסאודו-קוד (קטעים)
- 5.1. דפוס כשל טיפוסי
- 5.2. דוגמה לכיוון נכון (בגדול, ככה)
- חלוקה גסה לפי מצב
- סיכום
- מקורות
ב-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 נשאר רק אות של “כדאי ללכת לבדוק”. ככה זה נשבר פחות.
flowchart TB
accTitle: עמוד השדרה של התכנון במאמר
accDescr: ה-notification הוא רק trigger, האמת היא rescan של התיקייה, ownership נקבע ב-atomic claim, ובסוף תופסים כפילות עם idempotency.
notif["ה-notification הוא trigger"] --> rescan["האמת היא rescan של התיקייה"]
rescan --> claim["ownership נקבע ב-atomic claim"]
claim --> idem["בסוף תופסים עם 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 פעם אחת או יותר.
sequenceDiagram
participant 送信 as השולח
participant 共有 as watched dir
participant W as FileSystemWatcher
participant 受信 as המקבל
送信->>共有: יוצר את orders.csv
共有-->>W: Created
W-->>受信: OnCreated
受信->>共有: פותח את orders.csv וקורא
Note over 受信: עדיין באמצע ההעתקה
送信->>共有: כותב את השאר
共有-->>W: Changed
共有-->>W: Changed
Note over 受信: חוסר שורות / JSON פגום / ZIP פגום
איור 2: גם באמצע העתקה, Created מגיע. אם קוראים ברגע שהוא מגיע, תופסים נתונים פגומים.
Created אומר שהשם נראה. הוא לא מבטיח שכבר מותר לקרוא.
אם מזהים בין השניים, דורכים במסלול אחר על 2.1 מהמאמר הקודם.
2.2. סומכים על מספר הפעמים והסדר של Changed
Changed לא בהכרח מגיע פעם אחת.
גם פעולה רגילה כמו העברה או שמירה יכולה להיראות מחולקת לכמה אירועים. בנוסף אפשר לקלוט גם נגיעות של antivirus או indexer.
sequenceDiagram
participant App as האפליקציה ששומרת
participant Dir as watched dir
participant AV as AV / indexer
participant W as FileSystemWatcher
App->>Dir: מתחיל לשמור את report.xlsx
Dir-->>W: Created
Dir-->>W: Changed
App->>Dir: rename מקובץ זמני
Dir-->>W: Renamed
Dir-->>W: Changed
AV->>Dir: scan / קריאת attributes
Dir-->>W: Changed
Note over W: לא בהכרח פעם אחת, ולא בהכרח בסדר הזה
איור 3: גם שמירה רגילה מתפצלת לכמה אירועים, ומתערבבים גם נגיעות של process חיצוני. לא סומכים על מספר ולא על סדר.
הציפייה “Changed הגיע פעם אחת אז הסתיים” או “אחרי Renamed כבר לא נוגעים” די מסוכנת.
הערה:
- ב-rename של קובץ לפעמים מגיע גם
Changed RenamedEventArgs.Nameיכול להיותnullאם בצד ה-OS לא מצליחים להתאים old/new- hidden file לא מתעלמים ממנו. “זה שם temp מוסתר אז לא נראה” לא מחזיק
- גם אם עושים rename לתיקייה שעוקבים אחריה עצמה, השינוי הזה לא מגיע כ-notification
2.3. מאבדים שינויים כשה-buffer הפנימי עולה על גדותיו
ל-FileSystemWatcher יש buffer פנימי.
אם שינויים מתרכזים בזמן קצר, הוא עולה על גדותיו ומפספסים notifications בודדות.
flowchart LR
A["הרבה שינויים בזמן קצר"] --> B["notifications מצטברות ב-buffer הפנימי"]
B --> C{"העיבוד מדביק?"}
C -->|"כן"| D["מעבדים אירועים בודדים לפי הסדר"]
C -->|"לא"| E["overflow"]
E --> F["אירוע Error"]
F --> G["לא סומכים על שלמות ההיסטוריה הבודדת"]
G --> H["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, בפרץ חונקים את עצמכם.
flowchart TB
accTitle: נקודת ההפרדה במשקל של ה-event handler
accDescr: ב-event handler שמים בקשת rescan וחוזרים מהר. אם מתחילים I/O כבד או עדכון DB בתוך ה-handler, יש סכנה לקרוא תוכן לא גמור, ובפרץ העיבוד לא מדביק.
ev["event handler"] --> light["שמים בקשת rescan וחוזרים מהר"]
heavy["I/O כבד או עדכון DB בתוך ה-handler"] -.-> raw["סכנה לקרוא תוכן לא גמור"]
heavy -.-> choke["בפרץ העיבוד לא מדביק"]
איור 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 מה שחשוב הוא למצוא נכון את מה שמותר לעבד ברגע הזה, לא לשחזר יפה את היסטוריית האירועים.
flowchart TB
accTitle: שחזור מאירועים מול בדיקת המצב על הדיסק
accDescr: תכנון שמשחזר מצב מרצף אירועים נשבר עם duplicate, פיצול, overflow והפרעות. חזק יותר לבדוק בכל פעם את המצב על הדיסק ולמצוא נכון מה מותר לעבד עכשיו.
ev2["משחזרים מצב מרצף האירועים"] -.-> broke["duplicate, פיצול, overflow שוברים עקביות"]
disk["בודקים בכל פעם את המצב על הדיסק"] --> goal["מוצאים נכון מה מותר לעבד"]
איור 6: המטרה אינה לשחזר היסטוריית אירועים, אלא למצוא מה מותר לעבד עכשיו.
3.3. Changed נעצר, אז מתייחסים לזה כסיום
זה אותו ריח כמו “גודל הקובץ נעצר אז הסתיים” מהמאמר הקודם. נראה נוח, אבל קובעים סיום בניחוש.
if (lastChangedAt + TimeSpan.FromSeconds(10) < DateTime.UtcNow)
{
return Ready;
}
זה מכשיל במקרים כאלה, למשל.
- העתקה של קובץ גדול נעצרת באמצע
- האפליקציה השולחת שומרת בכמה שלבים
- ב-share ברשת ה-notification נראה באיחור
- process חיצוני משנה אחר כך attributes או זמן
סיום עדיף לציין במפורש, לא לנחש.
flowchart TB
accTitle: הסיכון בניחוש סיום לפי שקט
accDescr: ניחוש ש-Changed שנעצר אומר סיום נכשל בהשהיית העתקה, שמירה רב-שלבית, איחור ב-notification, או שינוי attributes אחר כך. יציב יותר שהשולח מסמן סיום במפורש.
guess["מנחשים סיום כי Changed נעצר"] -.-> c1["השהיית העתקה מזוהה בטעות"]
guess -.-> c2["שמירה רב-שלבית ואיחור ב-notification"]
fix["השולח מסמן סיום במפורש"] --> stable["יציב בלי ניחוש"]
איור 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
flowchart TB
accTitle: מה לעשות לפני שמגדילים buffer
accDescr: גם InternalBufferSize של 64KB לא עוזר אם הפרץ גדול יותר. קודם מצמצמים עם Filter ו-NotifyFilter, מקלים על ה-handler, ומכניסים full rescan ו-idempotency.
first["קודם מטפלים בזה"] --> f1["מצמצמים עם Filter ו-NotifyFilter"]
first --> f2["מקלים על ה-handler"]
first --> f3["full rescan ו-idempotency"]
buf["כוונון InternalBufferSize"] -.-> aux["נשאר עזר בסוף"]
איור 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 ישירות לעיבוד עסקי נפרד, קשה לראות תמונה. קודם מקפלים הכל לאות אחד: “לך לבדוק”.
flowchart LR
A["Created / Changed / Deleted / Renamed"] --> Q["scan request"]
B["Error / overflow"] --> Q
C["startup"] --> Q
Q --> D["rescan של התיקייה"]
D --> E["מונים מועמדים ready"]
E --> F["מנסים 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
flowchart TD
A["כותבים את כל התוכן ל-data.tmp"] --> B["flush / close"]
B --> C["rename / replace ל-data.csv"]
C --> D["מניחים data.done / manifest.json"]
D --> E["צד הקבלה מסתכל רק על שם final או done"]
איור 10: השולח כותב הכל ל-temp, עושה close, מפרסם ב-rename, ואם צריך מניח בסוף done / manifest.
זה אותו דבר כמו במאמר הקודם, וכאן זה באמת עובד.
FileSystemWatcher אינו כלי שממציא סיום. הוא כלי שמוצא מוקדם סיום שסומן במפורש.
4.3. צד הקבלה לוקח claim באופן אטומי
גם אם בסריקה נמצא מועמד ready, אם קוראים ישר כמה workers יכולים לתפוס במקביל. לכן לפני העיבוד לוקחים claim באופן אטומי.
sequenceDiagram
participant Scan as scanner
participant IN as incoming
participant P1 as processing/worker1
participant P2 as processing/worker2
Scan->>IN: מוצא את order-123
Scan->>P1: rename order-123
Scan->>P2: rename order-123
Note over P1,P2: רק מי שהצליח ראשון מחזיק ownership
איור 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 של עקביות.
flowchart TB
accTitle: מתי מכניסים full rescan
accDescr: בהפעלה, בקבלת Error, מיד אחרי יצירה מחדש של watcher, וכביטוח תקופתי. כך משחזרים שינויים שאירועים לא קלטו.
t1["בהפעלה"] --> fr["full rescan"]
t2["בקבלת Error"] --> fr
t3["מיד אחרי יצירה מחדש של watcher"] --> fr
t4["כביטוח תקופתי"] --> fr
fr --> heal["recovery של עקביות"]
איור 12: watcher הוא רמז להפרש, full rescan הוא recovery של עקביות. בארבעת הזמנים האלה מכניסים תמיד.
4.5. מניחים idempotency מראש
עם FileSystemWatcher בודקים את אותו יעד כמה פעמים.
זה לא באג. יציב יותר לקבל את זה כתכנון.
בפועל זה נראה כך.
- שמים
IdempotencyKeyב-manifest - אם כבר עובד, לא מריצים שוב side effects
- מאפשרים התאמה מול archive / רשומה ב-DB / כבר נשלח
- גם אחרי full rescan, זה רק “מסתכלים שוב בבטחה על אותו דבר”
לנסות לבנות exactly-once רק מאירועים נהיה קשה מאוד. חזק יותר בשטח לקבל at-least-once, ולסגור בסוף עם idempotency.
flowchart TB
accTitle: איך תופסים כפילות כהנחת תכנון
accDescr: מקבלים כתכנון שבודקים את אותו יעד כמה פעמים, מתאימים מול IdempotencyKey ב-manifest ולא מריצים שוב side effects. כך גם rescan נשאר בטוח.
multi["בודקים את אותו יעד כמה פעמים"] --> accept["מקבלים כתכנון"]
accept --> key["מתאימים מול IdempotencyKey"]
key --> safe["לא מריצים שוב side effects"]
safe --> strong["גם 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
flowchart TB
accTitle: הזרימה בכיוון הנכון
accDescr: מקפלים notifications ל-scan request, מוצאים מועמדים ready בסריקה, לוקחים claim, בודקים idempotency, ואז מעבדים, רושמים ומעבירים ל-archive. זו הזרימה שהפסאודו-קוד מתאר.
n["מקפלים notifications ל-scan request"] --> s["מוצאים ready בסריקה"]
s --> c["לוקחים claim"]
c --> i["בודקים idempotency"]
i --> p["מעבדים, רושמים, מעבירים ל-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 פשוט יותר למימוש.
flowchart TB
accTitle: ההבדל בין FileSystemWatcher ל-USN change journal
accDescr: FileSystemWatcher לא יודע על שינויים בזמן עצירה וממלא ב-full rescan. USN change journal נשאר בצד ה-volume, ואפשר לקרוא מחדש מ-USN קודם גם שינויים בזמן עצירה.
fsw["FileSystemWatcher"] -.-> gap["שינויים בזמן עצירה לא ידועים"]
gap --> fill["ממלאים ב-full rescan"]
usn["USN change journal"] --> keep["הרישום נשאר בצד ה-volume"]
keep --> resume["אפשר לקרוא מחדש מ-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. מקורות
- סט קוד הדוגמה המלא של המאמר (ספרייה, הדגמה, unit tests) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/filesystemwatcher-safe-basics
- מאמר קשור: Locking ב-file handoff: file lock ו-atomic claim בפועל
- FileSystemWatcher Class (System.IO)
- System.IO.FileSystemWatcher class - .NET
- FileSystemWatcher.InternalBufferSize Property (System.IO)
- FileSystemWatcher.NotifyFilter Property (System.IO)
- FileSystemWatcher.Error Event (System.IO)
- FileSystemWatcher.Created Event (System.IO)
- FileSystemWatcher.Changed Event (System.IO)
- FileSystemWatcher.Renamed Event (System.IO)
- Change Journals - Win32 apps
- Creating, Modifying, and Deleting a Change Journal - Win32 apps
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
למה להכניס Generic Host ו-BackgroundService לאפליקציית desktop ב-.NET
בכלי Windows ובאפליקציות long-running, איך להשתמש ב-Generic Host וב-BackgroundService כדי לרכז הפעלה, עיבוד תקופתי, shutdown, לוג, הגדרות...
למה arguments נשברים — כללי command-line arguments ב-Windows
Windows מעביר ל-CreateProcess מחרוזת אחת שהמקבל מפצל. מכסה את כללי CommandLineToArgvW, CRT ו-.NET, ArgumentList, ובניה ב-C++.
שיטות עבודה מומלצות ל-multithreading בפועל: מהדורת .NET — מה להחליט לפני שמוסיפים threads
כללי תכנון שמונעים מקוד multithreaded ב-.NET/C# לקרוס או להיתקע מדי פעם: להשתמש ב-Task במקום ליצור threads בעצמכם, לצמצם shared mutable s...
WMI/CIM מ-C# ומ-PowerShell — מדריך מעשי למידע חומרה, ניטור process ושאילתות remote
WMI/CIM הוא הדרך הסטנדרטית לשלוף serial number של מחשב, לנטר דיסק פנוי ולזהות process שהתחיל. המאמר מכסה CIM cmdlets כמו Get-CimInstance,...
Pitfalls באפליקציות serial communication — reconnect ותכנון log
Pitfalls שכדאי להימנע מהן באפליקציית serial לחיבור ציוד ולשליטה במכשירי מדידה: framing, timeout, RTS/CTS, DTR/RTS, reconnect ותכנון log, ...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
file handoff וכלי ניטור שמשתמשים ב-FileSystemWatcher עולים הרבה בשטח גם בתוך פיתוח אפליקציות Windows.
ייעוץ טכני וסקירת תכנון
אם רוצים ליישר כתכנון את הטיפול ב-miss, rescan וקביעת סיום, זה מתאים לייעוץ טכני ול-design review.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- מותר לקרוא קובץ באירוע 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.