ایک کنفیگ فائل جو پانچ سیکنڈ پہلے بالکل ٹھیک کام کر رہی تھی، اب ٹوٹے ہوئے JSON کا 347 بائٹ کا ایک ٹکڑا بن چکی ہے۔ آپ کا CLI ٹول شروع نہیں ہوگا۔ صارف، جس نے صرف اس لیے Ctrl-C دبایا کیونکہ اپ ڈیٹ توقع سے زیادہ وقت لے رہی تھی، اب ایک ایسے error stack trace کو دیکھ رہا ہے جس کا اس نے مطالبہ نہیں کیا تھا۔ لکھنے کے عمل سے پہلے موجود دو کلو بائٹ کی درست کنفیگریشن ختم ہو چکی ہے، اور اس کی جگہ ایک آدھے پرنٹ شدہ رسید کے ڈیجیٹل متبادل نے لے لی ہے۔

ایسا اس لیے ہوتا ہے کیونکہ writeFile ایٹامک (atomic) نہیں ہے۔ یہ موجودہ پاتھ کو کھولتا ہے، اسے ٹرنکیٹ (truncate) کرتا ہے، Node سے ڈیٹا کو کرنل کے پیج کیش (page cache) میں اسٹریم کرتا ہے، اور آخر کار فائل ڈسکرپٹر کو بند کر دیتا ہے۔ جس لمحے ٹرنکیشن ہوتی ہے، پرانا مواد پہلے ہی ختم ہو چکا ہوتا ہے۔ اس ٹرنکیشن اور آخری close کے درمیان کا تمام وقت خطرے کا ایک دورانیہ (window of vulnerability) ہے۔ اس دوران ایک SIGINT، بجلی کا جانا، یا لیپ ٹاپ کا ڈھکن بند ہو جانا، فائل سسٹم کو ایک ادھورے اور خراب ڈیٹا کے ساتھ چھوڑ دیتا ہے۔ اگرچہ Node Promise کو resolved ظاہر کرتا ہے، لیکن آپریٹنگ سسٹم اب بھی میموری میں رائٹس (writes) کو بفّر (buffer) کر رہا ہو سکتا ہے۔ سہولت بخش طریقے (convenience methods) اس خلا کو چھپاتے تو ہیں، لیکن اسے ختم نہیں کرتے۔

اس کا حل اسی جگہ لکھنا نہیں ہے۔ حل لکھنے کے عمل کو پبلش کرنے کے عمل سے الگ کرنا ہے۔

ایک ہم رقیب فائل (sibling) میں لکھیں، پھر اسے تبدیل (swap) کریں

اس قابل اعتماد پیٹرن کے پانچ مراحل ہیں۔ ان میں سے کوئی بھی پیچیدہ نہیں ہے، لیکن مل کر یہ ناکامی کے دورانیے (failure window) کو پورے اسٹریمنگ رائٹ سے کم کر کے صرف ایک فائل سسٹم میٹا ڈیٹا آپریشن تک محدود کر دیتے ہیں۔

پہلا، پورے پے لوڈ (payload) کو میموری میں سیریلائز (serialize) کریں۔ یہ کسی بھی عارضی فائل کو بنانے سے پہلے کریں۔ اگر کسی نے سرکولر آبجیکٹ (circular object) پاس کیا اور JSON.stringify ایرر دے، تو آپ چاہیں گے کہ ڈسک کو چھونے سے پہلے ہی وہ استثنا (exception) سامنے آ جائے۔

دوسرا، سیریلائز شدہ ڈیٹا کو اسی ڈائریکٹری میں ایک عارضی فائل میں لکھیں جہاں ٹارگٹ فائل موجود ہے۔ ایک رینڈمائزڈ نام استعمال کریں تاکہ دو بیک وقت چلنے والے عمل آپس میں نہ ٹکرائیں۔ عارضی فائل کو اسی ڈائریکٹری میں رکھنا اس لیے اہم ہے کیونکہ rename صرف ایک ہی فائل سسٹم کے اندر ایٹامک ہوتا ہے۔ اگر آپ کی عارضی فائل کسی دوسرے پارٹیشن پر ہے، تو آپریٹنگ سسٹم کاپی اور ڈیلیٹ (copy-and-delete) کے عمل پر منتقل ہو جاتا ہے، جو اپنے ساتھ ناکامی کے نئے طریقے لاتا ہے اور پھر یہ ایٹامک نہیں رہتا۔

