Faili la usanidi ambalo lilifanya kazi sekunde tano zilizopita sasa ni kipande cha JSON kilichoharibika cha baiti 347. Zana yako ya CLI haitaanza. Mtumiaji, ambaye alibonyeza tu Ctrl-C kwa sababu mchakato wa sasisho ulichukua muda mrefu kuliko ilivyotarajiwa, sasa anakabiliana na "error stack trace" ambayo hakuitaka. Kilobaiti mbili za usanidi halali zilizokuwepo kabla ya kuandika zimepotea, zimebadilishwa na kitu cha kidijitali kinachofanana na risiti iliyochapishwa nusu.

Hii hutokea kwa sababu writeFile si "atomic". Inafungua njia (path) iliyopo, inaikata (truncate), inasafirisha data kutoka Node kwenda kwenye "kernel’s page cache", na hatimaye inafunga "file descriptor". Wakati tu ukataji unapotokea, maudhui ya zamani yanakuwa tayari yamepotea. Kila kitu kati ya ukataji huo na close ya mwisho ni dirisha la hatari. SIGINT, kukatika kwa umeme, au kufunga kifuniko cha laptop ghafla wakati wa dirisha hilo huacha mfumo wa faili (filesystem) ukiwa na uchafu uliokatwa nusu. Hata kama Node inaripoti kuwa "Promise" imekamilika, mfumo wa uendeshaji unaweza bado kuwa unahifadhi maandishi (buffering writes) kwenye kumbukumbu. Njia rahisi (convenience methods) huficha pengo hilo, lakini haziliondoi.

Suluhisho si kuandika mahali pale pale. Suluhisho ni kutenganisha kitendo cha kuandika na kitendo cha kuchapisha.

Andika kwenye faili jirani, kisha badilisha

Mtindo huu wa kuaminika una hatua tano. Hakuna inayochanganya, lakini kwa pamoja zinapunguza dirisha la hitilafu kutoka kwenye mchakato mzima wa kusafirisha data (streaming write) hadi kwenye operesheni moja ya "metadata" ya mfumo wa faili.

Kwanza, "serialize" mzigo (payload) wote kwenye kumbukumbu. Fanya hivi kabla ya kutengeneza faili lolote la muda. Ikiwa JSON.stringify itatoa kosa (throw error) kwa sababu mtu ametoa "circular object", unataka kosa hilo litokee kabla ya kugusa diski.

Pili, andika data iliyoserialized kwenye faili la muda lililopo kwenye dirisha (directory) moja na lengo. Tumia jina la nasibu ili mchakato miwili yanayoendelea kwa wakati mmoja yasigongane. Kuweka faili la muda kwenye dirisha moja ni muhimu kwa sababu rename ni "atomic" tu ndani ya mfumo mmoja wa faili. Ikiwa faili lako la muda lipo kwenye sehemu (partition) tofauti, mfumo wa uendeshaji utatumia mfululizo wa "nakili na ufute" (copy-and-delete), ambao unaleta aina zake za hitilafu na si tena "atomic".

Tatu, iombe "kernel" isafishe (flush) faili hilo la muda kwenda kwenye hifadhi halisi. fsync ya Node, inayowasilishwa hapa kama njia ya sync kwenye "filehandle", inazuia mchakato hadi "buffers" ziishe kwenye kifaa halisi. Hii ni polepole, lakini maandishi ya usanidi hutokea mara chache sana kiasi kwamba uimara huo unastahili milisekunde hizo.

Nne, badilisha jina la faili la muda na kuweka kwenye njia ya asili. Katika mifumo ya POSIX na Windows, hii ndiyo hatua ya kukamilisha (commit point). Wasomaji wanaofungua njia ya asili wataona ama faili la zamani lililokamilika au faili jipya lililokamilika. Hakuna wakati ambapo msomaji anaweza kufungua njia hiyo na kuona "buffer" iliyoandikwa nusu.

Tano, "sync" dirisha la mzazi (parent directory). Hii inashughulikia hali adimu sana. Kubadilisha jina kunasasisha ingizo la dirisha, lakini "metadata" ya dirisha yenyewe inaweza kuwa kwenye "kernel’s page cache". Kupotea kwa umeme ghafla baada ya kubadilisha jina kwa mafanikio kunaweza wakati mwingine kuacha mfumo wa faili katika hali ambapo rejea mpya ya "inode" haijarekodiwa kwa uimara. Kufanya "sync" kwenye dirisha kunalazimisha sasisho hilo la "metadata" kwenda kwenye diski na kufunga muamala huo.

