পাঁচ সেকেন্ড আগেও যে কনফিগ ফাইলটি ঠিকঠাক কাজ করছিল, তা এখন একটি ভাঙা JSON-এর ৩৪৭-বাইটের টুকরো মাত্র। আপনার CLI টুলটি আর চালু হবে না। ব্যবহারকারী, যিনি আপডেটটি প্রত্যাশার চেয়ে বেশি সময় নিচ্ছিল বলে কেবল Ctrl-C চেপেছিলেন, তিনি এখন এমন একটি এরর স্ট্যাক ট্রেসের দিকে তাকিয়ে আছেন যা তিনি চাননি। রাইট করার আগে যে দুই কিলোবাইটের বৈধ কনফিগারেশন ছিল, তা হারিয়ে গেছে; তার বদলে এখন আছে অর্ধেক প্রিন্ট হওয়া রসিদের মতো একটি ডিজিটাল অবশিষ্টাংশ।
এটি ঘটে কারণ writeFile অ্যাটমিক (atomic) নয়। এটি বিদ্যমান পাথটি ওপেন করে, সেটি ট্রাঙ্কেট (truncate) করে, Node থেকে কার্নেলের পেজ ক্যাশে ডেটা স্ট্রিম করে এবং অবশেষে ফাইল ডেসক্রিপ্টরটি ক্লোজ করে। ট্রাঙ্কেশন হওয়ার মুহূর্তেই পুরনো কন্টেন্ট হারিয়ে যায়। সেই ট্রাঙ্কেশন এবং চূড়ান্ত close এর মধ্যবর্তী সময়টি হলো একটি ঝুঁকির সময় (window of vulnerability)। সেই সময়ের মধ্যে একটি SIGINT, বিদ্যুৎ বিভ্রাট বা ল্যাপটপের ঢাকনা হঠাৎ বন্ধ হয়ে যাওয়া ফাইল সিস্টেমকে একটি অগোছালো ও অসম্পূর্ণ অবস্থায় ফেলে রেখে যায়। এমনকি Node যদি Promise-টিকে resolved হিসেবে রিপোর্ট করে, তবুও অপারেটিং সিস্টেম মেমোরিতে রাইটগুলো বাফার (buffer) করে রাখতে পারে। কনভিনিয়েন্স মেথডগুলো সেই গ্যাপটি লুকিয়ে রাখে, কিন্তু তা দূর করে না।
সমাধান হলো সরাসরি (in place) রাইট না করা। সমাধান হলো রাইট করার কাজটিকে পাবলিশ করার কাজ থেকে আলাদা করা।
একটি সাইবলিং (sibling) ফাইলে লিখুন, তারপর সোয়াপ (swap) করুন
এই নির্ভরযোগ্য প্যাটার্নটিতে পাঁচটি ধাপ রয়েছে। এর কোনোটিই জটিল নয়, কিন্তু একত্রে এগুলো পুরো একটি স্ট্রিমিং রাইট-এর পরিবর্তে ব্যর্থতার ঝুঁকিকে একটিমাত্র ফাইল সিস্টেম মেটাডেটা অপারেশনে নামিয়ে আনে।
প্রথমত, পুরো পেলোডটি মেমোরিতে সিরিয়ালাইজ (serialize) করুন। কোনো টেম্পোরারি ফাইল তৈরি করার আগেই এটি করুন। যদি কেউ কোনো সার্কুলার অবজেক্ট পাস করার কারণে JSON.stringify এরর দেয়, তবে আপনি চান যে ডিস্ক স্পর্শ করার আগেই সেই এক্সেপশনটি (exception) সামনে আসুক।
দ্বিতীয়ত, সিরিয়ালাইজ করা ডেটাটি টার্গেট ডিরেক্টরির ভেতরেই একটি টেম্পোরারি ফাইলে লিখুন। একটি র্যান্ডমাইজড (randomized) নাম ব্যবহার করুন যাতে দুটি সমান্তরাল রান (concurrent runs) একে অপরের সাথে সংঘর্ষে না জড়ায়। টেম্পোরারি ফাইলটি একই ডিরেক্টরিতে রাখা গুরুত্বপূর্ণ কারণ rename শুধুমাত্র একটি একক ফাইল সিস্টেমের মধ্যে অ্যাটমিক হয়। যদি আপনার টেম্পোরারি ফাইলটি অন্য কোনো পার্টিশনে থাকে, তবে অপারেটিং সিস্টেম একটি কপি-অ্যান্ড-ডিলিট সিকোয়েন্স অনুসরণ করে, যা নিজস্ব ব্যর্থতার ঝুঁকি তৈরি করে এবং এটি আর অ্যাটমিক থাকে না।
তৃতীয়ত, কার্নেলকে নির্দেশ দিন যাতে সেই টেম্পোরারি ফাইলটি ফিজিক্যাল স্টোরেজে ফ্লাশ (flush) করা হয়। Node-এর fsync, যা এখানে একটি ফাইলহ্যান্ডেলের sync মেথড হিসেবে প্রকাশ করা হয়েছে, বাফারগুলো মেমোরি থেকে হার্ডওয়্যারে না আসা পর্যন্ত ব্লক করে রাখে। এটি ধীরগতির, কিন্তু কনফিগ রাইট খুব কমই ঘটে, তাই এই কয়েক মিলিসেকেন্ডের বিনিময়ে ডেটার স্থায়িত্ব (durability) নিশ্চিত করা সার্থক।
চতুর্থত, টেম্পোরারি ফাইলটিকে মূল পাথের নামে রিনেম (rename) করুন। POSIX সিস্টেম এবং উইন্ডোজ উভয় ক্ষেত্রেই এটি হলো কমিট পয়েন্ট (commit point)। যারা মূল পাথটি ওপেন করবেন, তারা হয় সম্পূর্ণ পুরনো ফাইলটি অথবা সম্পূর্ণ নতুন ফাইলটি দেখতে পাবেন। এমন কোনো মুহূর্ত নেই যখন একজন রিডার পাথটি ওপেন করে একটি অর্ধেক লেখা বাফার দেখতে পাবেন।
পঞ্চমত, প্যারেন্ট ডিরেক্টরি সিঙ্ক (sync) করুন। এটি একটি সূক্ষ্ম এজ কেস (edge case) মোকাবিলা করে। রিনেম করার ফলে ডিরেক্টরি এন্ট্রি আপডেট হয়, কিন্তু ডিরেক্টরি মেটাডেটা নিজেই কার্নেলের পেজ ক্যাশে থাকতে পারে। সফল রিনেমের পর হঠাৎ বিদ্যুৎ চলে গেলে ফাইল সিস্টেম এমন একটি অবস্থায় পড়ে যেতে পারে যেখানে নতুন inode রেফারেন্সটি স্থায়ীভাবে রেকর্ড করা হয়নি। ডিরেক্টরি সিঙ্ক করলে সেই মেটাডেটা আপডেটটি ডিস্কে ফোর্স করা হয় এবং ট্রানজ্যাকশনটি সম্পন্ন হয়।
একটি বাস্তবসম্মত 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 ফ্ল্যাগটির অর্থ হলো "রাইট করুন, কিন্তু ফাইলটি আগে থেকেই থাকলে ব্যর্থ (fail) হন।" এটি একটি UUID সংঘর্ষ বা পূর্ববর্তী কোনো ক্র্যাশ হওয়া প্রসেসের ফেলে রাখা টেম্পোরারি ফাইল থেকে সুরক্ষা দেয়। যদি কেউ আপনার টেম্পোরারি ফাইলের জায়গায় কোনো ক্ষতিকারক ফাইল রেখে থাকে, তবে আপনি সেখানে থাকা ফাইলটি ওভাররাইট করার পরিবর্তে তাৎক্ষণিকভাবে তা জানতে পারবেন।
0o600 পারমিশন মাস্ক টেম্পোরারি ফাইলটি শুধুমাত্র ওনার-রিড (owner-read) এবং ওনার-রাইট (owner-write) পারমিশন দিয়ে তৈরি করে। কনফিগ ফাইলগুলোতে প্রায়ই সিক্রেট, API টোকেন বা প্রাইভেট রিপোজিটরি URL থাকে। ফাইলটি প্রস্তুত হওয়ার সময় সিস্টেমের অন্য ব্যবহারকারীদের সেটি দেখার সুযোগ দেওয়ার কোনো কারণ নেই।
ফাইল এবং তারপর ডিরেক্টরির ওপর আলাদা sync কল করার বিষয়টি লক্ষ্য করুন। অনেক ডেভেলপার ডিরেক্টরি সিঙ্ক বাদ দিয়ে দেন কারণ এটি অপ্রয়োজনীয় মনে হয়। কিন্তু এটি অপ্রয়োজনীয় নয়। Ext4, APFS এবং NTFS—সবগুলোই ডিরেক্টরি আপডেট ভিন্নভাবে হ্যান্ডেল করে, তবে পারফরম্যান্সের জন্য মেটাডেটা রাইটগুলোকে ব্যাচ (batch) করার একটি সাধারণ অভ্যাস তাদের সবার মধ্যেই আছে। আপনি যদি বিদ্যুৎ বিভ্রাট থেকে ডেটা সুরক্ষিত রাখতে চান, তবে ডিরেক্টরি সিঙ্ক হলো চূড়ান্ত সিল (seal)।
catch ব্লকের ক্লিনআপটি উদ্দেশ্যমূলকভাবে রক্ষণাত্মকভাবে করা হয়েছে। ফাইলহ্যান্ডেল খোলার পরে যদি কোনো ত্রুটি (throw) ঘটে, তবে কোডটি হ্যান্ডেলটি বন্ধ করার এবং অস্থায়ী ফাইলটি মুছে ফেলার চেষ্টা করে এবং যেকোনো গৌণ ত্রুটি এড়িয়ে যায় যাতে মূল ত্রুটিটি (exception) স্পষ্টভাবে প্রকাশ পায়। আপনি নিশ্চয়ই চান না যে ক্লিনআপের সময় কোনো পারমিশন এরর আসল বাগটিকে আড়াল করে দিক।
যেখানে এই প্যাটার্ন আর কাজে আসে না
অ্যাটমিক ফাইল রিপ্লেসমেন্ট 'টর্ন রাইটস' (torn writes) প্রতিরোধ করে। কিন্তু এটি 'লস্ট আপডেট' (lost updates) প্রতিরোধ করতে পারে না। যদি আপনার CLI-এর দুটি ইনস্ট্যান্স একই সাথে একই কনফিগ পড়ে, উভয়ই মেমরিতে তাদের এডিট সম্পন্ন করে, উভয়ই নতুন টেম্প ফাইল লেখে এবং উভয়ই তাদের রিনেম অপারেশন চালায়, তবে দ্বিতীয় রিনেমটি জয়ী হবে। প্রথম প্রসেসটি দ্বিতীয় প্রসেসের পরিবর্তনগুলো দেখতে পায়নি। আপনার অ্যাপ্লিকেশনের ওপর ভিত্তি করে এর মানে হতে পারে যে, একজন ব্যবহারকারী একটি টার্মিনালে একটি সেটিংস যোগ করলেন এবং অন্য একজন ব্যবহারকারী অন্য একটি টার্মিনালে সেটি সরিয়ে ফেললেন, যার ফলে চূড়ান্ত ফাইলে কেবল শেষ রাইটারের পরিবর্তনটিই প্রতিফলিত হবে।
যদি আপনার টুলের কনকারেন্ট মিউটেটর (concurrent mutators) সাপোর্ট করার প্রয়োজন হয়, তবে অ্যাটমিক রাইটের পাশাপাশি আপনার একটি কোঅর্ডিনেশন মেকানিজম প্রয়োজন হবে। সাধারণ ক্ষেত্রে একটি অ্যাডভাইজরি লক ফাইল (advisory lock file) কাজ করে। কনফিগ ফাইলের ভেতরে ভার্সন ভেক্টর (version vectors) বা একটি মনোটোনিক রিভিশন নম্বর (monotonic revision number) থাকলে কলিশন শনাক্ত করতে সাহায্য করতে পারে, যাতে দ্বিতীয় রাইটার পুনরায় চেষ্টা করতে পারে। এগুলো জটিলতা বাড়িয়ে দেয়, আর জটিলতার মধ্যেই বাগ লুকিয়ে থাকে।
এই কারণেই সীমানাটি গুরুত্বপূর্ণ। একটি সিঙ্গেল JSON ব্লব যা একটি প্রসেস মাঝে মাঝে আপডেট করে, সেটি অ্যাটমিক ফাইল রাইটের জন্য একটি উপযুক্ত প্রার্থী। যখন আপনি একাধিক রেকর্ড পরিচালনা করা, স্কিমা (schema) প্রয়োগ করা বা কনকারেন্ট মিউটেশন নিয়ে চিন্তিত হতে শুরু করবেন, তখন বুঝবেন আপনি ফাইল সিস্টেমের সীমাবদ্ধতা ছাড়িয়ে গেছেন। SQLite ঠিক এই কারণেই তৈরি করা হয়েছে। এটি আপনাকে একটি সিঙ্গেল হোস্ট-লোকাল ফাইলের ভেতরেই অ্যাটমিক ট্রানজ্যাকশন, রোলব্যাক জার্নাল এবং কনকারেন্ট রিডার ও রাইটারদের সঠিক হ্যান্ডলিং প্রদান করে। একটি চতুর ফাইল প্রোটোকল কোনো ডাটাবেস নয়, এবং অন্য কিছু হওয়ার ভান করে আপনার রক্ষণাবেক্ষণ বাজেট নষ্ট করা উচিত নয়।
আসল শিক্ষা
পরবর্তী সময়ে যখন আপনি কোনো CLI টুলের ভেতরে writeFile ব্যবহার করতে যাবেন, একটু থামুন। সিরিয়ালাইজেশন (Serialization) কঠিন অংশ নয়; কঠিন হলো ডিউরেবিলিটি (Durability)। কনফিগ ফাইলগুলো স্ট্রিম করার জন্য খুব ছোট এবং ট্রাঙ্কেট (truncate) করার জন্য খুব গুরুত্বপূর্ণ। সম্পূর্ণ পেলোডটি একটি লুকানো সাইবলিং ফাইলে লিখুন, সেটি ফ্লাশ করুন, রিনেমের মাধ্যমে কমিট করুন এবং ডিরেক্টরিকে তা জানিয়ে দিন। আপনার ব্যবহারকারী Ctrl-C চাপতে পারেন, পাওয়ার কর্ড টেনে বের করে দিতে পারেন বা ল্যাপটপের ঢাকনা বন্ধ করে দিতে পারেন। যখন মেশিনটি আবার চালু হবে, ফাইলটিতে হয় পুরনো ডেটা থাকবে অথবা নতুন ডেটা থাকবে। মাঝখানের কিছু থাকবে না।