تیسرا، کرنل سے اس عارضی فائل کو فزیکل اسٹوریج میں فلش (flush) کرنے کا کہیں۔ Node کا fsync (جو یہاں فائل ہینڈل پر sync میتھڈ کے طور پر ظاہر کیا گیا ہے) تب تک رکتا ہے جب تک ڈیٹا مکمل طور پر اسٹوریج پر منتقل نہ ہو جائے۔ یہ عمل سست ہے، لیکن کنفیگ رائٹس اتنی کم ہوتی ہیں کہ اس پائیداری (durability) کے لیے چند ملی سیکنڈز کا وقت دینا قابلِ ذکر ہے۔

چوتھا، عارضی فائل کا نام تبدیل کر کے اسے اصل پاتھ پر رکھ دیں۔ POSIX سسٹمز اور Windows دونوں پر، یہ 'کمٹ پوائنٹ' (commit point) ہے۔ اصل پاتھ کھولنے والے قارئین کو یا تو مکمل پرانی فائل نظر آئے گی یا مکمل نئی فائل۔ ایسا کوئی لمحہ نہیں ہوتا جب کوئی قاری پاتھ کھولے اور اسے آدھا لکھا ہوا بفّر نظر آئے۔

پانچواں، پیرنٹ ڈائریکٹری کو سنک (sync) کریں۔ یہ ایک باریک پہلو (edge case) کو سنبھالتا ہے۔ رینیم (rename) ڈائریکٹری انٹری کو اپ ڈیٹ کرتا ہے، لیکن ڈائریکٹری کا میٹا ڈیٹا خود کرنل کے پیج کیش میں ہو سکتا ہے۔ کامیاب رینیم کے بعد اچانک بجلی جانے سے کبھی کبھی فائل سسٹم ایسی حالت میں رہ سکتا ہے جہاں نئے 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 کے ٹکراؤ یا پچھلے کریش ہونے والے پروسیس کی کسی چھوڑی ہوئی عارضی فائل سے بچاتا ہے۔ اگر کسی نے وہاں کوئی نقصان دہ فائل رکھ دی ہے جہاں آپ کی عارضی فائل ہونی چاہیے، تو آپ اسے وہاں موجود کسی بھی چیز کو اوور رائٹ کرنے کے بجائے فوری طور پر جان لیں گے۔

0o600 پرمیشن ماسک عارضی فائل کو صرف مالک کے پڑھنے (read) اور لکھنے (write) کے حقوق کے ساتھ تخلیق کرتا ہے۔ کنفیگ فائلز میں اکثر راز، API ٹوکنز، یا پرائیویٹ ریپوزٹری URLs ہوتے ہیں۔ سسٹم کے دوسرے صارفین کو عارضی فائل تیار ہونے کے دوران اسے دیکھنے دینے کی کوئی وجہ نہیں ہے۔

فائل اور پھر ڈائریکٹری پر الگ الگ sync کالز پر غور کریں۔ بہت سے ڈویلپرز ڈائریکٹری سنک کو چھوڑ دیتے ہیں کیونکہ یہ غیر ضروری محسوس ہوتا ہے۔ لیکن ایسا نہیں ہے۔ Ext4، APFS، اور NTFS تمام ڈائریکٹری اپ ڈیٹس کو مختلف طریقے سے سنبھالتے ہیں، لیکن وہ کارکردگی کے لیے میٹا ڈیٹا رائٹس کو بیچ (batch) کرنے کی مشترکہ عادت رکھتے ہیں۔ اگر آپ بجلی جانے کی صورت میں ڈیٹا کو محفوظ رکھنے کی فکر کرتے ہیں، تو ڈائریکٹری سنک آخری مہر (final seal) ہے۔

catch block میں صفائی کا عمل جان بوجھ کر دفاعی (defensive) رکھا گیا ہے۔ اگر فائل ہینڈل کھلنے کے بعد کوئی بھی ایرر آتا ہے، تو کوڈ ہینڈل کو بند کرنے اور عارضی فائل کو حذف کرنے کی کوشش کرتا ہے، اور کسی بھی ثانوی غلطی کو خاموش کر دیتا ہے تاکہ اصل استثنا (exception) بغیر کسی رکاوٹ کے آگے بڑھ سکے۔ آپ نہیں چاہیں گے کہ صفائی کے دوران ہونے والا کوئی پرمیشن ایرر اس اصل بگ (bug) کو چھپا دے جس کی وجہ سے ناکامی ہوئی۔

جہاں یہ پیٹرن مددگار نہیں رہتا