Utekelezaji halisi wa Node.js

Hivi ndivyo mtindo huo unavyoonekana kwa vitendo ukitumia maktaba ya kawaida ya Node.js pekee:

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;
  }
}

Maelezo machache katika kodi hii yanastahili kuzingatiwa.

Bendera (flag) ya wx inamaanisha "andika, lakini kama faili tayari lipo, toa kosa." Hii inalinda dhidi ya mgongano wa UUID au faili la muda lililoachwa na mchakato uliopata hitilafu hapo awali. Ikiwa mtu ameweka faili hatari mahali ambapo faili lako la muda linapaswa kuwa, utajua mara moja badala ya kufuta cho

Usafishaji katika catch block umefanywa kwa tahadhari ya makusudi. Ikiwa kitu chochote kitatokea baada ya kiunganishi cha faili (filehandle) kufunguliwa, kodi inajaribu kufunga kiunganishi hicho na kuondoa faili ya muda, ikificha makosa yoyote ya pili ili kosa la awali liendelee kusambaa bila vikwazo. Hutaki kosa la ruhusa wakati wa usafishaji lifunike hitilafu halisi iliyosababisha hitilafu hiyo.

Sehemu ambapo mfumo huu hausaidii tena

Ubadilishaji wa faili wa atomiki (atomic file replacement) huzuia maandishi yasiyokamilika (torn writes). Hata hivyo, hauzuia mabadiliko yaliyopotea (lost updates). Ikiwa nakala mbili za CLI yako zitasoma mipangilio (config) ile ile kwa wakati mmoja, zote mbili zitafanya marekebisho yake kwenye kumbukumbu (memory), zote mbili zitaandika faili mpya za muda, na zote mbili zitabadilisha majina yake, basi mabadiliko ya pili ndiyo yatashinda. Mchakato wa kwanza haukuona mabadiliko ya mchakato wa pili. Kulingana na programu yako, hii inaweza kumaanisha kuwa mtumiaji mmoja anaongeza mpangilio kwenye terminal moja na mtumiaji mwingine anaiondoa kwenye nyingine, huku faili ya mwisho ikionyesha tu mabadiliko ya mwandishi wa mwisho.

Ikiwa zana yako inahitaji kuunga mkono mabadiliko ya wakati mmoja (concurrent mutators), unahitaji mfumo wa uratibu juu ya uandishi wa atomiki. Faili ya kufunga ya kutoa ushauri (advisory lock file) hufanya kazi kwa matukio rahisi. Version vectors au namba ya marekebisho inayoongezeka (monotonic revision number) ndani ya mipangilio yenyewe inaweza kusaidia kugundua migongano ili mwandishi wa pili aweze kujaribu tena. Hizi huongeza utata, na utata ndipo hitilafu hujificha.

Ndiyo maana mpaka ni muhimu. JSON blob moja ambayo mchakato mmoja huiboresha mara chache ni chaguo zuri kwa uandishi wa faili wa atomiki. Mara tu unapojikuta ukisimamia rekodi nyingi, ukilazimisha miundo (schemas), au ukihofia mabadiliko ya wakati mmoja, basi umepitiliza uwezo wa mfumo wa faili (filesystem). SQLite ipo kwa sababu hiyo hiyo. Inakupa miamala ya atomiki (atomic transactions), rollback journals, na usimamizi mzuri wa wasomaji na waandishi wa wakati mmoja, yote ndani ya faili moja ya ndani ya mwenyeji. Itifaki ya faili ya kijanja si kanzi data (database), na hupaswi kutumia bajeti ya matengenezo ukijifanya vinginevyo.

Funzo la msingi

Wakati ujao utakapojaribu kutumia writeFile ndani ya zana ya CLI, simama kidogo. Serialization si sehemu ngumu. Durability ndiyo sehemu ngumu. Faili za mipangilio ni ndogo sana ili kutiririka (stream) na ni muhimu sana ili kukatwa (truncate). Andika payload nzima kwenye faili lingine la siri lililo jirani, lifanyie flush, liidhinishe (commit) kwa kubadilisha jina, na uijulishe hati (directory) kuhusu hilo. Watumiaji wako wanaweza kubonyeza Ctrl-C, kuvuta nyaya za umeme, au kufunga kifuniko cha laptop yao. Mashine itakapowaka tena, faili litakuwa na ulimwengu wa zamani au ulimwengu mpya. Hakuna kitu katikati.