પાંચ સેકન્ડ પહેલા જે કન્ફિગ ફાઇલ બરાબર કામ કરતી હતી, તે હવે તૂટેલા JSON નો 347-બાઇટનો ટુકડો બની ગઈ છે. તમારું CLI ટૂલ શરૂ થશે નહીં. વપરાશકર્તા, જેમણે અપડેટમાં અપેક્ષા કરતા વધુ સમય લાગી રહ્યો હોવાથી ફક્ત Ctrl-C દબાવ્યું હતું, તેઓ હવે એરર સ્ટેક ટ્રેસ (error stack trace) જોઈ રહ્યા છે જેની તેમને જરૂર નહોતી. લખતા પહેલા જે બે કિલોબાઇટની માન્ય કન્ફિગરેશન હતી તે હવે જતી રહી છે, અને તેની જગ્યાએ અડધું છપાયેલું રિસીપ્ટ જેવું ડિજિટલ અવશેષ રહી ગયું છે.

આવું એટલા માટે થાય છે કારણ કે writeFile એ એટમિક (atomic) નથી. તે હાલના પાથને ખોલે છે, તેને ટ્રંકેટ (truncate) કરે છે, Node માંથી કર્નલના પેજ કેશ (page cache) માં ડેટા સ્ટ્રીમ કરે છે, અને અંતે ફાઇલ ડિસ્ક્રિપ્ટર બંધ કરે છે. જે ક્ષણે ટ્રંકેશન થાય છે, તે જ ક્ષણે જૂની સામગ્રી જતી રહે છે. તે ટ્રંકેશન અને અંતિમ close વચ્ચેનો સમય જોખમનો સમયગાળો (window of vulnerability) છે. તે સમયગાળા દરમિયાન SIGINT, પાવર કટ, અથવા લેપટોપનું ઢાંકણું અચાનક બંધ થઈ જવું, ફાઇલ સિસ્ટમને એક અધૂરી અને બગડેલી ફાઇલ સાથે છોડી દે છે. ભલે Node Promise ને resolved તરીકે રિપોર્ટ કરે, ઓપરેટિંગ સિસ્ટમ હજુ પણ મેમરીમાં લખવાની પ્રક્રિયા (writes) બફર કરી રહી હોઈ શકે છે. કન્વીનિયન્સ મેથડ્સ (Convenience methods) તે ખામીને છુપાવે છે, પરંતુ તેને દૂર કરતી નથી.

તેનો ઉકેલ તે જ જગ્યાએ લખવાનો નથી. ઉકેલ એ છે કે લખવાની પ્રક્રિયાને પબ્લિશ કરવાની પ્રક્રિયાથી અલગ કરવી.

સમાન ડિરેક્ટરીમાં લખો, પછી બદલો (swap કરો)

આ વિશ્વસનીય પેટર્નમાં પાંચ સ્ટેપ્સ છે. તેમાંથી એક પણ જટિલ નથી, પરંતુ સાથે મળીને તેઓ નિષ્ફળતાના સમયગાળાને (failure window) આખા સ્ટ્રીમિંગ રાઈટમાંથી ઘટાડીને માત્ર એક સિંગલ ફાઇલ સિસ્ટમ મેટાડેટા ઓપરેશન સુધી લાવી દે છે.

પ્રથમ, આખા પેલોડને મેમરીમાં સિરીયલાઈઝ (serialize) કરો. કોઈપણ ટેમ્પરરી ફાઇલ બનાવતા પહેલા આ કરો. જો કોઈએ સર્ક્યુલર ઓબ્જેક્ટ પસાર કર્યો હોય અને JSON.stringify એ એરર ફેંક્યું હોય, તો તમે ઈચ્છો છો કે ડિસ્કને સ્પર્શ કરતા પહેલા તે એક્સેપ્શન (exception) ઉપર આવે.

બીજું, સિરીયલાઈઝ કરેલા ડેટાને ટાર્ગેટની સમાન ડિરેક્ટરીમાં આવેલી ટેમ્પરરી ફાઇલમાં લખો. રેન્ડમાઇઝ્ડ નામનો ઉપયોગ કરો જેથી બે એકસાથે ચાલતા પ્રોસેસ વચ્ચે અથડામણ ન થાય. ટેમ્પ ફાઇલને સમાન ડિરેક્ટરીમાં રાખવી મહત્વપૂર્ણ છે કારણ કે rename માત્ર એક જ ફાઇલ સિસ્ટમમાં એટમિક હોય છે. જો તમારી ટેમ્પ ફાઇલ અલગ પાર્ટીશન પર હોય, તો ઓપરેટિંગ સિસ્ટમ કોપી-અને-ડિલીટ સિક્વન્સ પર જાય છે, જે પોતાની નિષ્ફળતાના મોડ્સ લાવે છે અને તે હવે એટમિક રહેતું નથી.