ایٹامک فائل ریپلیسمنٹ (Atomic file replacement) نامکمل تحریروں (torn writes) کو روکتی ہے۔ یہ کھوئے ہوئے اپ ڈیٹس (lost updates) کو نہیں روکتی۔ اگر آپ کے CLI کے دو انسٹنس ایک ہی وقت میں ایک ہی کنفیگ کو پڑھتے ہیں، دونوں میموری میں اپنی ترامیم کرتے ہیں، دونوں نئی عارضی فائلیں لکھتے ہیں، اور دونوں اپنے ری نیم (rename) عمل کو مکمل کرتے ہیں، تو دوسرا ری نیم جیت جاتا ہے۔ پہلے عمل نے دوسرے عمل کی تبدیلیوں کو نہیں دیکھا۔ آپ کی ایپلی کیشن کے لحاظ سے، اس کا مطلب یہ ہو سکتا ہے کہ ایک صارف ایک ٹرمینل میں سیٹنگ شامل کرتا ہے اور دوسرا صارف دوسرے ٹرمینل میں اسے حذف کر دیتا ہے، اور حتمی فائل میں صرف آخری لکھنے والے کی تبدیلی نظر آتی ہے۔

اگر آپ کے ٹول کو متوازی تبدیلیوں (concurrent mutators) کی ضرورت ہے، تو آپ کو ایٹامک رائٹ کے اوپر ایک کوآرڈینیشن میکانزم (coordination mechanism) کی ضرورت ہوگی۔ سادہ معاملات کے لیے ایک ایڈوائزری لاک فائل (advisory lock file) کام کرتی ہے۔ کنفیگ کے اندر ورژن ویکٹرز (Version vectors) یا ایک مونوٹونک ریویژن نمبر (monotonic revision number) ٹکراؤ (collisions) کا پتہ لگانے میں مدد کر سکتے ہیں تاکہ دوسرا لکھنے والا دوبارہ کوشش کر سکے۔ یہ چیزیں پیچیدگی پیدا کرتی ہیں، اور پیچیدگی ہی وہ جگہ ہے جہاں بگ چھپے ہوتے ہیں۔

یہی وجہ ہے کہ حدود (boundary) اہمیت رکھتی ہے۔ ایک واحد JSON بلاگ جسے ایک عمل کبھی کبھار اپ ڈیٹ کرتا ہے، ایٹامک فائل رائٹ کے لیے ایک بہترین امیدوار ہے۔ ایک بار جب آپ خود کو متعدد ریکارڈز کو مینیج کرنے، اسکیما (schemas) نافذ کرنے، یا متوازی تبدیلیوں کے بارے میں فکر مند پاتے ہیں، تو اس کا مطلب ہے کہ آپ فائل سسٹم کی حدود سے باہر نکل چکے ہیں۔ SQLite بالکل اسی وجہ سے موجود ہے۔ یہ آپ کو ایٹامک ٹرانزیکشنز (atomic transactions)، رول بیک جرنلز (rollback journals)، اور متوازی قارئین اور لکھنے والوں کی مناسب ہینڈلنگ فراہم کرتا ہے، وہ بھی ایک ہی ہوسٹ لوکل فائل کے اندر۔ ایک چالاک فائل پروٹوکول ڈیٹا بیس نہیں ہے، اور آپ کو ایسا ظاہر کرنے میں اپنا مینٹیننس بجٹ ضائع نہیں کرنا چاہیے۔

اصل سبق

اگلی بار جب آپ کسی CLI ٹول کے اندر writeFile کا استعمال کریں، تو رک جائیں۔ سیریلائزیشن (Serialization) مشکل حصہ نہیں ہے۔ پائیداری (Durability) مشکل حصہ ہے۔ کنفیگ فائلیں اسٹریم کرنے کے لیے بہت چھوٹی اور ٹرنکیٹ (truncate) کرنے کے لیے بہت اہم ہوتی ہیں۔ پورے پے لوڈ (payload) کو ایک چھپی ہوئی سائیبلنگ فائل میں لکھیں، اسے فلیش (flush) کریں، ری نیم کے ساتھ اسے کمیٹ (commit) کریں، اور ڈائریکٹری کو اس کے بارے میں بتائیں۔ آپ کے صارفین Ctrl-C دبا سکتے ہیں، پاور کورڈ کھینچ سکتے ہیں، یا اپنے لیپ ٹاپ کا ڈھکن بند کر سکتے ہیں۔ جب مشین دوبارہ چلے گی، تو فائل میں یا تو پرانی دنیا ہوگی یا نئی۔ درمیان میں کچھ بھی نہیں ہوگا۔