पाच सेकंद आधी व्यवस्थित काम करणारी कॉन्फिग फाईल आता ३४७-बाइटचा तुटलेला JSON तुकडा बनली आहे. तुमचे CLI टूल सुरू होणार नाही. अपडेट अपेक्षित वेळेपेक्षा जास्त वेळ घेत असल्यामुळे ज्या वापरकर्त्याने फक्त Ctrl-C दाबले होते, तो आता न मागितलेल्या एरर स्टॅक ट्रेसकडे (error stack trace) पाहत बसला आहे. लिहिण्यापूर्वी अस्तित्वात असलेली दोन किलोबाइटची वैध कॉन्फिगरेशन आता गायब झाली आहे आणि त्याऐवजी अर्धवट छापलेल्या पावतीसारखा डिजिटल कचरा उरला आहे.

हे घडते कारण writeFile अ‍ॅटॉमिक (atomic) नाही. ते अस्तित्वात असलेला पाथ (path) उघडते, तो ट्रंकेट (truncate) करते, Node कडून डेटा कर्नलच्या पेज कॅशेमध्ये (kernel’s page cache) स्ट्रीम करते आणि शेवटी फाईल डिस्क्रिप्टर (file descriptor) बंद करते. ज्या क्षणी ट्रंकेशन होते, त्याच क्षणी जुना मजकूर निघून जातो. त्या ट्रंकेशनपासून ते अंतिम close पर्यंतचा काळ हा असुरक्षिततेचा काळ (window of vulnerability) असतो. त्या काळात SIGINT, वीज पुरवठा खंडित होणे किंवा लॅपटॉपचे झाकण अचानक बंद होणे, यामुळे फाईलसिस्टममध्ये फक्त एक अर्धवट आणि खराब झालेली फाईल उरते. जरी Node ने Promise 'resolved' असल्याचे सूचित केले, तरी ऑपरेटिंग सिस्टम अजूनही मेमरीमध्ये राइट्स (writes) बफर करत असू शकते. सोयीस्कर पद्धती (convenience methods) हा फरक लपवतात, पण ते दूर करत नाहीत.

उपाय म्हणजे त्याच ठिकाणी लिहिणे (write in place) हा नाही. उपाय म्हणजे लिहिण्याची क्रिया आणि ती प्रकाशित करण्याची क्रिया वेगळी करणे हा आहे.

एका शेजारील फाईलमध्ये लिहा आणि नंतर ती बदला (swap करा)

या विश्वसनीय पद्धतीमध्ये पाच पायऱ्या आहेत. त्यातील एकही पायरी गुंतागुंतीची नाही, परंतु एकत्रितपणे त्या 'फेल्युअर विंडो' (failure window) संपूर्ण स्ट्रीमिंग राईटपासून केवळ एका सिंगल फाईलसिस्टम मेटाडेटा ऑपरेशनपर्यंत कमी करतात.

पहिले, संपूर्ण पेलोड (payload) मेमरीमध्ये सिरीयलाईझ (serialize) करा. कोणतेही तात्पुरते फाईल तयार करण्यापूर्वी हे करा. जर कोणी सर्क्युलर ऑब्जेक्ट (circular object) पास केल्यामुळे JSON.stringify एरर देत असेल, तर डिस्कला स्पर्श करण्यापूर्वीच तुम्हाला ती एक्सेप्शन (exception) वर येण्यास हवी.

दुसरे, सिरीयलाईझ केलेला डेटा टार्गेटच्या (target) त्याच डिरेक्टरीमध्ये असलेल्या तात्पुरत्या फाईलमध्ये लिहा. दोन एकाच वेळी चालणाऱ्या प्रक्रियांमध्ये (concurrent runs) संघर्ष होऊ नये म्हणून रँडम नाव वापरा. तात्पुरती फाईल त्याच डिरेक्टरीमध्ये ठेवणे महत्त्वाचे आहे कारण rename केवळ एकाच फाईलसिस्टममध्ये अ‍ॅटॉमिक असते. जर तुमची तात्पुरती फाईल दुसऱ्या पार्टीशनवर असेल, तर ऑपरेटिंग सिस्टम 'कॉपी-अँड-डिलीट' (copy-and-delete) क्रियेचा वापर करते, ज्यामुळे नवीन त्रुटी निर्माण होऊ शकतात आणि ती प्रक्रिया अ‍ॅटॉमिक राहत नाही.

