ஐந்து வினாடிகளுக்கு முன்பு சரியாக வேலை செய்த ஒரு config file, இப்போது 347-byte அளவுள்ள சிதைந்த JSON துண்டாக மாறிவிட்டது. உங்கள் CLI tool தொடங்காது. அப்டேட் எதிர்பார்த்ததை விட அதிக நேரம் எடுப்பதால், பயனர் வெறும் Ctrl-C அழுத்தினால் மட்டும் போதும், இப்போது அவர் கேட்காத ஒரு error stack trace-ஐப் பார்த்துத் திகைத்து நிற்கிறார். எழுதும் முறைக்கு முன்னால் இருந்த அந்த இரண்டு கிலோபைட் அளவுள்ள சரியான configuration இப்போது காணவில்லை, அதற்குப் பதிலாக பாதியிலேயே அச்சிடப்பட்ட ரசீது போன்ற ஒரு டிஜிட்டல் சிதைவு மட்டுமே மிஞ்சியுள்ளது.
இது writeFile அணுக்கருத் தன்மை (atomic) கொண்டதல்ல என்பதால் நடக்கிறது. இது ஏற்கனவே உள்ள பாதையைத் திறந்து, அதைத் துண்டிக்கிறது (truncate), Node-லிருந்து kernel-ன் page cache-க்குத் தரவுகளைப் பாய்ச்சுகிறது (stream), இறுதியில் file descriptor-ஐ மூடுகிறது. அந்தத் துண்டிப்பு (truncation) நடக்கும் கணமே, பழைய உள்ளடக்கம் மறைந்துவிடும். அந்தத் துண்டிப்புக்கும் இறுதி close-க்கும் இடைப்பட்ட காலம் ஒரு பாதிப்பு காலமாகும் (window of vulnerability). அந்த நேரத்தில் ஒரு SIGINT, மின்சாரத் தடை அல்லது லேப்டாப் மூடி மூடப்பட்டால், கோப்பு முறைமை (filesystem) ஒரு சிதைந்த நிலையில் இருக்கும். Node அந்த Promise-ஐத் தீர்க்கப்பட்டதாக (resolved) அறிவித்தாலும், இயங்குதளம் (operating system) இன்னும் நினைவகத்தில் (memory) தரவுகளைச் சேமித்துக்கொண்டிருக்கலாம் (buffering). வசதியான முறைகள் (convenience methods) அந்த இடைவெளியை மறைக்கலாமே தவிர, அதை நீக்கிவிடாது.
இதற்குத் தீர்வு இருக்கும் இடத்தில் எழுதுவது (write in place) அல்ல. எழுதுவதையும் வெளியிடுவதையும் (publishing) தனித்தனியாகப் பிரிப்பதே சரியான தீர்வாகும்.
ஒரு இணை கோப்பில் (sibling) எழுதி, பின் மாற்றவும் (swap)
இந்த நம்பகமான முறையில் ஐந்து படிகள் உள்ளன. அவை ஒவ்வொன்றும் சிக்கலானவை அல்ல, ஆனால் அவை அனைத்தும் இணைந்து, தோல்விக்கான கால இடைவெளியை (failure window) முழுமையான streaming write-லிருந்து ஒரு ஒற்றை filesystem metadata operation-ஆகக் குறைத்துவிடுகின்றன.
முதலாவதாக, முழு payload-ஐயும் நினைவகத்தில் (memory) வரிசைப்படுத்தவும் (serialize). எந்தவொரு தற்காலிகக் கோப்பையும் (temporary file) உருவாக்குவதற்கு முன்பே இதைச் செய்துவிட வேண்டும். யாராவது ஒரு circular object-ஐ அனுப்பியதால் JSON.stringify ஒரு exception-ஐத் தூண்டினால், நீங்கள் வன்வட்டைத் (disk) தொடங்குவதற்கு முன்பே அந்தத் தவறு வெளிப்பட வேண்டும்.
இரண்டாவதாக, வரிசைப்படுத்தப்பட்ட தரவை இலக்கு கோப்பு இருக்கும் அதே கோப்பகத்தில் (directory) ஒரு தற்காலிகக் கோப்பாக எழுதவும். ஒரே நேரத்தில் இரண்டு செயல்பாடுகள் (concurrent runs) மோதிக்கொள்ளாமல் இருக்க ஒரு சீரற்ற பெயரைக் (randomized name) பயன்படுத்தவும். தற்காலிகக் கோப்பை அதே கோப்பகத்தில் வைத்திருப்பது முக்கியம், ஏனெனில் rename என்பது ஒரே filesystem-க்குள் மட்டுமே atomic ஆகச் செயல்படும். உங்கள் தற்காலிகக் கோப்பு வேறு ஒரு partition-இல் இருந்தால், இயங்குதளம் copy-and-delete முறையைப் பயன்படுத்தும், இது அதன் சொந்தத் தோல்வி முறைகளை (failure modes) உருவாக்கி, அது இனி atomic ஆக இருக்காது.
மூன்றாவதாக, அந்தத் தற்காலிகக் கோப்பை இயற்பியல் சேமிப்பகத்திற்கு (physical storage) மாற்ற (flush) kernel-இடம் கேட்கவும். Node-ன் fsync (இங்கு filehandle-ன் sync முறையாகக் கொடுக்கப்பட்டுள்ளது), buffers அனைத்தும் வன்வட்டில் பதிவாகும் வரை காத்திருக்கும் (blocks). இது மெதுவானதுதான், ஆனால் config எழுதுவது அரிதாகவே நடப்பதால், அந்தச் சில மில்லி விநாடிகளுக்காகக் காத்திருப்பது அதன் நிலைத்தன்மைக்கு (durability) அவசியமானது.
நான்காவதாக, தற்காலிகக் கோப்பை அசல் பாதையின் பெயரில் மாற்றவும் (rename). POSIX அமைப்புகள் மற்றும் Windows ஆகிய இரண்டிலும், இதுதான் உறுதிப்படுத்தும் புள்ளி (commit point). அசல் பாதையைத் திறக்கும் வாசகர்கள் (readers) ஒன்று முழுமையான பழைய கோப்பையோ அல்லது முழுமையான புதிய கோப்பையோ காண்பார்கள். ஒரு வாசகர் பாதையைத் திறந்து பாதியிலேயே எழுதப்பட்ட buffer-ஐக் காணும் தருணம் என்று எதுவுமே இருக்காது.
ஐந்தாவதாக, தாய் கோப்பகத்தை (parent directory) sync செய்யவும். இது ஒரு நுணுக்கமான விளிம்பு நிலைச் சிக்கலைத் (edge case) தவிர்க்கிறது. rename என்பது directory entry-ஐப் புதுப்பிக்கும், ஆனால் directory metadata அந்த kernel-ன் page cache-இல் இருக்கலாம். வெற்றிகரமான rename-க்குப் பிறகு திடீரென மின்சாரம் துண்டிக்கப்பட்டால், புதிய inode reference ஒருபோதும் நிரந்தரமாகப் பதிவு செய்யப்படாத கோப்பு முறைமை நிலையில் இருக்கலாம். கோப்பகத்தை sync செய்வது அந்த metadata புதுப்பிப்பை வன்வட்டில் உறுதிப்படுத்தி, அந்தப் பரிவர்த்தனையை (transaction) நிறைவு செய்கிறது.
ஒரு நடைமுறை Node.js செயலாக்கம்
Node.js standard library-ஐ மட்டும் பயன்படுத்தி அந்த முறை நடைமுறையில் எப்படி இருக்கும் என்பது இதோ:
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;
}
}
இந்த நிரலில் (code) கவனிக்க வேண்டிய சில விவரங்கள் உள்ளன.
wx flag என்பது "எழுதுக, ஆனால் கோப்பு ஏற்கனவே இருந்தால் தோல்வியடையவும்" என்று பொருள்படும். இது UUID மோதல் (collision) அல்லது முந்தைய செயலிழந்த செயல்முறையிலிருந்து (crashed process) எஞ்சியிருக்கும் தற்காலிகக் கோப்பிலிருந்து பாதுகாக்கிறது. உங்கள் தற்காலிகக் கோப்பு இருக்க வேண்டிய இடத்தில் யாராவது ஒரு தீயக் கோப்பை (malicious file) வைத்திருந்தால், அங்குள்ளதை நீங்கள் மேலெழுதாமல் (overwrite), உடனடியாகத் தெரிந்து கொள்ளலாம்.
0o600 permission mask என்பது தற்காலிகக் கோப்பை அதன் உரிமையாளருக்கு மட்டும் படிக்கவும் (owner-read) எழுதவும் (owner-write) கூடிய அனுமதியுடன் உருவாக்கும். Config கோப்புகளில் பெரும்பாலும் ரகசியங்கள் (secrets), API tokens அல்லது தனிப்பட்ட repository URLs இருக்கும். கோப்பு தயாராகிக் கொண்டிருக்கும்போது, கணினியில் உள்ள மற்ற பயனர்கள் அந்தத் தற்காலிகக் கோப்பைப் பார்ப்பதற்கு எந்தக் காரணமும் இல்லை.
கோப்பு மற்றும் கோப்பகம் ஆகியவற்றின் மீது தனித்தனியாகச் செய்யப்படும் sync அழைப்புகளைக் கவனியுங்கள். பல டெவலப்பர்கள் கோப்பக sync-ஐத் தேவையில்லாதது (redundant) என்று கருதித் தவிர்த்துவிடுவார்கள். ஆனால் அது தேவையில்லாதது அல்ல. Ext4, APFS மற்றும் NTFS ஆகிய அனைத்தும் கோப்பகப் புதுப்பிப்புகளைத் (directory updates) தனித்தனியாகக் கையாள்கின்றன, ஆனால் செயல்திறனுக்காக metadata எழுதுதல்களைத் தொகுத்து வைக்கும் (batching) பொதுவான பழக்கத்தைக் கொண்டுள்ளன. மின்சாரத் தடையைத் தாங்கிப் பிழைக்க நீங்கள் விரும்பினால், கோப்பக sync என்பது இறுதி முத்திரை (final seal) ஆகும்.
catch block-இல் செய்யப்படும் சுத்திகரிப்பு (cleanup) வேண்டுமென்றே முன்னெச்சரிக்கை நடவடிக்கையாக (defensive) செய்யப்பட்டுள்ளது. filehandle திறக்கப்பட்ட பிறகு ஏதேனும் பிழை ஏற்பட்டால், அந்த handle-ஐ மூடி தற்காலிகக் கோப்பை நீக்கக் குறியீடு முயற்சிக்கும்; அதே நேரத்தில், அசல் பிழை (original exception) சரியாக வெளிப்படுவதற்காக, பிற துணைப் பிழைகள் (secondary errors) தவிர்க்கப்படும். சுத்திகரிப்பின் போது ஏற்படும் ஒரு அனுமதிப் பிழை (permission error), தோல்விக்குக் காரணமான உண்மையான பிழையை மறைத்துவிடக் கூடாது என்பதே இதன் நோக்கம்.
இந்த முறை எங்கு உதவாது
Atomic file replacement என்பது torn writes-ஐத் தடுக்கிறது. ஆனால் அது lost updates-ஐத் தடுக்காது. உங்கள் CLI-இன் இரண்டு நிகழ்வுகள் (instances) ஒரே நேரத்தில் ஒரே config-ஐப் படித்தால், அவை இரண்டும் அவற்றின் மாற்றங்களை memory-இல் செய்யும், இரண்டும் புதிய தற்காலிகக் கோப்புகளை எழுதும், மற்றும் இரண்டும் அவற்றின் renames-களைச் செயல்படுத்தும்; இதில் இரண்டாவது rename வெற்றி பெறும். முதல் செயல்முறை (process), இரண்டாவது செயல்முறையின் மாற்றங்களைக் கவனிக்கவில்லை. உங்கள் பயன்பாட்டைப் பொறுத்து, இதன் பொருள் ஒரு பயனர் ஒரு terminal-இல் ஒரு அமைப்பைச் (setting) சேர்க்கலாம் மற்றும் மற்றொரு பயனர் அதை நீக்கலாம்; இறுதியில் கோப்பு கடைசி முறை எழுதியவரின் மாற்றத்தை மட்டுமே கொண்டிருக்கும்.
உங்கள் கருவி ஒரே நேரத்தில் பல மாற்றங்களைச் செய்பவர்களை (concurrent mutators) ஆதரிக்க வேண்டுமென்றால், atomic write-க்கு மேலாக ஒரு ஒருங்கிணைப்பு முறை (coordination mechanism) தேவைப்படும். எளிய நிகழ்வுகளுக்கு ஒரு advisory lock file போதுமானதாக இருக்கும். Version vectors அல்லது config-க்குள்ளேயே ஒரு monotonic revision number இருந்தால், மோதல்களைக் (collisions) கண்டறிய உதவும், இதனால் இரண்டாவது முறை எழுதுபவர் மீண்டும் முயற்சி செய்யலாம். இவை சிக்கலை (complexity) அதிகரிக்கும், மேலும் சிக்கலான இடங்களில்தான் பிழைகள் (bugs) ஒளிந்திருக்கும்.
அதனால்தான் இந்த எல்லை முக்கியமானது. ஒரு செயல்முறை அவ்வப்போது புதுப்பிக்கும் ஒற்றை JSON blob, atomic file write-க்குச் சிறந்த தேர்வாகும். நீங்கள் பல பதிவுகளை (records) நிர்வகிக்கத் தொடங்கும்போது, schemas-களைப் பயன்படுத்த வேண்டியிருக்கும் அல்லது ஒரே நேரத்தில் நிகழும் மாற்றங்களைப் (concurrent mutations) பற்றி கவலைப்பட வேண்டியிருக்கும் என்றால், நீங்கள் filesystem-இன் எல்லையைத் தாண்டிவிட்டீர்கள் என்று அர்த்தம். சரியாக இதற்காகவே SQLite உருவாக்கப்பட்டுள்ளது. இது உங்களுக்கு atomic transactions, rollback journals மற்றும் ஒரே நேரத்தில் படிப்பவர்கள் (readers) மற்றும் எழுதுபவர்கள் (writers) ஆகியோரைக் கையாளுதல் ஆகியவற்றை ஒரே ஒரு host-local கோப்பிற்குள்ளேயே வழங்குகிறது. ஒரு புத்திசாலித்தனமான file protocol என்பது தரவுத்தளம் (database) அல்ல, அதைத் தரவுத்தளம் போலக் காட்டிக்கொள்ள நீங்கள் உங்கள் பராமரிப்புச் செலவை (maintenance budget) வீணடிக்கக் கூடாது.
உண்மையான பாடம்
அடுத்த முறை ஒரு CLI கருவிக்குள் writeFile-ஐப் பயன்படுத்தும் போது, சற்று நிதானியுங்கள். Serialization கடினமான பகுதி அல்ல; Durability தான் கடினமானது. Config கோப்புகள் stream செய்வதற்கு மிகச் சிறியவை மற்றும் truncate செய்வதற்கு மிக முக்கியமானவை. முழு payload-ஐயும் ஒரு மறைக்கப்பட்ட இணை கோப்பில் (hidden sibling) எழுதி, அதை flush செய்து, rename மூலம் commit செய்து, அந்தத் தகவலை directory-இடம் தெரிவிக்கவும். உங்கள் பயனர்கள் Ctrl-C அழுத்தலாம், மின்சார இணைப்பைத் துண்டிக்கலாம் அல்லது லேப்டாப் மூடியை மூடலாம். கணினி மீண்டும் இயங்கும்போது, அந்தக் கோப்பில் பழைய தரவு இருக்கலாம் அல்லது புதிய தரவு இருக்கலாம். இரண்டிற்கும் இடைப்பட்ட நிலை எதுவும் இருக்காது.