ત્રીજું, કર્નલને તે ટેમ્પરરી ફાઇલને ફિઝિકલ સ્ટોરેજમાં ફ્લશ (flush) કરવા માટે કહો. Node નું fsync, જે અહીં ફાઇલહેન્ડલ પર sync મેથડ તરીકે ઉપલબ્ધ છે, તે બફર્સ મેટલ (physical storage) પર ન જાય ત્યાં સુધી બ્લોક કરે છે. આ ધીમું છે, પરંતુ કન્ફિગ રાઈટ્સ એટલી ઓછી વાર થાય છે કે તેની ટકાઉપણું (durability) માટે આ મિલીસેકન્ડ્સ ખર્ચવા યોગ્ય છે.

ચોથું, ટેમ્પરરી ફાઇલને મૂળ પાથ પર રીનેમ (rename) કરો. POSIX સિસ્ટમ્સ અને Windows બંનેમાં, આ કમિટ પોઈન્ટ (commit point) છે. મૂળ પાથ ખોલનારા રીડર્સ કાં તો સંપૂર્ણ જૂની ફાઇલ જોશે અથવા સંપૂર્ણ નવી ફાઇલ જોશે. એવો કોઈ સમય નથી જ્યારે રીડર પાથ ખોલી શકે અને અડધું લખાયેલું બફર જોઈ શકે.

પાંચમું, પેરેન્ટ ડિરેક્ટરીને સિંક (sync) કરો. આ એક સૂક્ષ્મ એજ કેસ (edge case) ને પકડે છે. રીનેમ ડિરેક્ટરી એન્ટ્રીને અપડેટ કરે છે, પરંતુ ડિરેક્ટરી મેટાડેટા પોતે કર્નલના પેજ કેશમાં હોઈ શકે છે. સફળ રીનેમ પછી અચાનક પાવર જતો રહેવાથી ક્યારેક ફાઇલ સિસ્ટમ એવી સ્થિતિમાં રહી શકે છે જ્યાં નવા inode રેફરન્સને ક્યારેય ટકાઉ રીતે રેકોર્ડ કરવામાં આવ્યો ન હોય. ડિરેક્ટરીને સિંક કરવાથી તે મેટાડેટા અપડેટ ડિસ્ક પર જાય છે અને ટ્રાન્ઝેક્શનને સુરક્ષિત (seal) કરે છે.

Node.js નું એક નક્કર અમલીકરણ

માત્ર 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 કોલિઝન અથવા અગાઉની ક્રેશ થયેલી પ્રોસેસની છોડેલી ટેમ્પ ફાઇલ સામે રક્ષણ આપે છે. જો કોઈએ તમારી ટેમ્પ ફાઇલ હોવી જોઈએ તે જગ્યાએ કોઈ માલિશિયસ (malicious) ફાઇલ મૂકી હોય, તો ત્યાં રહેલી કોઈપણ વસ્તુને ઓવરરાઈટ કરવાને બદલે તમને તરત જ ખબર પડી જશે.

0o600 પરમિશન માસ્ક ટેમ્પ ફાઇલને માત્ર ઓનર-રીડ (owner-read) અને ઓનર-રાઈટ (owner-write) સાથે બનાવે છે. કન્ફિગ ફાઇલોમાં વારંવાર સિક્રેટ્સ, API ટોકન્સ અથવા પ્રાઇવેટ રિપોઝિટરી URL હોય છે. જ્યારે ટેમ્પરરી ફાઇલ તૈયાર થઈ રહી હોય ત્યારે સિસ્ટમ પરના અન્ય વપરાશકર્તાઓને તેમાં ડોકિયું કરવા દેવાનું કોઈ કારણ નથી.

ફાઇલ પર અને પછી ડિરેક્ટરી પર અલગ sync કોલ્સ પર ધ્યાન આપો. ઘણા ડેવલપર્સ ડિરેક્ટરી સિંક છોડી દે છે કારણ કે તે બિનજરૂરી લાગે છે. તે બિનજરૂરી નથી. Ext4, APFS, અને NTFS તમામ ડિરેક્ટરી અપડેટ્સને અલગ રીતે હેન્ડલ કરે છે, પરંતુ તેઓ પર્ફોર્મન્સ માટે મેટાડેટા રાઈટ્સને બેચ કરવા માટેની સમાન આદત ધરાવે છે. જો તમે પાવર લોસથી બચવા માંગતા હોવ, તો ડિરેક્ટરી સિંક એ અંતિમ સીલ (final seal) છે.