तिसरे, कर्नलला ती तात्पुरती फाईल फिजिकल स्टोरेजमध्ये फ्लश (flush) करण्यास सांगा. Node चे fsync, जे येथे फाईलहँडलवरील (filehandle) sync मेथड म्हणून उपलब्ध आहे, जोपर्यंत बफर्स प्रत्यक्ष हार्डवेअरवर उतरत नाहीत तोपर्यंत थांबते (blocks). हे संथ आहे, परंतु कॉन्फिग राइट्स इतक्या कमी वेळा घडतात की त्यातील टिकाऊपणासाठी (durability) काही मिलीसेकंद खर्च करणे योग्य आहे.

चौथे, मूळ पाथवर (original path) तात्पुरत्या फाईलचे नाव बदला (rename). POSIX सिस्टम आणि Windows दोन्हीवर, हा 'कमिट पॉइंट' (commit point) आहे. मूळ पाथ उघडणारे रीडर्सना एकतर पूर्ण जुनी फाईल दिसेल किंवा पूर्ण नवीन फाईल दिसेल. असा कोणताही क्षण नसतो जेव्हा एखादा रीडर पाथ उघडून अर्धवट लिहिलेला बफर पाहू शकेल.

पाचवे, पेरेंट डिरेक्टरी सिंक (sync) करा. हे एका सूक्ष्म edge case ला हाताळते. rename मुळे डिरेक्टरी एंट्री अपडेट होते, परंतु डिरेक्टरी मेटाडेटा स्वतः कर्नलच्या पेज कॅशेमध्ये असू शकतो. यशस्वी rename नंतर अचानक वीज गेल्यास, फाईलसिस्टम अशा स्थितीत राहू शकते जिथे नवीन inode संदर्भ कायमस्वरूपी नोंदवला गेला नसेल. डिरेक्टरी सिंक केल्यामुळे तो मेटाडेटा अपडेट डिस्कवर लिहिला जातो आणि व्यवहार (transaction) पूर्ण होतो.

एक प्रत्यक्ष Node.js अंमलबजावणी (implementation)

केवळ Node.js स्टँडर्ड लायब्ररी वापरून ही पद्धत प्रत्यक्ष कशी दिसते ते खाली दिले आहे:

import { open, rename, rm } from "node:fs/promises";
import { dirname, basename, join } from "node:path";
import { randomUUID } from "node:crypto";

export async function writeJsonAtomic(path, value) {
  const directory = dirname(path);
  const temporary = join(directory, `.${basename(path)}.${randomUUID()}.tmp`);
  const body = `${JSON.stringify(value, null, 2)}\n`;
  let handle;

  try {
    handle = await open(temporary, "wx", 0o600);
    await handle.writeFile(body, "utf8");
    await handle.sync();
    await handle.close();
    handle = undefined;

    await rename(temporary, path);

    const directoryHandle = await open(directory, "r");
    try {
      await directoryHandle.sync();
    } finally {
      await directoryHandle.close();
    }
  } catch (error) {
    if (handle) await handle.close().catch(() => {});
    await rm(temporary, { force: true }).catch(() => {});
    throw error;
  }
}

या कोडमधील काही तपशील लक्षणीय आहेत.

wx फ्लॅगचा अर्थ आहे "लिहा, पण जर फाईल आधीच अस्तित्वात असेल तर अयशस्वी व्हा." हे UUID कोलिजन (collision) किंवा मागील क्रॅश झालेल्या प्रक्रियेमुळे राहिलेल्या तात्पुरत्या फाईलपासून संरक्षण करते. जर कोणी तुमच्या तात्पुरत्या फाईलच्या जागी एखादी घातक (malicious) फाईल ठेवली असेल, तर तिथे असलेल्या गोष्टीवर ओव्हरराईट करण्याऐवजी तुम्हाला लगेच त्याबद्दल समजेल.

0o600 परमिशन मास्क तात्पुरती फाईल केवळ 'ओनर-रीड' (owner-read) आणि 'ओनर-राईट' (owner-write) परवानग्यांसह तयार करते. कॉन्फिग फाईल्समध्ये अनेकदा सीक्रेट्स, API टोकन्स किंवा खाजगी रिपॉझिटरी URL असतात. तात्पुरती फाईल तयार होत असताना सिस्टमवरील इतर वापरकर्त्यांना ती पाहू देण्याचे कोणतेही कारण नाही.

फाईलवर आणि त्यानंतर डिरेक्टरीवर स्वतंत्र sync कॉल्सकडे लक्ष द्या. अनेक डेव्हलपर्स डिरेक्टरी सिंक वगळतात कारण ते अनावश्यक वाटते. पण ते नाही. Ext4, APFS आणि NTFS सर्व डिरेक्टरी अपडेट्स वेगवेगळ्या प्रकारे हाताळतात, परंतु कामगिरीसाठी (performance) मेटाडेटा राइट्स बॅच (batch) करण्याची त्यांची पद्धत समान आहे. जर तुम्हाला वीज गेल्यावरही डेटा सुरक्षित ठेवायचा असेल, तर डिरेक्टरी सिंक हे अंतिम सील (seal) आहे.

