Locking ב-file handoff: file lock ו-atomic claim בפועל
· עודכן בתאריך: · Go Komura · file handoff, locking, תכנון, פיתוח Windows
היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 7 Mar 2026)
- פרסום ראשון
לצטט את המאמר הזה(DOI: 10.5281/zenodo.22173298)
מאמר זה מאוחסן בארכיון Zenodo. להלן גם ה-DOI שתמיד מפנה לגרסה האחרונה וגם ה-DOI המקובע לגרסה שאתם קוראים.
Go Komura (2026). Locking ב-file handoff: file lock ו-atomic claim בפועל. KomuraSoft LLC. https://doi.org/10.5281/zenodo.22173298 https://comcomponent.com/he/blog/file-integration-locking-best-practices-komurasoft-style/
- DOI (הגרסה האחרונה)
- 10.5281/zenodo.22173298
- DOI (הגרסה הזו)
- 10.5281/zenodo.22173299
locking ב-file handoff כמעט תמיד הופך לבעיה בתיקייה משותפת, ב-batch לילי, ובהעברה בין processes נפרדים. מה שחוזר בחיפושים: האם file lock לבד מספיק, איך מונעים מכמה workers לתפוס את אותו קובץ, ואיך נמנעים מקובץ שעדיין נכתב.
המאמר עובר על locking ב-file handoff סביב file lock, atomic claim, temp -> rename ו-idempotency.
מונחים שכדאי ליישר קודם
בתחום הזה הרבה מילים נשארות באנגלית. אם המשמעות מעורפלת, הקריאה נתקעת. לכן נקבע כאן מה כל מונח אומר במאמר.
| מונח | המשמעות במאמר |
|---|---|
| atomic | פעולה שהמצב באמצע לא נראה מבחוץ. יש רק שתי אפשרויות: הצלחה, או שלא קרה כלום |
| claim | תפיסת זכות עיבוד: “אני מעבד את הקובץ הזה”. במאמר הכוונה בעיקר לצורה שבה רק מי שהצליח לעשות rename מ-incoming ל-processing/<worker>/ הופך לבעלים |
| atomic claim | ביצוע ה-claim בפעולה אחת. אם הבדיקה והתפיסה נפרדות, process אחר יכול להיכנס ביניהן (3.1) |
| lease | ownership עם תוקף. כותבים ב-lock file מי ועד מתי, כדי שכשהתוקף פג worker אחר יוכל לקחת (4.4) |
| stale | מצב של lock או claim שנשאר אחרי שהבעלים נפל באופן חריג. אם אי אפשר לקבוע אם הוא חי או מת, כולם נעצרים (2.3) |
| manifest | קובץ נפרד שמתאר את התוכן. שם קובץ, גודל, hash, מספר רשומות וכו’, לאימות בצד המקבל. קובץ done הוא הגרסה המינימלית (4.2) |
| idempotency | תכונה שעיבוד חוזר של אותו קלט לא משנה את התוצאה (4.5) |
| advisory lock | lock שעובד רק אם כל המשתתפים שומרים על ההסכם. ה-OS לא אוכף אותו, אז אפשר לכתוב תוכנית שמתעלמת וקוראת/כותבת בכל זאת. flock ב-Linux הוא מהסוג הזה |
| byte-range lock | lock על טווח מסוים, לא על כל הקובץ. LockFileEx ב-Windows הוא הדוגמה הבולטת, וה-OS אוכף אותו. יש חריג (3.5) |
ב-diagram, solid line מציינת relation שתמיד מתקיים ו-dashed line מציינת relation מותנה (התנאים מופיעים בהסבר של כל relation ב-detail page). הרשימה המלאה של ה-relations (סה”כ 29, כולל evidence ו-certainty) וההגדרות של ה-concepts המרכזיים נמצאות ב-detail page של ה-knowledge map (ביפנית). Data: JSON-LD / Turtle
תוכן עניינים
- המסקנה בקצרה
- דפוסי התנגשות ב-file handoff (תרשים)
- 2.1. קוראים קובץ שעדיין נכתב
- 2.2. כמה workers תופסים את אותו קובץ במקביל
- 2.3. כולם נעצרים בגלל stale lock
- Anti-patterns
- 3.1. בדיקה דו-שלבית
Exists -> Create - 3.2. כתיבה ישירה לשם הסופי
- 3.3. גודל הקובץ נעצר, אז מתייחסים לזה כסיום
- 3.4. כולם מעדכנים קובץ משותף
- 3.5. חושבים ש-lock API הוא פתרון-על
- 3.1. בדיקה דו-שלבית
- שיטות עבודה מומלצות
- 4.1. מפרסמים בסדר
temp -> close -> rename / replace - 4.2. מציינים שלמות במפורש עם
done/ manifest - 4.3. צד הקבלה לוקח claim באופן אטומי
- 4.4. אם מסתמכים על lock file, הופכים אותו ל-lease
- 4.5. מניחים idempotency מראש
- 4.1. מפרסמים בסדר
- פסאודו-קוד (קטעים)
- חלוקה גסה לפי מצב
- סיכום
- מקורות
file handoff הוא תחום שבו “הסכם המסירה” נשבר בקלות רבה יותר מהקוד עצמו. זה עובר ב-unit tests, אבל נשבר מדי פעם דווקא בתיקייה המשותפת בפרודקשן או ב-batch הלילי. וקשה לשחזר. זה די שכיח.
רוב הגורמים אינם ב-API של file I/O עצמו, אלא בכך ששלושת הדברים האלה מעורפלים:
- מתי מותר לקרוא
- מי מחזיק בזכות העיבוד
- איך מתאוששים כשיש כישלון
המאמר לא נעצר בסיפור של OS lock. הוא מסדר locking ב-file handoff כפרוטוקול מסירה.
הקוד שמופיע כאן מפורסם ב-GitHub כסט דוגמאות שאפשר לבנות ולהריץ: ספרייה, הדגמה של תחרות claim בין שני workers ותפיסת lease, ו-unit tests שמשחזרים תחרות, נתונים פגומים ו-stale lock.
file-integration-locking-best-practices-komurasoft-style - komurasoft-blog-samples (GitHub)
1. המסקנה בקצרה
- הדבר הכי חשוב ב-file handoff הוא ליצור מצב שבו ברגע ששם הקובץ הסופי נראה, כבר מותר לקרוא
- לייצג בהפרדה, דרך שם קובץ או תיקייה, את המצבים: ביצירה / פורסם / בעיבוד / עובד
- אם יש כמה workers, לקחת claim באופן אטומי לפני הקריאה
- להשתמש ב-lock file וב-OS lock כעזר, ובסוף לתפוס כפילות עם idempotency
במילים אחרות, ב-file handoff הגוף הוא לא “locking” אלא תכנון פרוטוקול המסירה. זה לא נגמר בקריאה אחת לפונקציית lock.
2. דפוסי התנגשות ב-file handoff (תרשים)
2.1. קוראים קובץ שעדיין נכתב
אם מתחילים לכתוב ישירות לשם הסופי, קורית התקלה הזו. ב-JSON חסר סוגר, ב-CSV חסרות שורות, וב-ZIP הקובץ פשוט פגום.
sequenceDiagram
accTitle: קריאה של קובץ שעדיין נכתב
accDescr: הצד השולח יוצר את הקובץ ישירות בשם הסופי וכותב בהדרגה, בעוד צד הקבלה מזהה ומתחיל לקרוא באמצע. התוצאה: שורות חסרות או parse שנכשל.
participant 送信 as השולח
participant 共有 as תיקייה משותפת
participant 受信 as המקבל
送信->>共有: יוצר את orders.csv בשם הסופי
送信->>共有: כותב שורות 1 עד 5000
受信->>共有: מזהה את orders.csv
受信->>共有: מתחיל לקרוא כפי שהוא
Note over 受信: עדיין באמצע
送信->>共有: כותב את השאר
Note over 受信: חוסר שורות / parse שנכשל / עיבוד חלקי
2.2. כמה workers תופסים את אותו קובץ במקביל
בזרימה של “מסתכלים ברשימה, ואם לא עובד פותחים”, שני workers יכולים לתפוס את אותו קובץ. זו ההתחלה של חיוב כפול או שליחה כפולה.
sequenceDiagram
accTitle: שני workers תופסים את אותו קובץ
accDescr: שני workers מוצאים את אותו קובץ ב-incoming ומתחילים לקרוא במקביל, ולכן אותו קלט מעובד פעמיים.
participant W1 as worker 1
participant W2 as worker 2
participant Dir as incoming
W1->>Dir: מוצא את a.csv
W2->>Dir: מוצא את a.csv
W1->>Dir: מתחיל לקרוא
W2->>Dir: מתחיל לקרוא
Note over W1,W2: אותו קלט מעובד פעמיים
2.3. כולם נעצרים בגלל stale lock
תכנון שרק מניח lock file נוטה להיתקע בסיום חריג. אם לא ברור של מי ה-lock, האם הוא עדיין חי, ועד מתי הוא תקף — מי שממתין אחריו ימתין לנצח.
sequenceDiagram
accTitle: כולם נעצרים כי אי אפשר לקבוע אם ה-lock עדיין תקף
accDescr: worker A יוצר lock ונופל באופן חריג. worker B מוצא את הקובץ, נמנע מלהתחיל, וממשיך להמתין כי אי אפשר לקבוע אם זה stale.
participant A as worker A
participant Lock as lock file
participant B as worker B
A->>Lock: יוצר lock
Note over A: נופל כאן באופן חריג
B->>Lock: בודק שה-lock קיים
B->>Lock: נמנע מלהתחיל לעבד
B->>Lock: ממשיך להמתין
Note over B,Lock: אי אפשר לקבוע אם זה stale, וכולם נעצרים
3. Anti-patterns
3.1. בדיקה דו-שלבית Exists -> Create
הבעיה: הבדיקה והתפיסה הן שתי פעולות נפרדות. process אחר יכול להיכנס ביניהן, ולכן זה לא נותן exclusive access.
sequenceDiagram
accTitle: בדיקה דו-שלבית מאפשרת לשני processes להתקדם
accDescr: process A ו-process B בודקים שניהם שאין lock, שניהם מקבלים שאין, ולכן שניהם יוצרים lock ומתקדמים במקביל.
participant A as process A
participant B as process B
participant FS as file system
A->>FS: בודק שאין lock
B->>FS: בודק שאין lock
FS-->>A: אין
FS-->>B: אין
A->>FS: יוצר lock
B->>FS: יוצר lock
Note over A,B: שניהם מצליחים להתקדם
דוגמה טיפוסית לא טובה נראית כך.
if (!File.Exists(lockPath))
{
File.WriteAllText(lockPath, Environment.ProcessId.ToString());
ProcessFile();
}
מה שנדרש הוא להפוך את “אם אין — ליצור” לפעולה אחת.
ב-.NET משתמשים במשפחת FileMode.CreateNew, ובמערכות POSIX ביצירה אטומית כמו O_CREAT | O_EXCL.
3.2. כתיבה ישירה לשם הסופי
אם צד הקבלה מפרש “אם השם הזה נראה, מותר לקרוא”, ברגע שמתחילים לכתוב ישירות לשם הסופי — כבר הפסדתם. העיקרון: לא לזהות בין “נראה” לבין “מותר לקרוא”.
flowchart LR
accTitle: הכשל שנגרם מכתיבה ישירה לשם הסופי
accDescr: ברגע ששם final נראה, צד הקבלה מזהה אותו בעוד השולח עדיין כותב, ולכן נקראים נתונים לא שלמים.
A["שם final נראה"] --> B["צד הקבלה מזהה"]
B --> C["השולח עדיין כותב"]
C --> D["נקראים נתונים לא שלמים"]
using var writer = OpenForWrite(finalPath); // כאן finalPath כבר נראה
foreach (var row in rows)
{
writer.WriteLine(row);
}
השיטה הזו מזמינה בעצמה את התקלה מסעיף 2.1.
3.3. גודל הקובץ נעצר, אז מתייחסים לזה כסיום
זה נראה נוח, אבל זה די מסוכן. זה מתנדנד באופן שגרתי בגלל העתקה דרך רשת, השהיה זמנית בצד השולח, buffering ו-retry.
sequenceDiagram
accTitle: זיהוי שגוי של סיום לפי התייצבות גודל הקובץ
accDescr: השולח מתחיל להעתיק ומשתהה באמצע. המקבל רואה שהגודל לא השתנה 10 שניות, מזהה בטעות כסיום, ומתחיל לקרוא לפני שההעתקה הסתיימה.
participant 送信 as השולח
participant 共有 as תיקייה משותפת
participant 受信 as המקבל
送信->>共有: מתחיל להעתיק את data.zip
送信->>共有: משתהה זמנית באמצע
受信->>共有: הגודל לא משתנה 10 שניות
Note over 受信: מזהה בטעות כסיום
受信->>共有: מתחיל לקרוא
送信->>共有: ממשיך בהעתקה
if (currentLength == lastLength && stableSeconds >= 10)
{
return Ready;
}
אם קובעים סיום על סמך ניחוש, זה יכול להכשיל בתיקייה משותפת או בקבצים גדולים. יציב יותר לציין סיום במפורש עם manifest או קובץ done.
3.4. כולם מעדכנים קובץ משותף
תכנון שבו כולם קוראים ומעדכנים status.csv או counter.json יחיד, בדרך כלל מסתיים בכך שמי שכתב אחרון מנצח.
כשמתחילים להשתמש ב-file handoff כמסד נתונים פשוט, כאן זה נהיה קשה.
sequenceDiagram
accTitle: עדכון הדדי בקובץ משותף מאבד עדכון
accDescr: batch A ו-batch B שתיהן קוראות את אותה גרסה v1 של status.csv וכל אחת כותבת גרסה משלה. גרסה B נכתבת אחרונה ומוחקת את העדכון של A.
participant A as batch A
participant B as batch B
participant F as status.csv
A->>F: קורא v1
B->>F: קורא v1
A->>F: כותב v2-A
B->>F: כותב v2-B
Note over F: העדכון של A נעלם
יש גם רעיון לברוח ל-append-only, אבל המשמעות שלו משתנה לפי file system ואופן הפריסה. אם נדרש עדכון משותף, עדיף לא להתאמץ יתר על המידה עם file handoff כאן.
3.5. חושבים ש-lock API הוא פתרון-על
lock API חשוב, אבל הוא יעיל רק כשכל המשתתפים פועלים לפי אותו הסכם. בשילוב בין מערכות שונות, בטוח יותר לא לסמוך עליו יתר על המידה.
הערה:
flockב-Linux הוא advisory lock, ולכן אפשר בקלות לכתוב צד שמתעלם מההסכם- byte-range lock ב-Windows מתעלמים ממנו כשמדובר ב-memory-mapped file
- כלומר, עדיף לא להטיל על OS lock לבדו גם את התכנון של הודעת סיום ו-ownership
הנקודה השנייה מפורשת במפרט של Windows. ב-Locking and Unlocking Byte Ranges in Files של Microsoft Learn, מיד אחרי המשפט שגישה של process אחר לטווח נעול תמיד תיכשל (כלומר נעילת הטווח ב-Windows אינה advisory אלא נאכפת), מופיעה הערה שכאשר משתמשים ב-memory-mapped file, ה-byte-range lock מתעלמים ממנה. אם הצד השני נוגע באותו קובץ דרך CreateFileMapping, ה-lock שלכם פשוט לא רלוונטי עבורו.
אם רוצים לתפוס range lock ב-.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);
}
הצורה הזו יעילה בין אפליקציות שפועלות לפי אותו הסכם. אבל כפי שראינו, היא לא יעילה אם הצד השני ניגש דרך memory mapping, ואין גם ערובה שמערכת אחרת תסתכל בכלל על התג הזה. לכן פרוטוקול המסירה של פרק 4 הוא הגוף.
4. שיטות עבודה מומלצות
קודם, ההתאמה ל-anti-patterns בפרק 3. אם יש תחושה שאתם עושים אחד מהם, אפשר לקרוא ישר את הסעיף המתאים.
| Anti-pattern | מה קורה | הפתרון המתאים |
|---|---|---|
3.1. בדיקה דו-שלבית Exists -> Create |
process אחר נכנס בין הבדיקה לתפיסה, ושני processes מתקדמים במקביל | 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. חושבים ש-lock API הוא פתרון-על | נשבר מול צד שלא שומר על ההסכם או ניגש דרך memory mapping | 4.4 lock file בתור lease, ו-4.5 idempotency שתופס את זה |
4.1. מפרסמים בסדר temp -> close -> rename / replace
זו הדרך הסטנדרטית. בזמן היצירה הקובץ נשאר תחת שם temp. אחרי close מחליפים לשם final. צד הקבלה מסתכל רק על שם final.
flowchart LR
accTitle: חמשת השלבים של פרסום בטוח
accDescr: יצירת שם temp ייחודי, כתיבת כל התוכן ל-temp, flush/close, rename/replace לשם final באותה תיקייה, וצד הקבלה עוקב רק אחרי שם final.
A["יוצרים שם temp ייחודי"] --> B["כותבים את כל התוכן ל-temp"]
B --> C["עושים flush / close"]
C --> D["rename / replace לשם final באותה תיקייה"]
D --> E["צד הקבלה עוקב רק אחרי שם final"]
נקודות מרכזיות:
- temp ו-final נמצאים באותה תיקייה, לפחות באותו volume / file system
- ב-Windows / .NET אפשר לשקול את משפחת
File.Replace - ההסכם: ברגע ששם final נראה, התוכן כבר הושלם
אם temp נמצא בכונן אחר, ה-rename עלול להיות שקול להעתקה בלבד, או ש-Replace עלול להיכשל.
ההנחה הזו נראית שקטה, אבל היא חשובה מאוד.
דרך תיקייה משותפת (SMB), עוד ארבע נקודות מתנדנדות. זה בדיוק שדה הקרב המרכזי של המאמר, ולכן הן בנפרד.
- rename בתוך אותה תיקייה של אותו share רץ בצד השרת. לכן התכונה “לא נראה שם ביניים” נשמרת. לעומת זאת, כשחוצים shares, כמו מ-
\\server\shareAל-\\server\shareB, זה נחשב ל-volume נפרד. כש-MoveFileExב-Windows מקבלMOVEFILE_COPY_ALLOWED, הוא מחליף את המעבר בהעתקה ומחיקה. כלומר זה מפסיק להיות atomic, ונראה מצב ביניים. ההנחה שממקמים את temp ו-final, ואתincomingו-processing, בתוך אותו share, חשובה עוד יותר מאשר במצב מקומי - rename נכשל רק כי מישהו פתח את הקובץ. לתיקייה משותפת ניגשים גורמים שלא הכרתם: antivirus, search indexer, או לקוחות מסניפים אחרים. מעשי להתייחס לכישלון rename של publish ו-claim לא כחריגה אלא כענף רגיל, עם המתנה קצרה ו-retry
- timestamp לא יכול לשמש כלי החלטה. ב-File Times של Microsoft Learn כתוב שמה שמובטח לגבי זמני קובץ הוא רק שהוא ישקף נכון את השינוי בזמן שסוגרים את ה-handle שביצע אותו. זמן העדכון האחרון בזמן כתיבה לא מתעדכן במלואו עד שכל ה-handles לכתיבה נסגרים. גם הגרנולריות תלויה ב-file system: זמן העדכון האחרון ב-FAT מתעדכן ביחידות של 2 שניות, וזמן הגישה האחרון ב-NTFS מתעדכן באיחור של עד שעה. בנוסף, דרך SMB הזמן מסומן לפי שעון השרת. אם יש הפרש בין שעון הלקוח לשרת, גם קביעה כמו “לעבד N דקות אחרי העדכון” תתעוות בהתאם. זו הסיבה שקביעת סיום לא נעשית לפי זמן או גודל, אלא עם
done/ manifest מסעיף 4.2 - גם change notifications מפספסות. לא כדאי להסתמך רק על התראות אירועים לניטור תיקייה משותפת. שילוב עם סריקת ספרייה מחזורית מייצב. הנושא מסוכם ב-FileSystemWatcher בפועל: miss ו-duplicate
4.2. מציינים שלמות במפורש עם done / manifest
לא רק גוף הנתונים. אם מציינים במפורש “מה הושלם” בקובץ נפרד, צד הקבלה נהיה יציב יותר. זה יעיל במיוחד בשילוב בין מערכות שונות.
flowchart TD
accTitle: זרימת פרסום עם done ומעקב manifest
accDescr: יוצרים data.tmp, מפרסמים כ-data.csv, יוצרים data.done או manifest.json, צד הקבלה מזהה את ה-done או ה-manifest, ומאמת שם קובץ, גודל ו-hash.
A["יוצרים את data.tmp"] --> B["מפרסמים כ-data.csv"]
B --> C["יוצרים data.done / manifest.json"]
C --> D["צד הקבלה מזהה done / manifest"]
D --> E["מאמתים שם קובץ, גודל ו-hash"]
הפריטים שכדאי לכלול ב-manifest הם בערך אלה.
- שם הקובץ הרלוונטי
- גודל
- hash
- מספר רשומות
- מזהה העברה / idempotency key
- זמן יצירה
גם הסדר חשוב.
אם done מונח לפני פרסום גוף הנתונים, זו לא הודעת סיום אלא אזהרה מוקדמת על תקלה.
4.3. צד הקבלה לוקח claim באופן אטומי
אם כמה workers מסתכלים על אותו incoming, קל להבין את הגישה “להעביר לעצמך לפני שקוראים”.
רק ה-worker שהצליח לעשות rename מ-incoming ל-processing/<worker>/ מעבד.
sequenceDiagram
accTitle: רק ה-worker שמצליח ב-rename תופס ownership
accDescr: שני workers מוצאים את אותו קובץ ומנסים rename ל-processing. רק מי שמצליח ראשון תופס ownership על העיבוד.
participant W1 as worker 1
participant W2 as worker 2
participant IN as incoming
participant PR as processing
W1->>IN: מוצא את a.csv
W2->>IN: מוצא את a.csv
W1->>PR: עושה rename ל-a.csv
W2->>PR: עושה rename ל-a.csv
Note over W1,W2: רק מי שהצליח ראשון תופס ownership
מבחינה תפעולית, קל יותר לעקוב אם גם מפרידים תיקיות.
flowchart LR
accTitle: מסלול התיקיות מ-temp עד archive או error
accDescr: קובץ עובר מ-temp דרך פרסום ל-incoming, משם דרך claim ל-processing, ולבסוף בהצלחה ל-archive או בכישלון ל-error.
T["temp"] -->|"פרסום"| I["incoming"]
I -->|"claim"| P["processing"]
P -->|"הצלחה"| A["archive"]
P -->|"כישלון"| E["error"]
גם ה-rename עבור ה-claim מניחים שהוא רץ על אותו file system.
4.4. אם מסתמכים על lock file, הופכים אותו ל-lease
אם משתמשים ב-lock file, כדאי שהוא יהיה מידע ownership עם תוקף, ולא רק קובץ ריק. lock שלא ברור מי תפס אותו, בהכרח יגרום לבעיות בהמשך.
flowchart TD
accTitle: השדות של קובץ ה-lease ב-lock.json
accDescr: lock.json מכיל שישה שדות: ownerId, host, pid, acquiredAt, expiresAt ו-heartbeatAt.
L["lock.json"] --> A["ownerId"]
L --> B["host"]
L --> C["pid"]
L --> D["acquiredAt"]
L --> E["expiresAt"]
L --> F["heartbeatAt"]
נקודות מרכזיות:
- היצירה אטומית
- הפסקת עדכונים משמשת כלי לקביעת stale
- המחיקה, כעיקרון, רק על ידי מי שיצר
- קובעים מראש נוהל recovery, בהנחה שיש מקרים שלא משחררים
lock file הוא בסך הכול תג לצורך תיאום. אם מנסים להבטיח באמצעותו לבדו שלמות מלאה, זה בדרך כלל נהיה קשה.
4.5. מניחים idempotency מראש
locking חשוב, אבל בתפעול בפועל אי אפשר לאפס לגמרי מצבים כמו “מדי פעם מגיע כפול” או “רצים שוב באמצע”. בסוף, תכנון שלא נשבר גם כשאותו קלט נכנס שוב עוזר.
flowchart LR
accTitle: תהליך שמניח idempotency
accDescr: קלט עם idempotency key נבדק אם כבר עובד. אם כן מטופל כהצלחה בלי ריצה כפולה. אם לא, מתבצע העיבוד ונרשם ביומן העיבודים.
A["קלט + idempotency key"] --> B{"כבר עובד?"}
B -->|"כן"| C["מתייחסים כהצלחה בלי ריצה כפולה"]
B -->|"לא"| D["מבצעים את העיבוד"]
D --> E["רושמים ביומן העיבודים"]
למשל, נותנים לכל קובץ נכנס מזהה העברה, ורושמים אותו ביומן העיבודים. אם מכינים מראש מבנה שבו גם אם ה-locking נשבר פעם אחת התוצאה לא נספרת פעמיים, התפעול נהיה קל בהרבה.
5. פסאודו-קוד (קטעים)
MakeTempPathSameDirectory ו-TryClaimBundleByRename שמופיעים כאן הם שמות פונקציה בדיוניים שמוצגים כדי להראות את הסדר. המימוש שבאמת רץ נמצא בסט הדוגמאות מהפתיחה.
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); // בהנחת אותו file system / אותו volume
PublishDoneFile(finalPath + ".done", new
{
FileName = Path.GetFileName(finalPath),
Size = GetFileSize(finalPath),
Hash = ComputeHash(finalPath),
IdempotencyKey = integrationId
});
if (!TryClaimBundleByRename(baseName, incomingDir, processingDir))
{
return; // worker אחר תפס קודם
}
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);
כאן הסדר חשוב יותר מפרטי המימוש. כשלא מערבבים “לכתוב”, “לפרסם”, “לתפוס ownership” ו”לתעד עיבוד” — זה נשבר פחות.
6. חלוקה גסה לפי מצב
- עם writer יחיד, reader יחיד ואותה מכונה, גם
temp -> renameבלבד כבר יציב למדי - אם יש כמה consumers, מוסיפים claim rename מ-
incomingל-processing - בשילוב בין מערכות שונות, NAS או תיקייה משותפת, בטוח יותר להוסיף גם manifest / done וגם idempotency
- אם כמה writers רוצים לעדכן את אותו מצב לוגי, כדאי לשקול DB או queue, ולא להתאמץ יתר על המידה עם file handoff
- OS lock יעיל בתוך אותה קבוצת אפליקציות ואותן הנחות, אבל הוא לא תחליף לפרוטוקול מסירה
הפריט האחרון הוא גם החלטת נסיגה. באמת יש בעיות שקשה לפתור עם קבצים.
7. סיכום
locking ב-file handoff הוא לא קריאה לפונקציית lock, אלא קביעת מעברי מצב. זה עצם הרעיון של המאמר. מייצגים בשם או בתיקייה את המצבים ביצירה / פורסם / בעיבוד / עובד, ונמנעים מבדיקה דו-שלבית Exists -> Create, מכתיבה ישירה לשם הסופי, מהמתנה להתייצבות גודל, מעדכון הדדי של קובץ משותף, ומהסתמכות יתר על lock API. מעבר לכך, שילוב של temp -> close -> rename / replace, done / manifest, claim rename, lease ו-idempotency מונע תקלות רבות ב-file handoff דרך תיקייה משותפת.
הטריק ב-file handoff הוא לא לזהות בין “אפשר לקרוא” לבין “מותר לקרוא”. רק ההפרדה הזו מקטינה משמעותית תקלות מהסוג שמופיע רק בלילה.
8. מקורות
- סט קוד הדוגמה המלא של המאמר (ספרייה, הדגמה, unit tests) - komurasoft-blog-samples (GitHub)
- LockFileEx function (Win32)
- Locking and Unlocking Byte Ranges in Files (Win32)
- Moving and Replacing Files (Win32)
- MoveFileEx function (Win32)
- File Times (Win32)
- FileStream.Lock Method (.NET)
- File.Replace Method (.NET)
- rename — POSIX
- open — POSIX (
O_CREAT | O_EXCL) - flock(2) — Linux manual page
- open(2) — Linux manual page
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
FileSystemWatcher בפועל: miss ו-duplicate
איך משתמשים ב-FileSystemWatcher בזהירות: miss, duplicate, מלכודות בקביעת סיום, rescan, atomic claim ו-idempotency.
למה Sleep(1) ב-Windows לא מדויק, ולמה עדיף event wait
ב-Windows דיוק של timed wait קצר תלוי ב-system clock resolution ובתזמון. כשמחכים להגעת עבודה, השלמת I/O או בקשת עצירה, עדיף event-driven ...
Exception לא צפוי ב-.NET: מתי מסיימים תהליך ומתי ממשיכים
מתי מסיימים אפליקציית Windows אחרי exception לא צפוי ומתי אפשר להמשיך: לפי state corruption, side effect חיצוני, threads וגבול native.
למה להכניס Generic Host ו-BackgroundService לאפליקציית desktop ב-.NET
בכלי Windows ובאפליקציות long-running, איך להשתמש ב-Generic Host וב-BackgroundService כדי לרכז הפעלה, עיבוד תקופתי, shutdown, לוג, הגדרות...
הרשת עובדת אבל Windows מציג "אין אינטרנט" — לבודד NCSI, DNS, Proxy ו-VPN ב-Windows
למה Windows מציג "אין אינטרנט" בזמן שהרשת עובדת — נקודת המוצא היא קביעת הקישוריות של NCSI. כאן מפרידים בין DNS, proxy, VPN ו-captive port...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
בפיתוח אפליקציות Windows שכולל file handoff בתיקייה משותפת ו-batch לילי, תכנון ה-locking משפיע ישירות על איכות המימוש.
ייעוץ טכני וסקירת תכנון
אם רוצים ליישר קודם את חלוקת האחריות בין lock, atomic claim ו-idempotency, זה מתאים לייעוץ טכני ול-design review.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- האם locking ב-file handoff מספיק עם lock API בלבד?
- לרוב לא. flock ב-Linux הוא advisory lock, אז אפשר לכתוב צד שפשוט מתעלם מההסכם. byte-range lock ב-Windows מתעלמים ממנו על memory-mapped file. OS lock עובד בתוך אותה קבוצת אפליקציות ואותן הנחות, אבל כעזר. הגוף הוא פרוטוקול המסירה: temp -> rename, done/manifest, atomic claim ו-idempotency.
- איך מונעים קריאה של קובץ שעדיין נכתב?
- הדרך הסטנדרטית היא לפרסם בסדר temp -> close -> rename/replace. בזמן היצירה הקובץ נשאר תחת שם temp. אחרי close מחליפים לשם final באותה תיקייה, וצד הקבלה מסתכל רק על השם הסופי. temp ו-final חייבים להיות באותה תיקייה, לפחות באותו volume / file system. ההסכם: ברגע שהשם הסופי נראה, התוכן כבר הושלם.
- איך מונעים מכמה workers לעבד את אותו קובץ במקביל?
- לוקחים claim באופן אטומי לפני הקריאה. בפועל, רק ה-worker שהצליח לעשות rename מ-incoming ל-processing/<worker>/ הוא זה שמעבד. בדיקה דו-שלבית Exists ואז Create לא נותנת exclusive access, כי הבדיקה והתפיסה הן שתי פעולות נפרדות, ותהליך אחר יכול להיכנס ביניהן. אם צריך יצירה אטומית, משתמשים ב-FileMode.CreateNew של .NET או ב-O_CREAT | O_EXCL של POSIX.
- על מה לשים לב כשמשתמשים ב-lock file?
- לא קובץ ריק. עושים ממנו lease עם תוקף: ownerId, host, pid, acquiredAt, expiresAt ו-heartbeatAt. היצירה אטומית. הפסקת עדכונים משמשת לזיהוי stale. מחיקה כעיקרון רק על ידי מי שיצר. מכינים מראש recovery בהנחה שיש מקרים שלא משחררים. בפועל עדיף לא לנסות להבטיח שלמות מלאה עם lock file אחד, אלא לתכנן כך שבסוף idempotency תופס גם כפילות.