catch block માં ક્લીનઅપ જાણીજોઈને ડિફેન્સિવ રાખવામાં આવ્યું છે. જો filehandle ખુલ્યા પછી કંઈ પણ error આવે, તો કોડ handle ને બંધ કરવાનો અને કામચલાઉ ફાઇલને દૂર કરવાનો પ્રયાસ કરે છે, અને કોઈપણ secondary errors ને અવગણે છે જેથી મૂળ exception યોગ્ય રીતે આગળ વધી શકે. તમે ક્લીનઅપ દરમિયાન આવતી permission error ને કારણે અસલી bug છુપાવવા માંગતા નથી જે નિષ્ફળતાનું કારણ બન્યું હતું.

આ પેટર્ન ક્યાં મદદ કરવાનું બંધ કરે છે

Atomic file replacement 'torn writes' ને અટકાવે છે. તે 'lost updates' ને અટકાવતું નથી. જો તમારા CLI ના બે ઇન્સ્ટન્સ એકસાથે સમાન config વાંચે, બંને મેમરીમાં ફેરફારો કરે, બંને નવી temp ફાઇલો લખે અને બંને તેમના rename કરે, તો બીજું rename જીતી જાય છે. પ્રથમ પ્રોસેસે બીજી પ્રોસેસના ફેરફારો જોયા નથી. તમારા એપ્લિકેશન પર આધાર રાખીને, આનો અર્થ એ હોઈ શકે કે એક યુઝર એક ટર્મિનલમાં સેટિંગ ઉમેરે છે અને બીજો યુઝર બીજામાં તેને દૂર કરે છે, જેનાથી અંતિમ ફાઇલમાં માત્ર છેલ્લે લખનારના ફેરફારો જ દેખાય છે.

જો તમારા ટૂલને concurrent mutators ને સપોર્ટ કરવાની જરૂર હોય, તો તમારે atomic write ની ઉપર એક coordination mechanism ની જરૂર પડશે. સાદા કિસ્સાઓ માટે advisory lock file કામ કરે છે. Version vectors અથવા config ની અંદર જ એક monotonic revision number કોલિઝન (collisions) શોધવામાં મદદ કરી શકે છે જેથી બીજો લખનાર ફરીથી પ્રયાસ કરી શકે. આ જટિલતા વધારે છે, અને જટિલતામાં જ બગ્સ છુપાયેલા હોય છે.

એટલા માટે જ સીમા (boundary) મહત્વની છે. એક સિંગલ JSON blob જેને એક પ્રોસેસ ક્યારેક અપડેટ કરે છે, તે atomic file write માટે સારો ઉમેદવાર છે. એકવાર તમે મલ્ટિપલ રેકોર્ડ્સ મેનેજ કરવા લાગો, schemas લાગુ કરવા લાગો અથવા concurrent mutations વિશે ચિંતા કરવા લાગો, ત્યારે તમે filesystem ની મર્યાદા ઓળંગી ગયા છો. SQLite બરાબર આ જ કારણ માટે અસ્તિત્વ ધરાવે છે. તે તમને atomic transactions, rollback journals, અને concurrent readers અને writers નું યોગ્ય હેન્ડલિંગ આપે છે, તે પણ એક સિંગલ host-local ફાઇલની અંદર. એક ચતુર file protocol ડેટાબેઝ નથી, અને તમારે તેમ હોવાનો ડોળ કરીને મેન્ટેનન્સ બજેટ ખર્ચવું જોઈએ નહીં.

સાચો નિષ્કર્ષ

આગલી વખતે જ્યારે તમે CLI ટૂલની અંદર writeFile નો ઉપયોગ કરો, ત્યારે થોભો. Serialization અઘરો ભાગ નથી. Durability અઘરો ભાગ છે. Config ફાઇલો સ્ટ્રીમ કરવા માટે ખૂબ નાની છે અને ટ્રંકેટ (truncate) કરવા માટે ખૂબ મહત્વપૂર્ણ છે. આખા payload ને એક છુપાયેલી (hidden) sibling ફાઇલમાં લખો, તેને flush કરો, rename સાથે commit કરો, અને ડિરેક્ટરીને તેના વિશે જણાવો. તમારા યુઝર્સ Ctrl-C દબાવી શકે છે, પાવર કોર્ડ ખેંચી શકે છે, અથવા તેમના લેપટોપનું ઢાંકણું બંધ કરી શકે છે. જ્યારે મશીન ફરી ચાલુ થાય, ત્યારે ફાઇલમાં કાં તો જૂની દુનિયા હશે અથવા નવી. વચ્ચે કંઈ જ નહીં.