catch block मधील cleanup मुद्दाम संरक्षणात्मक (defensive) ठेवण्यात आले आहे. जर filehandle उघडल्यानंतर काही त्रुटी आली, तर कोड handle बंद करण्याचा आणि तात्पुरती फाईल काढून टाकण्याचा प्रयत्न करतो, आणि त्यानंतर येणाऱ्या इतर कोणत्याही त्रुटींकडे दुर्लक्ष करतो जेणेकरून मूळ exception व्यवस्थितपणे पुढे जाऊ शकेल. cleanup दरम्यान येणाऱ्या permission error मुळे मूळ bug लपला जाऊ नये, अशी तुमची इच्छा असेल.

ही पद्धत कुठे उपयोगाची ठरत नाही

Atomic file replacement मुळे 'torn writes' टाळता येतात. परंतु, यामुळे 'lost updates' टाळता येत नाहीत. जर तुमच्या CLI च्या दोन instances ने एकाच वेळी एकच config वाचला, दोघांनीही मेमरीमध्ये बदल केले, दोघांनीही नवीन temp फाईल्स लिहिल्या आणि दोघांनीही त्यांचे renames पूर्ण केले, तर दुसरे rename यशस्वी होते. पहिल्या प्रक्रियेला (process) दुसऱ्या प्रक्रियेतील बदल दिसून येत नाहीत. तुमच्या ॲप्लिकेशनवर अवलंबून, याचा अर्थ असा असू शकतो की एका युजरने एका टर्मिनलमध्ये सेटिंग जोडली आणि दुसऱ्या युजरने दुसऱ्या टर्मिनलमध्ये ती काढून टाकली, ज्यामुळे अंतिम फाईलमध्ये फक्त शेवटच्या व्यक्तीने केलेले बदल दिसतील.

जर तुमच्या टूलला एकाच वेळी बदल करणाऱ्या (concurrent mutators) प्रक्रियांना सपोर्ट करायचा असेल, तर तुम्हाला atomic write च्या वर एक समन्वय यंत्रणा (coordination mechanism) आवश्यक आहे. साध्या प्रकरणांसाठी advisory lock file उपयुक्त ठरते. Version vectors किंवा config मध्येच असलेला monotonic revision number संघर्षाचा (collisions) शोध घेण्यास मदत करू शकतो, जेणेकरून दुसरा writer पुन्हा प्रयत्न करू शकेल. यामुळे जटिलता (complexity) वाढते आणि जटिलतेमध्येच त्रुटी (bugs) लपलेल्या असतात.

म्हणूनच ही मर्यादा महत्त्वाची आहे. एखादा single JSON blob जो एखादी process अधूनमधून अपडेट करते, तो atomic file write साठी योग्य पर्याय आहे. एकदा का तुम्ही अनेक रेकॉर्ड्स व्यवस्थापित करू लागलात, schemas लागू करू लागलात किंवा concurrent mutations बद्दल काळजी करू लागलात, की समजून जा की तुम्ही filesystem च्या मर्यादेबाहेर गेला आहात. SQLite याच कारणासाठी अस्तित्वात आहे. ते तुम्हाला atomic transactions, rollback journals आणि concurrent readers व writers चे योग्य व्यवस्थापन एकाच host-local फाईलमध्ये देते. एक हुशार file protocol म्हणजे डेटाबेस नाही, आणि तसे भासवण्यासाठी तुम्ही तुमचे maintenance budget खर्च करू नये.

मुख्य निष्कर्ष

पुढच्या वेळी जेव्हा तुम्ही CLI टूलमध्ये writeFile वापरण्याचा विचार कराल, तेव्हा थोडा वेळ थांबा. Serialization कठीण भाग नाही. Durability कठीण भाग आहे. Config फाईल्स stream करण्यासाठी खूप लहान आणि truncate करण्यासाठी खूप महत्त्वाच्या असतात. संपूर्ण payload एका लपलेल्या sibling फाईलमध्ये लिहा, ती flush करा, rename ने commit करा आणि directory ला त्याबद्दल कळवा. तुमचे युजर्स Ctrl-C दाबू शकतात, पॉवर कॉर्ड ओढू शकतात किंवा लॅपटॉपचे झाकण बंद करू शकतात. जेव्हा मशीन पुन्हा सुरू होईल, तेव्हा फाईलमध्ये एकतर जुनी स्थिती असेल किंवा नवीन स्थिती असेल. त्या दोघांच्या मधली काहीही नसेल.