ext4 আর Journaling — ক্র্যাশ হলে কী বাঁচে, কী হারায়
ext4 and Journaling: Crash Consistency
একটা ফাইলে ডেটা append করা কার্নেলের কাছে একটা atomic অপারেশন মনে হয়, কিন্তু আসলে তিনটা আলাদা ডিস্ক-write -- inode size, block bitmap, data block। মাঝপথে power গেলে এই তিনটা অসামঞ্জস্যপূর্ণ অবস্থায় থেকে যেতে পারে। এই লেসনে সেই সমস্যাটা সংখ্যা দিয়ে দেখা হবে, journaling কীভাবে সমাধান করে তা বোঝা হবে, আর write()/fsync()-এর প্রকৃত durability contract সম্পূর্ণ খোলা হবে -- সহ ২০০৯ সালের সেই বিখ্যাত ext4 ডেটা-হারানোর গল্প যেটা আজকের delayed-allocation নিয়মগুলো তৈরি করেছে।
আগে এটা বুঝি
আগের লেসনের শেষে একটা প্রশ্ন তোলা হয়েছিল ইচ্ছাকৃতভাবে: write(fd, "X", 1) একটা ফাইলের শেষে ডেটা যোগ করলে, ঠিক কতটা ডিস্ক-লেখা লাগে?
উত্তর — অন্তত তিনটা আলাদা জায়গা:
- inode-এ
i_sizeবাড়াতে হয় (ফাইল বড় হয়েছে) - block bitmap-এ নতুন data block-টা “ব্যবহৃত” চিহ্নিত করতে হয়
- data block-এ প্রকৃত বাইট লিখতে হয়
তিনটা আলাদা write, তিনটা আলাদা ডিস্ক sector, আর ডিস্ক এই তিনটাকে atomic-ভাবে একসাথে লিখতে পারে না। এখন প্রশ্ন: power ঠিক এই তিনটার মাঝখানে কোনো একটা মুহূর্তে চলে গেলে কী থাকে?
- শুধু inode size বেড়েছে, কিন্তু bitmap বা data block আপডেট হয়নি — ফাইল বড় দেখাবে, কিন্তু নতুন অংশে গার্বেজ ডেটা (পুরনো, অন্য কোনো ফাইলের অবশিষ্ট বাইট) থাকবে।
- শুধু bitmap আপডেট হয়েছে, inode size বাড়েনি — একটা ব্লক “ব্যবহৃত” হিসেবে চিরকালের জন্য লক হয়ে থাকবে, কোনো ফাইল তাকে দাবিও করছে না — filesystem-এ একটা স্থায়ী leak।
- শুধু data block-এ লেখা হয়েছে, কিন্তু bitmap/inode কিছুই আপডেট হয়নি — লেখাটাই কার্যত হারিয়ে গেছে, ফাইল আগের মতোই ছোট রয়ে গেছে।
তিনটাই বাস্তব, তিনটাই ঘটে — আর প্রতিটাই filesystem-কে একটা অসামঞ্জস্যপূর্ণ (inconsistent) অবস্থায় ফেলে দেয়। এই লেসনের কেন্দ্রীয় প্রশ্ন: এই অসামঞ্জস্য কীভাবে ধরা পড়ে, কীভাবে সারানো হয়, আর কীভাবে প্রথমেই এড়ানো যায়।
মূল ধারণা
Crash consistency সমস্যা — সংখ্যায় দেখা
আগের লেসনের struct ext4_inode আর directory entry মনে করুন। একটা append অপারেশনের সম্পূর্ণ ধাপ:
| ধাপ | কী বদলায় | কোথায় (ডিস্কে) |
|---|---|---|
| ১ | নতুন data block বরাদ্দ | block bitmap (১ বিট) |
| ২ | প্রকৃত ডেটা লেখা | data block (নতুন বরাদ্দ করা ব্লক) |
| ৩ | inode-এ নতুন ব্লকের ঠিকানা যোগ (i_block[] বা extent) | inode table |
| ৪ | i_size বাড়ানো | inode table (একই entry, ভিন্ন ফিল্ড) |
| ৫ | i_mtime আপডেট | inode table |
এই পাঁচটা logical পরিবর্তন হয়তো ২-৩টা physical ডিস্ক-sector write-এ পড়ে (inode-এর ফিল্ডগুলো কাছাকাছি, একই sector-এ পড়তে পারে), কিন্তু block bitmap আর data block আলাদা sector, প্রায় সবসময় ডিস্কের ভিন্ন জায়গায় — কারণ bitmap গোটা block group-এর শুরুতে, data block গোটা filesystem জুড়ে ছড়ানো।
ডিস্ক (বা SSD-র firmware) নিজে একবারে একটা sector-ই atomic-ভাবে লেখার গ্যারান্টি দেয় (৫১২ বাইট বা ৪KB, ডিস্ক-প্রজন্মভেদে) — একাধিক sector-জুড়ে কোনো atomicity নেই, আর OS একাধিক write-কে reorder করতে পারে performance-এর জন্য (I/O scheduler, write cache)। তাই এই পাঁচটা পরিবর্তনের মধ্যে যেকোনো ক্রমে, যেকোনো subset ডিস্কে পৌঁছাতে পারে crash-এর আগে।
স্বাভাবিক ক্রম: [bitmap আপডেট] → [data লেখা] → [inode size+ptr আপডেট]
Crash point A -- শুধু bitmap:
bitmap: ব্লক ৫০০০ "ব্যবহৃত" ┐
data block ৫০০০: এখনো পুরনো/গার্বেজ ├─► ফলাফল: block leak,
inode: i_size অপরিবর্তিত, ptr নেই ┘ fsck ছাড়া কখনো মুক্ত হবে না
Crash point B -- bitmap + data, inode বাকি:
bitmap: ব্লক ৫০০০ "ব্যবহৃত" ┐
data block ৫০০০: নতুন ডেটা লেখা হয়ে গেছে ├─► ফলাফল: ডেটা ডিস্কে আছে,
inode: i_size অপরিবর্তিত, ptr নেই ┘ কিন্তু কোনো ফাইল তা দাবি করে না --
write() সফল রিটার্ন দিয়েছিল, কিন্তু
ফাইল কখনো বড়ই হলো না (silent data loss)
Crash point C -- inode size বেড়েছে, data/bitmap বাকি:
bitmap: ব্লক ৫০০০ এখনো "ফ্রি" ┐
data block: স্পর্শই হয়নি ├─► ফলাফল: ফাইল বড় দেখাচ্ছে (i_size বেড়েছে)
inode: i_size বেড়েছে, কিন্তু ptr নেই ┘ কিন্তু নতুন অংশ পড়লে গার্বেজ/শূন্য --
এটাই ২০০৯-এর বিখ্যাত ext4 বাগের মূল রূপতিনটা পয়েন্টই সমান বাস্তব — আর কোনটাই ধরার জন্য কোনো error message নেই, কারণ write() তার নিজের অংশটুকু সফলভাবেই শেষ করেছিল; সমস্যা সেই সফল write-এর পরবর্তী কোনো এক মুহূর্তে ঘটেছে।
পুরনো সমাধান — fsck, আর কেন সেটা যথেষ্ট ছিল না
Journaling আসার আগে (ext2, পুরনো Unix filesystem) সমাধান ছিল: প্রতিবার unclean shutdown-এর পরে বুট হওয়ার সময় fsck (filesystem check) চালানো। fsck করে কী:
- প্রতিটা inode স্ক্যান — valid mode, reasonable size, link count কি directory entry গণনার সাথে মিলছে
- প্রতিটা directory ট্রাভার্স — reachability গ্রাফ বানানো (কোন inode কোনো directory থেকে পৌঁছানো যায়)
- block bitmap বনাম প্রকৃত ব্যবহার মিলিয়ে দেখা — bitmap বলছে ব্যবহৃত কিন্তু কোনো inode দাবি করছে না এমন ব্লক খুঁজে বের করে
lost+found-এ পাঠানো (আগের লেসনেরdebugfs ls-এ দেখা সেইlost+founddirectory ঠিক এই কাজের জন্য) - অসামঞ্জস্য পেলে heuristic দিয়ে “সবচেয়ে যুক্তিসঙ্গত” অবস্থায় ফেরত আনা
সমস্যাটা স্কেলে: fsck-কে পুরো filesystem-এর প্রতিটা inode আর bitmap স্ক্যান করতে হয়, কারণ সে জানে না ক্র্যাশের সময় কোথায় পরিবর্তন হচ্ছিল — তাই তাকে সবখানে খুঁজতে হয়।
একটা ২০০৯ সালের ১TB ডিস্কে fsck লাগত ২০-৩০ মিনিট। আজকের ২০TB ডিস্কে সেই একই linear-scan পদ্ধতিতে কয়েক ঘণ্টা লাগবে — production সার্ভারের জন্য পুরোপুরি অচল, বিশেষত যদি প্রতিবার unclean reboot-এই এটা লাগে।
Journaling — সমাধানের মূলনীতি
মূল ধারণাটা আশ্চর্যজনকভাবে সহজ: প্রকৃত জায়গায় পরিবর্তন লেখার আগে, “আমি কী করতে যাচ্ছি” তার একটা কপি একটা আলাদা, বিশেষ append-only লগে (journal) লিখে ফেলো, আর সেই লেখাটাকে সম্পূর্ণ হয়েছে বলে চিহ্নিত (commit) করো। তারপরই প্রকৃত জায়গায় (inode table, bitmap, data block) পরিবর্তনগুলো প্রয়োগ (checkpoint) করো।
সাধারণ (journal ছাড়া): বদল সরাসরি চূড়ান্ত জায়গায় লেখা
[inode] [bitmap] [data] ← যেকোনো একটার পরে crash = অসামঞ্জস্য
Journaling: প্রথমে journal-এ, তারপর চূড়ান্ত জায়গায়
ধাপ ১: journal-এ লেখা [জার্নাল entry: "inode X →...,
bitmap Y →..., data Z →..."]
ধাপ ২: commit marker [এই transaction সম্পূর্ণ, valid]
ধাপ ৩: checkpoint [inode] [bitmap] [data] ← আসল জায়গায়কেন এটা কাজ করে: crash journal লেখার মাঝপথে হলে (commit marker-এর আগে), পুরো journal entry-টাই অসম্পূর্ণ/invalid হিসেবে চিহ্নিত থাকে (checksum বা marker মেলে না) — বুটের সময় সেটা উপেক্ষা করা হয়, filesystem ঠিক আগের সামঞ্জস্যপূর্ণ অবস্থায় ফিরে যায়, যেন transaction-টা কখনো শুরুই হয়নি। আর crash commit-এর পরে, checkpoint-এর মাঝপথে হলে — বুটের সময় journal replay করে (আবার সেই একই লেখাগুলো চূড়ান্ত জায়গায় বসিয়ে দেওয়া) নিশ্চিত করা হয় সবকিছু সম্পূর্ণভাবে প্রয়োগ হয়েছে। কোনো অবস্থাতেই আংশিক পরিবর্তন দৃশ্যমান হয় না — হয় পুরো transaction, নয় কিছুই না। এটাই atomicity, ঠিক ডাটাবেসের transaction-এর মতো (Level 8-এর সাথে যোগসূত্র, নিচে আরো)।
পুনরুদ্ধারের সময় এখন:
journal সাধারণত ডিফল্টে ১২৮MB (ext4, mkfs-এর সময় adjustable) — যত বড়ই ডিস্ক হোক, recovery সময় journal-এর আকারের সমানুপাতিক, ডিস্কের আকারের না। এই কারণেই ext4-এ crash-এর পরে বুট প্রায়ই সেকেন্ডে হয়, যেখানে ext2-তে ঘণ্টার পর ঘণ্টা লাগত।
Transaction আর checkpoint — ব্যাচিং
প্রতিটা write()-এর জন্য আলাদাভাবে journal-এ commit করা অতি ধীর হতো (প্রতিটা ছোট write-এ একটা সম্পূর্ণ sync journal-write)। ext4 তাই একাধিক filesystem-অপারেশন একসাথে একটা transaction-এ ব্যাচ করে (ডিফল্টে প্রতি ৫ সেকেন্ডে একবার commit, বা journal buffer ভরে গেলে) — একই সময়ের ভেতরে ঘটা সব inode/bitmap/data পরিবর্তন একসাথে একটা commit-এ যায়।
Journal নিজে একটা সীমিত, বৃত্তাকার (circular) বাফার — অসীম বড় না। Commit হওয়ার পরে, filesystem একসময় সেই পরিবর্তনগুলো চূড়ান্ত জায়গায় লিখে দেয় (checkpoint), আর তারপর journal-এর সেই অংশটা পুনরায় ব্যবহারযোগ্য হয়ে যায়। যদি checkpointing journal-এর গতির সাথে তাল মেলাতে না পারে, journal ভরে যায় আর নতুন transaction-কে অপেক্ষা করতে হয় — heavy-write workload-এ এটাই একটা প্রকৃত bottleneck হতে পারে (নিচে realworld-এ বিস্তারিত)।
ext4-এর তিনটা journaling mode
data= mount option তিনটা mode বেছে নেয়, আর তিনটার trade-off সম্পূর্ণ ভিন্ন — এটাই এই লেসনের সবচেয়ে ব্যবহারিক অংশ:
| Mode | Metadata journal-এ? | Data journal-এ? | Crash-এর পরে গ্যারান্টি | Performance |
|---|---|---|---|---|
journal | হ্যাঁ | হ্যাঁ (পুরো ডেটাও journal হয়) | সবচেয়ে শক্ত — metadata আর data দুটোই সবসময় সামঞ্জস্যপূর্ণ, কখনো stale/গার্বেজ ডেটা দেখা যায় না | সবচেয়ে ধীর — প্রতিটা বাইট দুইবার লেখা হয় (journal-এ, তারপর চূড়ান্ত জায়গায়) |
ordered (ডিফল্ট) | হ্যাঁ | না, কিন্তু অর্ডার নিশ্চিত | data block সবসময় metadata-র আগে ডিস্কে লেখা হয় নিশ্চিত করা হয় — তাই inode বলবে “এখানে ডেটা আছে” শুধু তখনই যখন সেই ডেটা সত্যিই ডিস্কে আছে; crash হলে সর্বোচ্চ ক্ষতি একটা অসম্পূর্ণ write হারানো, কখনো গার্বেজ/পুরনো-অন্য-ফাইলের ডেটা প্রকাশ পাওয়া না | মাঝারি — data দুইবার লেখা হয় না, শুধু order গ্যারান্টি করার খরচ |
writeback | হ্যাঁ | না, কোনো অর্ডার গ্যারান্টি না | সবচেয়ে দুর্বল — crash-এর পরে inode-এ “নতুন ডেটা আছে” দেখাতে পারে কিন্তু data block-এ আসলে পুরনো, অন্য কোনো ফাইলের ফেলে যাওয়া বাইট থাকতে পারে (stale data exposure) — এটা শুধু performance bug না, নিরাপত্তা সমস্যাও (আগের owner-এর ডেটা leak) | সবচেয়ে দ্রুত — শুধু metadata journal-এর খরচ |
Barrier আর FUA — journaling-এর আসল ভিত্তি
Journaling-এর পুরো যুক্তি নির্ভর করে একটা ধরে নেওয়ার উপর: “commit marker” journal-এ লেখার সময়, তার আগের সব journal-এন্ট্রি ইতিমধ্যে ডিস্কে পৌঁছে গেছে। কিন্তু আধুনিক ডিস্ক আর SSD-র নিজস্ব write cache থাকে, যেখানে OS-এর কাছে “লেখা শেষ” বলে দেওয়ার পরেও ডেটা আসলে volatile RAM-এ বসে থাকতে পারে — power গেলে সেটাও হারিয়ে যায়, journal-এর নিজস্ব order গ্যারান্টি ভেঙে।
এই সমস্যা সমাধানে দুইটা মেকানিজম:
- Write barrier — কার্নেল ডিস্ককে বলে “এই বিন্দুর আগের সব write, এই বিন্দুর পরের সব write-এর আগেই স্থায়ীভাবে সম্পন্ন করো” — একটা ordering fence, যা কার্নেল journal commit-এর ঠিক আগে-পরে ব্যবহার করে।
- FUA (Force Unit Access) — একটা নির্দিষ্ট write-কে সরাসরি বলা “এটা cache-এ না, স্থায়ী মিডিয়ামেই লিখে তবে সফল বলো” — commit marker নিজে এই flag নিয়ে লেখা হয়, কারণ সেটাই সবচেয়ে গুরুত্বপূর্ণ একক বাইট।
ডিস্কের write cache যদি ভুলভাবে (বা battery-backed নয় এমন cheap hardware-এ) “লেখা সম্পন্ন” বলে মিথ্যা রিপোর্ট করে (barrier উপেক্ষা করে), তাহলে journaling-এর পুরো গ্যারান্টিই ভেঙে পড়ে — এই কারণেই এন্টারপ্রাইজ storage-এ hdparm -W দিয়ে write cache আচরণ যাচাই করা, বা battery-backed RAID controller ব্যবহার করা, প্রোডাকশন ডেটাবেস সার্ভারে একটা মৌলিক checklist item।
write() durability দেয় না, fsync() দেয় — পূর্ণ কনট্র্যাক্ট
আগের লেসনের misconception-এ বলা হয়েছিল close() durability দেয় না। এখন সম্পূর্ণ কনট্র্যাক্টটা:
| কল | কী গ্যারান্টি দেয় | কী দেয় না |
|---|---|---|
write() | ডেটা page cache-এ পৌঁছেছে; অন্য কোনো process একই fd/inode দিয়ে পড়লে সাথে সাথেই নতুন ডেটা দেখবে (cache coherent) | ডিস্কে পৌঁছানোর কোনো গ্যারান্টি না — কার্নেল লেখাটা সেকেন্ড-মিনিট পরে writeback করতে পারে, বা crash হলে কখনোই না |
close() | fd মুক্ত, reference count কমেছে | কোনো durability গ্যারান্টি না — dirty page cache অক্ষত থেকে যায় যতক্ষণ না kernel writeback thread বা explicit sync তাকে ছোঁয় |
fsync(fd) | এই fd-র inode-এর সব dirty data এবং metadata (size, timestamp, block pointer) ডিস্কে (বা অন্তত storage device-এর স্থায়ী মিডিয়ামে, write cache-এর ওপারে, barrier/FUA সহ) পৌঁছেছে, সিস্টেম কল ফেরত আসার আগেই | শুধু এই inode-এর গ্যারান্টি — parent directory-র কোনো পরিবর্তন (নতুন নাম যোগ) কভার করে না |
fdatasync(fd) | fsync-এর মতো, কিন্তু শুধু ডেটা আর সেই metadata যা read করতে দরকার (যেমন size) — mtime-এর মতো pure-bookkeeping metadata বাদ দিতে পারে | সামান্য দ্রুত, কিন্তু গ্যারান্টি প্রায় সমান |
সবচেয়ে গুরুত্বপূর্ণ, প্রায়ই ভুলে যাওয়া অংশ: একটা নতুন ফাইল তৈরি করে (বা rename করে) শুধু সেই ফাইল fsync() করলেই যথেষ্ট না। কারণ ফাইলের নামটা directory-র নিজস্ব data block-এ থাকে (আগের লেসনের কেন্দ্রীয় সত্য) — আর directory নিজেও একটা inode, তার নিজস্ব dirty metadata, যেটা আলাদাভাবে fsync না করা পর্যন্ত ডিস্কে পৌঁছানোর গ্যারান্টি নেই।
Copy-on-write filesystem — একটা সম্পূর্ণ ভিন্ন কৌশল
journaling সমস্যাটা সমাধান করে “প্রথমে লগে লিখে, তারপর জায়গায় বসানো” দিয়ে — কিন্তু জায়গাটা (inode table-এর নির্দিষ্ট slot, bitmap-এর নির্দিষ্ট বিট) সবসময় একই থাকে, শুধু তার লেখার আগে একটা নিরাপত্তা-কপি থাকে। btrfs আর ZFS সম্পূর্ণ ভিন্ন দর্শন নেয়: কখনো বিদ্যমান কোনো ব্লক in-place ওভাররাইট করে না। একটা ব্লক বদলাতে হলে, নতুন একটা খালি ব্লকে নতুন সংস্করণ লেখা হয়, তারপর তার parent pointer (আর তার parent-এর parent, root পর্যন্ত) নতুন সংস্করণের দিকে আপডেট করা হয় — একটা নতুন root তৈরি না হওয়া পর্যন্ত পুরনো root-ই বৈধ থাকে, আর root বদলটা একটা একক atomic pointer-write।
এর ফলে কোনো journal-ই লাগে না crash-consistency-র জন্য — crash হলে হয় নতুন root সম্পূর্ণ commit হয়েছে (নতুন অবস্থা দৃশ্যমান), নয়তো হয়নি (পুরনো root, পুরনো অবস্থা, ডেটা অক্ষত) — কখনো মাঝামাঝি অবস্থা সম্ভবই না, কারণ পুরনো ডেটা কখনো destroy হয়ইনি। এই একই মেকানিজম বিনামূল্যে snapshot দেয় (পুরনো root-টা রেখে দিলেই একটা point-in-time কপি)। এই লেসনের বিস্তারিত btrfs/ZFS আলোচনা আসবে না — শুধু এইটুকু মনে রাখা দরকার যে journaling একমাত্র সমাধান না, বরং দুইটা মৌলিকভাবে ভিন্ন দর্শনের একটা: log-then-apply (journaling) বনাম never-overwrite (copy-on-write)।
ভেতরে কী ঘটছে
data=ordered-এ একটা append — ধাপে ধাপে
- userspace: write() কল, সফল রিটার্ন৪০৯৬ বাইট page cache-এ dirty হিসেবে বসল, syscall ফেরত এলো -- এখনো ডিস্কে কিছুই যায়নি, এটাই আগের লেসনের 'write() এখানেই ফিরে আসে' লাইনের বিস্তারিত রূপ
- kernel: dirty page + dirty inode metadata চিহ্নিতi_size বাড়বে বলে চিহ্নিত (এখনো commit হয়নি), নতুন ব্লকের bitmap বরাদ্দ in-memory (জার্নালে না গিয়ে সরাসরি স্থায়ী bitmap বদলায় না -- ext4 জার্নাল লেয়ারের ভেতর দিয়ে যায়)
- ordered-mode নিয়ম -- data আগে, metadata journal পরেকার্নেল নিশ্চিত করে dirty data page (নতুন content) ডিস্কে লেখা *শুরু* হবে journal transaction commit হওয়ার *আগেই় -- এই ক্রমটাই ordered mode-এর পুরো গ্যারান্টির উৎস
- writeback thread: data block ডিস্কে flushপ্রকৃত ডেটা (৪০৯৬ বাইট) এখন data block-এ লেখা -- এখনো কোনো inode/bitmap বদলায়নি স্থায়ীভাবে
- jbd2 (ext4-এর journaling layer): transaction journal-এ লেখাinode-এর নতুন i_size, নতুন block pointer, bitmap বদল -- এই মেটাডেটা পরিবর্তনগুলো জার্নালে (ডিস্কের একটা রিজার্ভড এলাকায়) লেখা হয়
- commit marker journal-এ, FUA দিয়েtransaction সম্পূর্ণ চিহ্নিত -- এখন থেকে এই transaction 'নিশ্চিত', crash হলেও replay হবে
- checkpoint (কিছু পরে): inode table ও bitmap স্থায়ী জায়গায় আপডেটjournal-এর কনটেন্ট এখন প্রকৃত inode table/bitmap-এ প্রয়োগ হয় -- এই ধাপের পরে journal-এর সেই অংশ পুনর্ব্যবহারযোগ্য
লক্ষ করুন data block লেখা metadata journal commit-এর আগে ঘটে ordered mode-এ — এই একটা অর্ডারিং নিয়মই নিশ্চিত করে crash যেকোনো বিন্দুতে হোক, inode কখনো এমন একটা ব্লকের দিকে নির্দেশ করবে না যেখানে এখনো নতুন ডেটা লেখাই শেষ হয়নি।
উদাহরণ
২০০৯ সালের ext4 ডেটা-হারানোর ঘটনা — সম্পূর্ণ গল্প, সংখ্যাসহ
২০০৯ সালের শুরুতে, ext4 নতুন ডিফল্ট হিসেবে বেশ কিছু Linux distribution-এ (Ubuntu, Fedora) আসার পরপরই, ব্যবহারকারীরা একটা ভয়ংকর প্যাটার্ন রিপোর্ট করা শুরু করলেন: crash বা power-loss-এর পরে, সম্প্রতি তৈরি হওয়া ফাইল ০ বাইট আকারে দেখা যাচ্ছিল — content সম্পূর্ণ উধাও, ফাইল নিজে বেঁচে (name+inode আছে), কিন্তু ভেতরে কিছুই নেই।
মূল কারণ — delayed allocation। Performance বাড়াতে ext4 একটা optimization ব্যবহার করে: write() কল হলে সাথে সাথেই ডিস্কে কোথায় data block বরাদ্দ হবে তা ঠিক করে না — সেই সিদ্ধান্ত পিছিয়ে দেয় (delay) যতক্ষণ না প্রকৃতপক্ষে writeback হচ্ছে (সাধারণত কয়েক সেকেন্ড পরে)। কারণ: একই সময়ে অনেক ছোট write একসাথে এলে, delayed allocation তাদের একসাথে, contiguous block-এ বরাদ্দ করতে পারে (আগের লেসনের extent আলোচনার সরাসরি সুবিধা) — ছোট ছোট আলাদা বরাদ্দের চেয়ে অনেক কম fragmentation।
সমস্যাটা এই অপ্টিমাইজেশন আর একটা খুবই সাধারণ অ্যাপ্লিকেশন-প্যাটার্নের সংঘর্ষে:
/* বহু প্রোগ্রাম (টেক্সট এডিটর, কনফিগ-লেখা টুল) এই প্যাটার্নে "atomic
save" করত -- fsync ছাড়াই, কারণ ext3-তে এটা কার্যত নিরাপদই ছিল */
fd = open("file.tmp", O_WRONLY | O_CREAT | O_TRUNC, 0644);
write(fd, new_content, len);
close(fd);
rename("file.tmp", "file.conf"); /* কোনো fsync নেই! */ext3-তে (পুরনো) এটা প্রায় সবসময় নিরাপদ ছিল ভিন্ন এক কারণে — ext3-এর ডিফল্ট journaling আচরণ (data=ordered, কিন্তু delayed allocation ছাড়া) মানে write()-এর কয়েক সেকেন্ডের মধ্যেই data block বরাদ্দ এবং লেখা হয়ে যেত, তারপরই rename()-এর metadata commit হতো — কার্যত data সবসময় rename-এর অনেক আগেই ডিস্কে পৌঁছে যেত বলে দুর্ঘটনাক্রমে নিরাপদ মনে হতো (কোনো explicit গ্যারান্টি ছিল না, কিন্তু ব্যবহারিকভাবে কাজ করত)।
ext4-এ delayed allocation দুইটা ঘটনাকে বিপজ্জনকভাবে কাছাকাছি এনে দিল:
| সময় | ঘটনা |
|---|---|
| t1 | write() — ডেটা page cache-এ, ডিস্কে block বরাদ্দ এখনো হয়নি (delayed) |
| t2 | close() — কোনো durability গ্যারান্টি নেই, page cache-এ dirty থেকে যায় |
| t3 | rename("file.tmp", "file.conf") — এই metadata অপারেশন তুলনামূলক দ্রুত commit হয় (ছোট, priority বেশি) — নতুন নামটা এখনই কার্যকর |
| t4 | CRASH, এখনো t1-এর data block writeback হয়নি (ext4 এটা সাধারণত ৩০-১৫০ সেকেন্ড পর্যন্ত পিছিয়ে দিতে পারত পুরনো ডিফল্টে) |
| পুনরায় বুট | file.conf নামটা আছে (rename commit হয়ে গিয়েছিল t3-তে), inode আছে, কিন্তু data block কখনো লেখাই হয়নি — ০ বাইট বা আংশিক গার্বেজ |
সংখ্যায়: ext4-এর পুরনো delayed-allocation writeback interval ডিফল্টে dirty_expire_centisecs (৩০ সেকেন্ড) থেকে শুরু করে কনফিগারেশন অনুযায়ী আরো বেশি হতে পারত। ঐ পুরো জানালার ভেতরে যেকোনো crash মানে সেই জানালায় লেখা যেকোনো নতুন/পরিবর্তিত ফাইল শূন্য-বাইট হয়ে যাওয়ার ঝুঁকি — আর ২০০৯ সালে ল্যাপটপ crash, ডেস্কটপ power failure, অনেক বেশি সাধারণ ছিল আজকের তুলনায় (SSD/battery-backed সিস্টেমের প্রসারের আগে)।
সমাধান — Ted Ts’o-র (ext4-এর মূল মেইনটেইনার) নেতৃত্বে দুই স্তরের ফিক্স:
rename()-এর heuristic detection। কার্নেল ২.৬.৩০-এ একটা heuristic যোগ হলো: যদি একটাrename()একটা ফাইলকে replace করে (নতুন নাম আগে থেকেই অস্তিত্বশীল কোনো ফাইলের জায়গায় বসছে — ঠিক এই atomic-save প্যাটার্নের signature), কার্নেল স্বয়ংক্রিয়ভাবে rename-এর আগে সেই ফাইলের delayed data flush করে দেয় — অ্যাপ্লিকেশনকে কিছু বদলাতে না হয়েও।- নীতিগত অবস্থান — অ্যাপ্লিকেশনের দায়িত্ব। Ted Ts’o-র বিখ্যাত ব্যাখ্যা ছিল: POSIX কখনোই
fsync()ছাড়া durability গ্যারান্টি দেয়নি — ext3-তে যা হতো তা কাকতালীয়ভাবে নিরাপদ আচরণ ছিল, চুক্তি না। সঠিক ফিক্স আসলে অ্যাপ্লিকেশনের কোডে: rename-এর আগে/পরেfsync()করা (এই লেসনের atomic-write প্যাটার্ন, পরে দেখানো হবে)। কার্নেল heuristic শুধু practical damage control, guarantee না — যে প্যাটার্নটা heuristic-এ ধরা পড়ে না (নতুন ফাইল, replace না) তাতে এখনো ঝুঁকি থেকেই যায়।
নিজে চালিয়ে দেখুন
loopback ext4-এ তিনটা data= mode বেঞ্চমার্ক করুন
আগের লেসনের /tmp/scratch.img তৈরির পদ্ধতি পুনরায় ব্যবহার করি (dd + mkfs.ext4)।
# ধাপ ১ -- scratch filesystem বানাই (আগের লেসনের মতো, এবার একটু বড়)
dd if=/dev/zero of=/tmp/scratch2.img bs=1M count=512 2>/dev/null
mkfs.ext4 -q /tmp/scratch2.img
sudo mkdir -p /mnt/scratch2# ধাপ ২ -- data=ordered (ডিফল্ট) দিয়ে mount করে বেঞ্চমার্ক
sudo mount -o loop,data=ordered /tmp/scratch2.img /mnt/scratch2
sudo bash -c 'time (dd if=/dev/zero of=/mnt/scratch2/f bs=4k count=10000 conv=fsync 2>&1)'
sudo umount /mnt/scratch210000+0 records in
10000+0 records out
40960000 bytes (41 MB, 39 MiB) copied, 4.31 s, 9.5 MB/s
real 0m4.32s# ধাপ ৩ -- data=journal (সবচেয়ে সুরক্ষিত, সবচেয়ে ধীর হওয়ার কথা)
sudo mount -o loop,data=journal /tmp/scratch2.img /mnt/scratch2
sudo bash -c 'time (dd if=/dev/zero of=/mnt/scratch2/f bs=4k count=10000 conv=fsync 2>&1)'
sudo umount /mnt/scratch210000+0 records in
10000+0 records out
40960000 bytes (41 MB, 39 MiB) copied, 7.86 s, 5.2 MB/s
real 0m7.87s# ধাপ ৪ -- data=writeback (সবচেয়ে দ্রুত হওয়ার কথা)
sudo mount -o loop,data=writeback /tmp/scratch2.img /mnt/scratch2
sudo bash -c 'time (dd if=/dev/zero of=/mnt/scratch2/f bs=4k count=10000 conv=fsync 2>&1)'
sudo umount /mnt/scratch210000+0 records in
10000+0 records out
40960000 bytes (41 MB, 39 MiB) copied, 3.68 s, 11.1 MB/s
real 0m3.69sতিনটা সংখ্যা পাশাপাশি — journal (৭.৮৭s) \> ordered (৪.৩২s) \> writeback (৩.৬৯s), ঠিক এই লেসনের “concept” সেকশনের টেবিলের ভবিষ্যদ্বাণী অনুযায়ী। (প্রকৃত সংখ্যা আপনার ডিস্ক/VM অনুযায়ী বদলাবে, কিন্তু আপেক্ষিক ক্রমটা ধারাবাহিকভাবে একই থাকবে — conv=fsync প্রতিটা mode-কেই প্রকৃত barrier/commit খরচ প্রকাশ করতে বাধ্য করছে।)
# ধাপ ৫ -- dumpe2fs দিয়ে journal-এর নিজস্ব parameter দেখি
sudo dumpe2fs -h /tmp/scratch2.img 2>/dev/null | grep -iE "journal|feature"Filesystem features: ext_attr resize_inode dir_index filetype needs_recovery extent 64bit flex_bg sparse_super large_file huge_file dir_nlink extra_isize metadata_csum
Journal inode: 8
Journal backup: inode blocks
Journal size: 16384k
Journal length: 4096
Journal sequence: 0x00000012
Journal start: 0Journal inode: 8 — মজার তথ্য: journal নিজেই ext4-এ একটা সাধারণ inode (নম্বর ৮, reserved), এই লেসনের আগের লেসন থেকে শেখা সাধারণ inode স্ট্রাকচারের ভেতরেই বাস করে। Journal size: 16384k — এই ছোট (৫১২MB) filesystem-এ mkfs স্বয়ংক্রিয়ভাবে ১৬MB journal রেখেছে; বড় filesystem-এ এটা ডিফল্টে ১২৮MB পর্যন্ত যায়।
data=journal সবচেয়ে ধীর (প্রতিটা বাইট দুইবার লেখা হয়), data=writeback সবচেয়ে দ্রুত কিন্তু নিরাপত্তাহীন, data=ordered (ডিফল্ট) মাঝামাঝি -- আর fsync ছাড়া লেখা প্রায় বিনামূল্যে (page cache-এ থেমে যায়), fsync সহ প্রকৃত ডিস্ক-লেটেন্সি প্রকাশ পায়।
fsync-এর প্রকৃত খরচ -- একটা ছোট write loop দিয়ে সরাসরি মাপুন
cat > /tmp/synctest.c << 'EOF'
#include \<stdio.h>
#include \<stdlib.h>
#include \<fcntl.h>
#include \<unistd.h>
#include \<time.h>
int main(int argc, char **argv) {
int do_fsync = (argc > 1 && argv[1][0] == '1');
int fd = open("/tmp/synctest.dat", O_WRONLY | O_CREAT | O_TRUNC, 0644);
char buf[64] = "0123456789";
struct timespec t0, t1;
clock_gettime(CLOCK_MONOTONIC, &t0);
for (int i = 0; i \< 1000; i++) {
write(fd, buf, sizeof buf);
if (do_fsync) fsync(fd);
}
clock_gettime(CLOCK_MONOTONIC, &t1);
double ms = (t1.tv_sec - t0.tv_sec) * 1000.0 +
(t1.tv_nsec - t0.tv_nsec) / 1e6;
printf("%s: 1000 write, %s fsync -- মোট %.1f ms, প্রতি write %.3f ms\n",
do_fsync ? "fsync" : "no-fsync", do_fsync ? "প্রতিটার পরে" : "কোনো",
ms, ms / 1000.0);
close(fd);
return 0;
}
EOF
cc -O2 -o /tmp/synctest /tmp/synctest.c
echo "--- fsync ছাড়া ---"
/tmp/synctest 0
echo "--- fsync সহ ---"
/tmp/synctest 1--- fsync ছাড়া ---
no-fsync: 1000 write, কোনো fsync -- মোট 0.8 ms, প্রতি write 0.001 ms
--- fsync সহ ---
fsync: 1000 write, প্রতিটার পরে fsync -- মোট 6420.3 ms, প্রতি write 6.420 ms~৮০০০× পার্থক্য। fsync ছাড়া প্রতিটা write() শুধু page cache-এ একটা memcpy — মাইক্রোসেকেন্ডের নিচে। fsync সহ প্রতিটা write-কে অপেক্ষা করতে হয় একটা প্রকৃত journal commit (আর ordered mode হলে data block writeback) সম্পূর্ণ হওয়া পর্যন্ত — SSD-তে সাধারণত ১-১০ms (NAND-এর program latency + journal commit), পুরনো spinning disk-এ ১০-২০ms (মেকানিক্যাল seek + rotation)। এই সংখ্যাটাই ব্যাখ্যা করে কেন ডাটাবেস (Level 8) প্রতিটা transaction-এ আলাদা fsync না করে group commit করে — একাধিক transaction-এর জন্য একটামাত্র fsync, latency-কে amortize করে।
fsync() ছাড়া write() প্রায় বিনামূল্যে (শুধু memcpy-স্কেল খরচ, page cache-এ থামে), fsync সহ প্রতিটা write প্রকৃত ডিস্ক commit latency বহন করে -- পার্থক্য কয়েক হাজার গুণ হতে পারে, spinning disk-এ।
নিজে বানান
সঠিক atomic-file-write ফাংশন -- temp লিখো, fsync করো, rename করো, directory fsync করো
- একটা temp ফাইল বানান একই directory-তে (ভিন্ন filesystem-এ না, নাহলে rename atomic থাকবে না)
- পুরো content সেই temp ফাইলে write() করুন
- temp ফাইল fsync() করুন -- ডেটা ডিস্কে পৌঁছেছে তা নিশ্চিত করার আগ পর্যন্ত rename করবেন না
- rename() দিয়ে temp-কে চূড়ান্ত নামে সরান -- এটা একটা atomic filesystem অপারেশন
- parent directory আলাদাভাবে open() করে fsync() করুন -- rename-এর metadata পরিবর্তনটাও ডিস্কে পৌঁছেছে তা নিশ্চিত করতে
/* atomic_write.c -- একটা ফাইলে "সব-অথবা-কিছুই" আপডেট করার সঠিক প্যাটার্ন,
* ২০০৯-এর ext4 delayed-allocation ঘটনা থেকে শেখা শিক্ষাসহ।
*
* কম্পাইল: cc -Wall -Wextra -O2 -o atomic_write atomic_write.c
* চালান: ./atomic_write /tmp/config.txt "নতুন কনফিগ বিষয়বস্তু"
*/
#define _GNU_SOURCE
#include \<stdio.h>
#include \<stdlib.h>
#include \<string.h>
#include \<unistd.h>
#include \<fcntl.h>
#include \<errno.h>
#include \<libgen.h>
/* target_path-এর জন্য একটা atomic, durable write.
* রিটার্ন 0 সাফল্যে, -1 ব্যর্থতায় (errno সেট থাকবে)। */
int atomic_write_file(const char *target_path, const char *data, size_t len)
{
char dir_path[PATH_MAX];
char tmp_path[PATH_MAX];
char target_copy[PATH_MAX];
/* dirname() নিজের argument বদলে দিতে পারে, তাই কপি ব্যবহার */
strncpy(target_copy, target_path, sizeof target_copy - 1);
target_copy[sizeof target_copy - 1] = '\0';
snprintf(dir_path, sizeof dir_path, "%s", dirname(target_copy));
/* temp ফাইলের নাম -- একই directory-তে, যাতে rename() একই
filesystem-এর ভেতরে থাকে (cross-filesystem rename EXDEV দেয়,
আর copy+delete atomicity ভেঙে দেয়) */
snprintf(tmp_path, sizeof tmp_path, "%s/.atomic_XXXXXX", dir_path);
int tmp_fd = mkstemp(tmp_path); /* atomic ভাবে ইউনিক নাম বানায় ও খোলে */
if (tmp_fd < 0) return -1;
/* ধাপ ১ -- পুরো ডেটা temp ফাইলে লিখি।
এখনো crash হলে ক্ষতি নেই: temp ফাইলটা কেউ দেখছে না,
target_path এখনো পুরনো (বা অস্তিত্বহীন) content-এই আছে। */
ssize_t written = 0;
while (written < (ssize_t)len) {
ssize_t n = write(tmp_fd, data + written, len - written);
if (n < 0) { if (errno == EINTR) continue; close(tmp_fd); unlink(tmp_path); return -1; }
written += n;
}
/* ধাপ ২ -- temp ফাইল fsync().
এটা রক্ষা করে: rename()-এর *পরে* crash হলে যাতে নতুন নামের
পেছনের content নিশ্চিতভাবেই সম্পূর্ণ ডিস্কে থাকে, কখনো যেন
"নাম আছে কিন্তু data delayed-allocation-এ আটকে" (2009 ext4 বাগ)
এই অবস্থা না হয়। fsync() না করলে rename() নিজে atomic হলেও
তার পেছনের ডেটার durability-র কোনো গ্যারান্টি নেই। */
if (fsync(tmp_fd) != 0) { close(tmp_fd); unlink(tmp_path); return -1; }
close(tmp_fd);
/* ধাপ ৩ -- rename() দিয়ে atomic switch।
এই একটা কল-এ target_path নামটা tmp_path-এর inode-কে নির্দেশ
করা শুরু করে -- কোনো observer কখনো "আংশিক লেখা" ফাইল দেখবে না,
হয় সম্পূর্ণ পুরনো content দেখবে, নয়তো সম্পূর্ণ নতুন -- কখনো মাঝামাঝি না।
(এটা এই লেসনের copy-on-write দর্শনের একটা ছোট, filesystem-স্তরের
প্রতিফলন: rename একটা একক atomic pointer-swap।) */
if (rename(tmp_path, target_path) != 0) { unlink(tmp_path); return -1; }
/* ধাপ ৪ -- parent directory আলাদাভাবে fsync।
rename() নিজে একটা metadata অপারেশন -- directory-র data block-এ
"target_path এখন এই inode-কে নির্দেশ করছে" এই পরিবর্তনটা লেখে।
এই পরিবর্তনটাও page cache-এ dirty থাকতে পারে rename() ফেরত
আসার পরেও। এই ধাপ ছাড়া: crash হলে rename "কখনো ঘটেইনি" এমন
দেখাতে পারে -- target_path পুরনো inode-কেই নির্দেশ করবে,
যদিও আপনার প্রোগ্রাম rename()-এ সফল রিটার্ন পেয়েছিল। */
int dir_fd = open(dir_path, O_RDONLY | O_DIRECTORY);
if (dir_fd < 0) return -1;
if (fsync(dir_fd) != 0) { close(dir_fd); return -1; }
close(dir_fd);
return 0; /* এখন সত্যিই, সম্পূর্ণভাবে durable */
}
int main(int argc, char **argv)
{
if (argc != 3) {
fprintf(stderr, "usage: %s \<target-path\> \<content\>\n", argv[0]);
return 1;
}
const char *target = argv[1];
const char *content = argv[2];
if (atomic_write_file(target, content, strlen(content)) != 0) {
perror("atomic_write_file");
return 1;
}
printf("লেখা সম্পন্ন ও durable: %s (%zu বাইট)\n", target, strlen(content));
return 0;
}প্রত্যাশিত আউটপুট:
$ ./atomic_write /tmp/config.txt "server=1.2.3.4"
লেখা সম্পন্ন ও durable: /tmp/config.txt (15 বাইট)
$ cat /tmp/config.txt
server=1.2.3.4strace দিয়ে যাচাই করলে ঠিক এই ক্রমটাই দেখা যাবে:
openat(AT_FDCWD, "/tmp/.atomic_XXXXXX", O_RDWR|O_CREAT|O_EXCL, 0600) = 3
write(3, "server=1.2.3.4", 15) = 15
fsync(3) = 0
close(3) = 0
rename("/tmp/.atomic_a1B2c3", "/tmp/config.txt") = 0
openat(AT_FDCWD, "/tmp", O_RDONLY|O_DIRECTORY) = 3
fsync(3) = 0
close(3) = 0চারটা ধাপই আলাদা syscall হিসেবে দৃশ্যমান — কোনো ধাপ বাদ পড়লে (বিশেষত শেষ directory fsync) durability-র গ্যারান্টি ভেঙে যায়, যদিও প্রোগ্রামটা normal অবস্থায় (কোনো crash ছাড়া) একেবারে একইভাবে কাজ করবে দেখতে — এই বাগটা শুধু crash হলেই ধরা পড়ে, তাই টেস্ট করা কঠিন।
নিজে বাড়ান
O_TMPFILEদিয়ে একটা বিকল্প সংস্করণ লিখুন। Linux-এopen(dir, O_TMPFILE | O_WRONLY, mode)একটা নামহীন inode বানায় directory-র মধ্যেই, লেখার পরেlinkat(fd, "", dir_fd, target, AT_EMPTY_PATH)দিয়ে নাম দেওয়া যায়। এটাmkstemp+rename-এর চেয়ে কীভাবে ভালো (কোনো visible temp file-ই কখনো থাকে না, crash হলে garbage temp file থেকে যাওয়ার ঝুঁকি নেই) ব্যাখ্যা করুন কোডে কমেন্ট আকারে।- fsync ব্যর্থ হলে কী করবেন তা হ্যান্ডল করুন। Linux-এ একটা কুখ্যাত বাগ ক্লাস আছে:
fsync()ব্যর্থ হলে (EIO) কিছু পুরনো কার্নেল সংস্করণে dirty page-টাকে clean বলে চিহ্নিত করে দিত, তাই দ্বিতীয়বার fsync চেষ্টা করলে ভুলভাবে সাফল্য দেখাত (ডেটা আসলে হারিয়েই গেছে) — এটা PostgreSQL টিমের বিখ্যাত “fsyncgate” (2018) আবিষ্কার। আপনার ফাংশনে fsync ব্যর্থ হলে সেই fd বাতিল ঘোষণা করে পুরো অপারেশন retry (নতুন temp ফাইল থেকে) করার লজিক যোগ করুন, শুধু আবার fsync() না করে। - Benchmark করুন — fsync-সহ বনাম ছাড়া রাউন্ড-ট্রিপ latency। এই ফাংশনটা ১০০০ বার ছোট ছোট ভিন্ন ফাইলে চালান, সময় মাপুন। তারপর
fsync()কলগুলো temporarily কমেন্ট করে আবার মাপুন। আগের experiment-এর ৮০০০× পার্থক্যের সাথে তুলনা করুন, আর ব্যাখ্যা করুন কেন এই ফাংশনের ব্যবহারকারীকে (config file writer, না যে ডাটাবেস) সাধারণত এই খরচ মেনে নিতেই হয়। - Directory fsync বাদ দিয়ে পরীক্ষা করুন crash simulate করে। (নিরাপদে, একটা VM-এ) ধাপ ৪ বাদ দিয়ে একটা সংস্করণ চালান, তারপর
echo c > /proc/sysrq-trigger(immediate reboot, কোনো clean unmount ছাড়া) দিয়ে জোর করে crash করান — বুটের পরে দেখুন নতুন rename হওয়া ফাইলটা টিকে আছে কি না। (সতর্কতা: শুধু disposable VM-এ করুন, এটা সত্যিই ডেটা হারানোর ঝুঁকি তৈরি করে ইচ্ছাকৃতভাবে।)
বাস্তব সিস্টেমে
যেখানে journaling আর fsync প্রতিদিন কামড় দেয়
PostgreSQL-এর WAL (Write-Ahead Log) — একই ধারণা, ডাটাবেস-স্তরে। PostgreSQL কখনো সরাসরি table file-এ পরিবর্তন লিখে ফেলে না তার আগে সেই পরিবর্তনটা WAL-এ (একটা append-only log, ঠিক ext4-এর journal-এর মতোই) লিখে fsync করে না। Crash হলে recovery WAL replay করে ঠিক সেই একই “log-then-apply” নীতিতে — filesystem journaling আর database WAL আক্ষরিক অর্থেই একই সমাধান, দুইটা ভিন্ন স্তরে প্রয়োগ করা। Level 8-এর databases module-এ এই সংযোগটা সম্পূর্ণভাবে খোলা হবে, সহ ARIES protocol আর group commit-এর বিস্তারিত।
SQLite-এর rollback journal আর WAL mode — অ্যাপ্লিকেশন-স্তরে filesystem-journaling-এর নকল। SQLite-এর ডিফল্ট mode একটা .db-journal ফাইল বানায় প্রতিটা transaction-এর আগে (পুরনো content-এর কপি) — অনেকটা এই লেসনের journal ধারণারই একটা user-space সংস্করণ, filesystem-নিরপেক্ষভাবে কাজ করার জন্য (কারণ SQLite জানে না নিচের filesystem ext4, FAT32, নাকি নেটওয়ার্ক ফাইলসিস্টেম)। WAL mode (SQLite 3.7+) সরাসরি এই লেসনের নাম-ই ধার করেছে, প্রায় অভিন্ন নীতিতে।
২০১৮-এর PostgreSQL “fsyncgate” — Linux কার্নেলের fsync error-handling বাগ। আবিষ্কার হলো Linux কার্নেলে (২০১৮ পর্যন্ত) fsync() ব্যর্থ হলে (EIO, ডিস্ক error) কার্নেল সেই dirty page-কে clean মার্ক করে দিত, এমনকি ডেটা আসলে ডিস্কে না পৌঁছালেও — তাই দ্বিতীয়বার fsync চেষ্টা করলে সেটা “সফল” রিটার্ন দিত (আর কিছুই করার ছিল না, কারণ page আর dirty না)। PostgreSQL (এবং অন্য বহু ডাটাবেস) ধরে নিয়েছিল fsync retry করা নিরাপদ — এই বাগ প্রমাণ করল সেটা মিথ্যা, silent data loss সম্ভব। এই আবিষ্কারের পরে Linux কার্নেলে errseq_t মেকানিজম যোগ হলো (kernel 4.13+) যাতে প্রতিটা fd আলাদাভাবে error state ট্র্যাক করে, একটা fsync-error অন্য process-এর কাছে চাপা না পড়ে। এই লেসনের build-এর extension ২ ঠিক এই ঘটনা থেকে শেখা শিক্ষা প্রয়োগ করে।
MySQL/InnoDB-এর innodb_flush_log_at_trx_commit — durability বনাম performance-এর explicit ট্রেড-অফ knob। মান 1 মানে প্রতিটা commit-এ fsync (সবচেয়ে নিরাপদ, সবচেয়ে ধীর — এই লেসনের experiment-এর ৮০০০× খরচ সরাসরি প্রযোজ্য), 0 বা 2 মানে periodic flush (দ্রুত, কিন্তু crash হলে সাম্প্রতিক কিছু commit হারানোর ঝুঁকি)। এটা ঠিক এই লেসনের তিনটা ext4 data= mode-এর ডাটাবেস-স্তরের সমতুল্য সিদ্ধান্ত, একই trade-off কাঠামোতে।
Etcd আর Kubernetes-এর “slow disk” সতর্কতা। etcd (Kubernetes-এর কেন্দ্রীয় কনফিগ স্টোর) প্রতিটা write-এ fsync করে তার নিজস্ব Raft log-এ (consensus-এর জন্য বাধ্যতামূলক, Level 9-এর distributed systems-এ বিস্তারিত)। যদি underlying disk ধীর হয় (নেটওয়ার্ক স্টোরেজ, বা busy shared disk), fsync latency বেড়ে যায়, আর পুরো cluster-এর leader election অস্থির হয়ে যেতে পারে — একটা filesystem-স্তরের latency সমস্যা সরাসরি cluster-স্তরের uptime সমস্যায় পরিণত হয়। etcd ডকুমেন্টেশন স্পষ্টভাবে বলে dedicated SSD ছাড়া production-এ না চালাতে, ঠিক এই কারণে।
nginx-এর fsync config আর logrotate। Web server access log-এ প্রতিটা request-এ fsync করা অকল্পনীয় ধীর হতো (এই লেসনের experiment-এর সংখ্যা অনুযায়ী), তাই বেশিরভাগ logging system ইচ্ছাকৃতভাবে fsync skip করে — সাম্প্রতিক কয়েক সেকেন্ডের log হারানোর ঝুঁকি নিয়ে, কারণ log ডেটা সাধারণত mission-critical durability দাবি করে না। এটাই এই পুরো লেসনের ব্যবহারিক সিদ্ধান্ত-কাঠামো: প্রতিটা ডেটার জন্য জিজ্ঞাসা করুন, “এটা হারালে কী ক্ষতি?” — উত্তরটাই ঠিক করে দেয় fsync লাগবে কি না।
যে ভুলগুলো সবাই করে
“filesystem journaling মানে filesystem কখনো ডেটা হারাবে না।”
Journaling metadata-consistency গ্যারান্টি দেয় (filesystem নিজে valid, browsable, fsck লাগবে না), কিন্তু আপনার সাম্প্রতিক write-করা ডেটার durability সম্পূর্ণ ভিন্ন প্রশ্ন। data=ordered (ডিফল্ট) মোডেও, যদি আপনি fsync() না করেন, সাম্প্রতিক write page cache-এ থেকে যেতে পারে আর crash হলে হারিয়ে যায় — filesystem নিজে সম্পূর্ণ সুস্থ, fsck লাগবে না, কিন্তু আপনার ডেটা তবু নেই।
২০০৯-এর ext4 ঘটনাটাই এই ভুল ধারণার সবচেয়ে বিখ্যাত প্রমাণ: filesystem নিজে perfectly consistent ছিল (journaling ঠিকঠাক কাজ করছিল, কোনো corrupted metadata ছিল না) — কিন্তু ব্যবহারকারীর ফাইল-content তবু হারিয়ে গিয়েছিল, কারণ সেই content কখনো ডিস্কেই পৌঁছায়নি। এই দুইটা গ্যারান্টি — “filesystem structurally sound” আর “আমার সাম্প্রতিক write টিকে থাকবে” — সম্পূর্ণ স্বাধীন, journaling শুধু প্রথমটার প্রতিশ্রুতি দেয়।
“rename() একটা ফাইলকে atomic-ভাবে replace করে, তাই rename করার পরপরই ডেটা নিরাপদ।”
rename() atomic — এটা সত্যি: কোনো observer কখনো “আংশিক rename” দেখবে না, সবসময় হয় পুরনো নাম-mapping দেখবে, নয় নতুনটা, কখনো মাঝামাঝি না। কিন্তু atomic মানে durable না — দুইটা সম্পূর্ণ ভিন্ন গ্যারান্টি।
rename() ফেরত আসার মুহূর্তে পরিবর্তনটা শুধু page cache-এ ঘটেছে বলে গ্যারান্টিড; ডিস্কে পৌঁছানোর কোনো নিশ্চয়তা নেই যতক্ষণ না parent directory আলাদাভাবে fsync() করা হয় — এই লেসনের build সেকশনের ধাপ ৪ ঠিক এই ফাঁকটাই বন্ধ করে। “atomic” শব্দটা বলছে কীভাবে পরিবর্তনটা দৃশ্যমান হয় (সব-অথবা-কিছুই), “durable” শব্দটা বলছে সেটা টিকে থাকবে কি না পাওয়ার হারানোর পরেও — দুইটা প্রশ্ন স্বাধীন, আর অ্যাপ্লিকেশন প্রায়ই প্রথমটা পেয়ে ভুলভাবে দ্বিতীয়টাও ধরে নেয়।
“data=journal mode সবচেয়ে নিরাপদ, তাই সবসময় এটাই ব্যবহার করা উচিত।”
নিরাপত্তার দিক থেকে এটা সত্যি (data journal মানে data নিজেও journal-এ প্রথমে যায়, তাই crash-এর পর সবচেয়ে কম করাপশন-ঝুঁকি) — কিন্তু “সবসময় সবচেয়ে নিরাপদটা ব্যবহার করুন” একটা false generalization, কারণ এটা performance খরচকে উপেক্ষা করে।
এই লেসনের বেঞ্চমার্ক experiment-এ দেখা গেছে data=journal data=writeback-এর প্রায় দ্বিগুণ ধীর, কারণ প্রতিটা বাইট আক্ষরিক অর্থেই দুইবার লেখা হয় (একবার journal-এ, একবার চূড়ান্ত জায়গায়)। বেশিরভাগ workload-এর জন্য data=ordered (ডিফল্ট) যথেষ্ট নিরাপদ — এটা কখনো stale/গার্বেজ ডেটা exposed হতে দেয় না (writeback-এর সমস্যা), শুধু সাম্প্রতিক-অসম্পূর্ণ write হারানোর ঝুঁকি রাখে (যেটা এমনিতেই যেকোনো mode-এ fsync ছাড়া থাকবে)।
data=journal তখনই যুক্তিসঙ্গত যখন workload নিজেই খুব বেশি ছোট, ঘন ঘন, নিরাপত্তা-সংবেদনশীল write করে (যেমন একটা mail server যেখানে প্রতিটা মেসেজ হারানো অগ্রহণযোগ্য) — সাধারণ workload-এ (ওয়েব সার্ভার, বেশিরভাগ অ্যাপ্লিকেশন সার্ভার) এই দ্বিগুণ খরচ অপ্রয়োজনীয়, আর সঠিক সমাধান প্রায়ই data=ordered + সঠিক জায়গায় explicit fsync(), ব্লানকেট data=journal না।
“SSD-তে লেখা শেষ মানে সেই ডেটা নিরাপদে ফ্ল্যাশ মেমোরিতে আছে।”
write(), এমনকি fsync()-এর পরেও, OS-এর দৃষ্টিতে “storage device নিশ্চিত করেছে” মানে ডেটা device-এর নিজস্ব DRAM write cache-এ পৌঁছেছে — যেটা নিজেও volatile, power হারালে হারায়। এই কারণেই এই লেসনের “barrier আর FUA” আলোচনা গুরুত্বপূর্ণ: fsync() কার্নেলকে বাধ্য করে ডিভাইসকে একটা “cache flush” বা FUA কমান্ড পাঠাতে, যা ডিভাইসের firmware-কে বলে ডেটা সত্যিই non-volatile NAND-এ commit করতে।
সমস্যাটা বাস্তব কারণ কিছু consumer-grade SSD (এবং পুরনো spinning disk) firmware-স্তরে এই কমান্ড উপেক্ষা করে বা ভুলভাবে বাস্তবায়ন করে benchmark-এ ভালো সংখ্যা দেখানোর জন্য — “লেখা সম্পন্ন” মিথ্যা রিপোর্ট করে যখন ডেটা আসলে এখনো volatile cache-এ। এন্টারপ্রাইজ SSD-তে সাধারণত power-loss protection (ক্যাপাসিটর-ব্যাকড cache) থাকে ঠিক এই সমস্যা এড়াতে — আর এই কারণেই ডাটাবেস সার্ভারে “consumer SSD ব্যবহার করবেন না” একটা প্রায়-সর্বজনীন প্রোডাকশন-নিয়ম। fsync() আপনার কোডের দায়িত্ব পালন করে ঠিকই, কিন্তু চূড়ান্ত গ্যারান্টি হার্ডওয়্যার সততার উপরও নির্ভর করে, যা সফটওয়্যার-স্তর থেকে verify করা কঠিন।
বুঝেছেন কি না দেখুন
1একটা ফাইলে ৪KB ডেটা append করার সময় কার্নেল crash করল ঠিক data block writeback-এর পরে কিন্তু journal commit marker লেখার আগে, data=ordered mode-এ। বুটের পরে filesystem-এর অবস্থা কী হবে — data block-টা কি ফাইলের অংশ হিসেবে দেখা যাবে? আপনার উত্তরে journal replay-র ভূমিকা ব্যাখ্যা করুন।
যুক্তি
data=ordered mode-এ। বুটের পরে filesystem-এর অবস্থা কী হবে — data block-টা কি ফাইলের অংশ হিসেবে দেখা যাবে? আপনার উত্তরে journal replay-র ভূমিকা ব্যাখ্যা করুন।না, data block ফাইলের অংশ হিসেবে দেখা যাবে না — ডেটা লেখাটা কার্যত হারিয়ে গেছে, কিন্তু filesystem structurally সম্পূর্ণ সুস্থ থাকবে।
কারণটা journal-এর atomicity নীতিতে: commit marker journal-এ লেখা হয়নি এই crash point-এ, তাই সংশ্লিষ্ট transaction (যেটাতে এই append-এর metadata পরিবর্তন — নতুন i_size, নতুন block pointer, bitmap আপডেট — থাকার কথা ছিল) কখনো commit-ই হয়নি। বুটের সময় journal recovery প্রতিটা transaction পরীক্ষা করে — commit marker না থাকা transaction সম্পূর্ণভাবে উপেক্ষা করা হয়, যেন সেটা কখনো লেখাই শুরু হয়নি।
| স্তর | crash-এর পরে অবস্থা |
|---|---|
| data block | ডিস্কে নতুন ডেটা আছে (writeback হয়ে গিয়েছিল) — কিন্তু এতিম, কোনো inode তাকে দাবি করছে না |
inode (i_size, block pointer) | পুরনো অবস্থাতেই — journal transaction commit না হওয়ায় এই পরিবর্তন কখনো প্রয়োগই হয়নি |
| block bitmap | পুরনো অবস্থাতেই — এই ব্লকটা “ফ্রি” হিসেবেই দেখাবে |
| filesystem structural consistency | সম্পূর্ণ সুস্থ — fsck লাগবে না, কোনো করাপশন নেই |
ফলাফল: ফাইলটা তার আগের (append-এর আগের) আকারেই দেখা যাবে — নতুন ৪KB “হারিয়ে গেছে” ব্যবহারকারীর দৃষ্টিতে, যদিও ডেটাটা আক্ষরিক অর্থে ডিস্কে বসে আছে (শুধু কোনো metadata তাকে নির্দেশ করছে না, তাই পরবর্তী কোনো নতুন বরাদ্দ সেই ব্লকটা নিরাপদে পুনর্ব্যবহার করতে পারবে, block bitmap অনুযায়ী সেটা “ফ্রি”)।
এটাই এই লেসনের “concept” সেকশনের “Crash point B” (bitmap+data লেখা হয়ে গেছে, inode বাকি)-এর একটা সংস্করণ — শুধু এখানে ordered-mode-এর নিয়ম অনুযায়ী data আগে থেকেই ডিস্কে ছিল, metadata journal-ই কমিট হয়নি। এটাই ordered mode-এর পুরো পয়েন্ট: crash যে বিন্দুতেই হোক, inode কখনো এমন কোনো ব্লক দাবি করবে না যেখানে ডেটা এখনো লেখা শেষ হয়নি — এখানে উল্টো ঘটনা ঘটেছে (ডেটা আছে, inode তাকে দাবি করেনি), যা “নিরাপদ দিকে ব্যর্থতা” — গার্বেজ এক্সপোজ হয়নি, শুধু একটা সাম্প্রতিক write হারিয়েছে।
Level 8-এর সাথে যোগ: এই ঠিক একই atomicity নীতি ডাটাবেস transaction-এ “all-or-nothing” গ্যারান্টির ভিত্তি — একটা transaction-এর WAL commit record লেখার আগে crash হলে পুরো transaction rollback (যেন ঘটেইনি), পরে হলে পুরো transaction টিকে থাকে (redo করে হলেও)। Level 8-এ ACID-এর “A” (atomicity) এবং “D” (durability) ঠিক এই ext4 journal-এর মতোই একটা log-based মেকানিজমে বাস্তবায়িত হয়।
2একটা লগিং সিস্টেম প্রতি সেকেন্ডে ১০,০০০টা ছোট (১০০ বাইট) লগ-লাইন লেখে। দুইটা ডিজাইন প্রস্তাব করা হয়েছে: (ক) প্রতিটা লাইনের পরে fsync(), (খ) প্রতি ১০০ লাইনে একবার (batch) fsync()। এই লেসনের experiment-এর সংখ্যা ব্যবহার করে (প্রতি fsync ~৬.৪ms) দুইটার throughput হিসাব করুন, আর প্রতিটার worst-case ডেটা-হারানোর ঝুঁকি বলুন।
প্রয়োগ
fsync(), (খ) প্রতি ১০০ লাইনে একবার (batch) fsync()। এই লেসনের experiment-এর সংখ্যা ব্যবহার করে (প্রতি fsync ~৬.৪ms) দুইটার throughput হিসাব করুন, আর প্রতিটার worst-case ডেটা-হারানোর ঝুঁকি বলুন।(ক) প্রতিটা লাইনে fsync:
প্রতিটা লাইনের খরচ প্রায় সম্পূর্ণভাবে fsync-latency দ্বারা dominated (write নিজে নগণ্য, experiment-এ ০.০০১ms):
কিন্তু চাহিদা ১০,০০০ লাইন/সেকেন্ড — এই ডিজাইন প্রয়োজনের চেয়ে ~৬৪× ধীর, সম্পূর্ণ অচল।
(খ) প্রতি ১০০ লাইনে ব্যাচ fsync:
১০০টা write() (প্রতিটা ~০.০০১ms, নগণ্য) + ১টা fsync() (৬.৪ms):
এটা ১০,০০০/সেকেন্ড চাহিদা পূরণ করে, প্রায় ~১০০× দ্রুত (ক)-এর চেয়ে — কারণ fsync-এর fixed latency ১০০টা লাইনের মধ্যে amortize হয়ে গেছে।
Worst-case ডেটা-হারানোর ঝুঁকি:
| ডিজাইন | Crash হলে সর্বোচ্চ কত লাইন হারাতে পারে |
|---|---|
| (ক) প্রতি লাইনে fsync | ১টা — সবচেয়ে সাম্প্রতিক write, যেটার fsync এখনো সম্পূর্ণ হয়নি |
| (খ) প্রতি ১০০ লাইনে fsync | সর্বোচ্চ ৯৯টা — সর্বশেষ commit-এর পর থেকে লেখা সব লাইন, যেগুলো এখনো page cache-এ, ডিস্কে যায়নি |
ট্রেড-অফ স্পষ্ট: (খ) প্রায় ১০০× দ্রুত, কিন্তু crash-এ ~১০০× বেশি ডেটা হারানোর ঝুঁকি। বাস্তব সিস্টেম (Kafka, ডাটাবেস WAL) এই দুই প্রান্তের মাঝামাঝি একটা adaptive batch policy ব্যবহার করে — সময়-ভিত্তিক (প্রতি N মিলিসেকেন্ডে অন্তত একবার fsync, ব্যাচ যত ছোটই হোক) এবং আকার-ভিত্তিক (ব্যাচ N লাইনে পৌঁছালে fsync, সময় যাই লাগুক) — দুইটার মধ্যে যেটা আগে ঘটে। এভাবে worst-case ক্ষতি একটা সময়-সীমার (যেমন “সর্বোচ্চ ১০ms-এর ডেটা”) মধ্যে বাঁধা থাকে, ব্যাচ-আকারের উপর সম্পূর্ণ নির্ভর করে না।
Level 8-এর সাথে যোগ: এটাই ডাটাবেসের group commit কৌশলের হুবহু হিসাব — PostgreSQL/MySQL একাধিক concurrent transaction-এর commit request একসাথে জড়ো করে একটা fsync-এ পাঠায়। যত বেশি concurrent transaction, group তত বড়, per-transaction amortized cost তত কম — throughput বাড়ে সমান্তরাল লোডের সাথে, ঠিক এই হিসাবের বড় পরিসরের সংস্করণ।
3একজন সহকর্মী বলছেন: “আমাদের config-file-writer টুল rename() ব্যবহার করে atomic replace করে, তাই সেটা নিরাপদ, fsync লাগবে না — পারফরম্যান্সের জন্য বাদ দিয়ে দিলাম।” এই যুক্তিতে কী ভুল আছে? দুইটা ভিন্ন crash scenario দিয়ে দেখান কী ব্যর্থ হতে পারে।
যুক্তি
সহকর্মীর যুক্তি “atomic” আর “durable”-কে গুলিয়ে ফেলেছে (এই লেসনের দ্বিতীয় misconception-এর ঠিক এই ভুল)। rename() নিশ্চিত করে transition-টা atomic — কিন্তু কোনো গ্যারান্টি দেয় না যে rename নিজে বা তার পেছনের ডেটা ডিস্কে টিকে থাকবে crash-এর পরে।
Scenario ১ — টেম্প ফাইলের ডেটা কখনো ডিস্কে পৌঁছায়নি (fsync temp ফাইলে বাদ দিলে):
write(tmp_fd, new_config, len) ← page cache-এ, ডিস্কে না
close(tmp_fd)
rename(tmp, "config.conf") ← metadata পরিবর্তন, এটাও এখনো page cache-এ
CRASHবুটের পরে দুইটা সম্ভাব্য বিপর্যয়: (ক) যদি rename নিজেও journal-এ commit না হয়ে থাকে, config.conf তার পুরনো content-এই ফিরে যাবে — অন্তত ডেটা পুরনো কিন্তু সামঞ্জস্যপূর্ণ, তেমন ক্ষতিকর না। (খ) কিন্তু যদি rename commit হয়ে যায় (নাম বদল দ্রুত, ছোট metadata write) অথচ data block delayed allocation-এ এখনো লেখা হয়নি — ঠিক ২০০৯-এর সেই বাগ — তাহলে config.conf নাম আছে, inode আছে, কিন্তু content শূন্য বা গার্বেজ। এই দ্বিতীয়টাই সবচেয়ে বিপজ্জনক কারণ প্রোগ্রাম rename()-এ সফল রিটার্ন দেখেছিল।
Scenario ২ — rename নিজে ডিস্কে পৌঁছায়নি (temp fsync থাকলেও directory fsync বাদ দিলে):
write(tmp_fd, new_config, len)
fsync(tmp_fd) ← ডেটা নিশ্চিতভাবে ডিস্কে
close(tmp_fd)
rename(tmp, "config.conf") ← metadata পরিবর্তন, কিন্তু এই লেসনের
build-এর ধাপ ৪ (directory fsync) বাদ
CRASHএখানে ডেটা নিরাপদ (temp ফাইলটার content ডিস্কে পৌঁছেছে) — কিন্তু rename নিজেই এখনো page cache-এ dirty, ডিস্কে যায়নি। বুটের পরে সম্ভাবনা: config.conf এখনো পুরনো নামের mapping-এ (rename-এর আগের অবস্থায়) ফিরে যেতে পারে — নতুন ডেটা ডিস্কে সম্পূর্ণ নিরাপদে বসে আছে, কিন্তু কোনো নাম তাকে নির্দেশ করছে না, প্রোগ্রামের দৃষ্টিতে “আপডেট কখনো ঘটেইনি” দেখাবে, যদিও rename() সফল রিটার্ন দিয়েছিল।
দুইটা scenario-ই দেখাচ্ছে: fsync(tmp_fd) শুধু ডেটার durability রক্ষা করে, fsync(dir_fd) শুধু নামের mapping-এর durability রক্ষা করে — দুইটা স্বাধীন গ্যারান্টি, একটা থাকলে অন্যটা বিনামূল্যে আসে না। সহকর্মীর “পারফরম্যান্সের জন্য fsync বাদ” সিদ্ধান্তটা আসলে একটা explicit trade-off হওয়া উচিত ছিল (“আমরা সাম্প্রতিক config write হারানোর ঝুঁকি নিচ্ছি দ্রুততার বিনিময়ে”), গোপন অনুমান না (“rename নিরাপদ তাই fsync লাগে না”) — আর config ফাইলের ক্ষেত্রে (কম frequency, উচ্চ correctness importance) সাধারণত পুরো fsync দাম দেওয়াই সঠিক সিদ্ধান্ত, log-লাইনের বিপরীতে।
Level 11-এর সাথে যোগ: এই ধরনের “correctness bug যা শুধু crash-এ প্রকাশ পায়” পরীক্ষা করার জন্য বিশেষ টুল দরকার (Level 11-এর performance/reliability module-এ আসবে) — যেমন ALICE (Pillai et al., একই গবেষণাপত্র এই লেসনের reference-এ) যেটা systematically সব সম্ভাব্য crash-point simulate করে ঠিক এই ধরনের বাগ পদ্ধতিগতভাবে খুঁজে বের করে, ম্যানুয়াল reasoning-এর উপর নির্ভর না করে।
4একটা IoT ডিভাইসে ফ্ল্যাশ স্টোরেজ, ext4 filesystem, ব্যাটারি ব্যাকআপ নেই (power হঠাৎ যেকোনো সময় যেতে পারে)। ডিভাইসটা প্রতি সেকেন্ডে সেন্সর-রিডিং একটা লগ ফাইলে লেখে — সাম্প্রতিক কয়েক সেকেন্ডের রিডিং হারানো গ্রহণযোগ্য (পরের রিডিং আসবে), কিন্তু filesystem করাপ্ট হয়ে পুরো ডিভাইস বুট-লুপে আটকে যাওয়া অগ্রহণযোগ্য। কোন data= mode বেছে নেবেন, আর mount option-এ আর কী যোগ করবেন?
প্রয়োগ
data= mode বেছে নেবেন, আর mount option-এ আর কী যোগ করবেন?data=ordered (ডিফল্ট) সঠিক পছন্দ, data=writeback না, data=journal না।
কেন writeback বাদ: এই লেসনের সবচেয়ে বড় সতর্কতা — writeback mode-এ crash-এর পরে stale/গার্বেজ ডেটা (আগের অন্য কোনো ফাইলের ফেলে যাওয়া বাইট) প্রকাশ পেতে পারে ফাইলের নতুন অংশে। এই সিস্টেমে সেটা মানে সেন্সর-লগে ভুল ডেটা দেখাতে পারে (পুরনো/অসংশ্লিষ্ট বাইট), শুধু হারানো ডেটা না — যেকোনো ডাউনস্ট্রিম সিস্টেম যদি এই লগ পড়ে সিদ্ধান্ত নেয়, ভুল সিদ্ধান্তের ঝুঁকি। “সাম্প্রতিক রিডিং হারানো গ্রহণযোগ্য” বলা হয়েছে, “ভুল রিডিং দেখানো গ্রহণযোগ্য” বলা হয়নি — এই পার্থক্যটাই writeback বাদ দেওয়ার কারণ।
কেন journal অপ্রয়োজনীয়: সমস্যার বিবরণ স্পষ্টভাবে বলছে সাম্প্রতিক write হারানো গ্রহণযোগ্য — তাই data=journal-এর অতিরিক্ত সুরক্ষা (আর দ্বিগুণ write খরচ, যেটা ফ্ল্যাশ স্টোরেজে write-endurance-এর জন্যও খারাপ, কারণ NAND flash-এর সীমিত write cycle থাকে) এখানে অপ্রয়োজনীয় খরচ।
data=ordered-এর গ্যারান্টি ঠিক এই workload-এর জন্য যথেষ্ট: crash হলে সর্বোচ্চ ক্ষতি হলো সাম্প্রতিক অসম্পূর্ণ log write (গ্রহণযোগ্য বলা হয়েছে) — কিন্তু কখনো stale/গার্বেজ ডেটা দেখাবে না, আর filesystem structural corruption হবে না (journal থাকায় fsck-এর প্রয়োজনই পড়বে না প্রায় সবসময়, বা লাগলেও দ্রুত)।
অতিরিক্ত mount option বিবেচনা:
commit=N— ডিফল্ট commit interval ৫ সেকেন্ড; ব্যাটারি-ব্যাকআপবিহীন ডিভাইসে এটা কমিয়ে (যেমনcommit=1) ঝুঁকির জানালা ছোট করা যায় (একবারে হারানোর সর্বোচ্চ ডেটা কমে) — খরচ: বেশি ঘন ঘন journal commit, কিছুটা বেশি flash write।errors=remount-ro— যদি filesystem কোনো অপ্রত্যাশিত অসামঞ্জস্য ধরে (যা journaling সত্ত্বেও বিরল ক্ষেত্রে ঘটতে পারে, হার্ডওয়্যার bit-flip ইত্যাদির কারণে), পুরো সিস্টেম panic করার বদলে filesystem-কে read-only করে দেয় — “বুট-লুপে আটকে যাওয়া অগ্রহণযোগ্য” শর্তটা সরাসরি রক্ষা করে, কারণ ডিভাইস অন্তত বুট হতে পারবে (log লেখা বন্ধ হলেও, বাকি কার্যক্রম চলতে পারে)।journal_checksum— journal-এর নিজস্ব entries-এ checksum যোগ করে, যাতে অর্ধেক-লেখা (torn) journal entry নিজেই detect হয়ে বাতিল হয়ে যায় replay-র সময়, ভুল ডেটা প্রয়োগ না হয়ে।
Level 9-এর সাথে যোগ: “কিছু সাম্প্রতিক ডেটা হারানো ঠিক আছে, কিন্তু ভুল ডেটা দেখানো ঠিক না” — এই একই নীতি distributed sensor/IoT সিস্টেমে eventual consistency ডিজাইনের একটা মৌলিক নীতি (Level 9-এ বিস্তারিত): stale read কখনো কখনো গ্রহণযোগ্য, কিন্তু corrupted বা contradictory read প্রায় কখনোই না।
5আপনি একটা নতুন crash-consistency মেকানিজম ডিজাইন করছেন একটা embedded filesystem-এর জন্য যেখানে ডিস্ক স্পেস অত্যন্ত সীমিত (journal-এর জন্য বড় জায়গা রাখা যাবে না) কিন্তু crash সাধারণ ঘটনা (ব্যাটারিবিহীন ডিভাইস)। Journaling বনাম copy-on-write — কোনটা বেছে নেবেন, আর কেন? দুইটার resource trade-off (ডিস্ক স্পেস, write amplification, implementation জটিলতা) তুলনা করুন।
ডিজাইন
সংক্ষিপ্ত উত্তর: প্রেক্ষাপটের উপর নির্ভর করে, কিন্তু এই নির্দিষ্ট constraint সেটে (স্পেস অত্যন্ত সীমিত, crash সাধারণ) copy-on-write-এর দিকে ঝোঁকা যুক্তিসঙ্গত — যদিও দুইটারই গুরুতর আপত্তি আছে।
Journaling-এর trade-off এই প্রেক্ষাপটে:
- ডিস্ক স্পেস: journal নিজেই একটা নির্দিষ্ট, আলাদা সংরক্ষিত জায়গা দাবি করে (এই লেসনের experiment-এ ছোট filesystem-এও ১৬MB) — “ডিস্ক স্পেস অত্যন্ত সীমিত” constraint-এর সাথে সরাসরি সংঘর্ষে, কারণ এই জায়গাটা ব্যবহারযোগ্য ডেটার জন্য কখনোই পাওয়া যায় না।
- Write amplification:
data=ordered-এ শুধু metadata দ্বিগুণ লেখা হয় (data একবারই) — মাঝারি amplification।data=journalহলে সব ডেটা দ্বিগুণ, যা flash-এর সীমিত write-endurance-এর জন্য গুরুতর। - জটিলতা: filesystem-এর দুইটা সম্পূর্ণ ভিন্ন উপস্থাপনা (journal ফরম্যাট + চূড়ান্ত on-disk ফরম্যাট) বজায় রাখতে হয়, replay logic আলাদা কোড path — একটা ছোট embedded টিমের জন্য বাস্তবায়ন-জটিলতা একটা বাস্তব খরচ।
Copy-on-write-এর trade-off:
- ডিস্ক স্পেস: কোনো আলাদা reserved journal area লাগে না — কিন্তু “কখনো in-place overwrite না করা” নীতির নিজস্ব খরচ: পুরনো সংস্করণ পরিষ্কার (garbage collect) না করা পর্যন্ত জায়গা দখল করে থাকে। সীমিত স্পেসে এটা বিপজ্জনক হতে পারে যদি GC ঠিকমতো টিউন না করা হয় — কিন্তু journal-এর মতো স্থায়ী, ব্যবহারযোগ্য-না reservation না, বরং dynamic overhead, যা কম-লেখার সময়ে প্রায় শূন্যে নামতে পারে।
- Write amplification: এখানেই copy-on-write-এর প্রধান আপত্তি — একটা inode-এর গভীরে একটা ছোট ব্লক বদলাতে root পর্যন্ত পুরো path-এর প্রতিটা ব্লক নতুন করে লিখতে হয় (parent pointer আপডেট করতে করতে) — একটা ছোট লজিক্যাল পরিবর্তনও tree-এর উচ্চতার সমানুপাতিক physical write তৈরি করতে পারে। Flash-এ এটা journaling-এর চেয়েও বেশি write amplification দিতে পারে নির্দিষ্ট access pattern-এ (যেমন randomly ছোট আপডেট)।
- জটিলতা: B-tree-এর মতো কাঠামো বজায় রাখা, garbage collection, reference counting — এগুলো journaling-এর চেয়েও জটিল একটা ছোট টিমের জন্য বাস্তবায়ন করা।
সুপারিশ এই নির্দিষ্ট constraint সেটের জন্য: যদি সত্যিই স্পেস “অত্যন্ত সীমিত” (journal-এর reservation বহন করা না-করার মতো), copy-on-write-এর dynamic-overhead প্রকৃতি সুবিধাজনক — কিন্তু তার write-amplification সমস্যাটা flash-এর write-endurance-এর সাথে সাবধানে মেলাতে হবে (log-structured merge-tree-এর মতো কৌশল দিয়ে amplification কমানো, বা F2FS-এর মতো ইতিমধ্যে flash-অপ্টিমাইজড log-structured filesystem ব্যবহার করা, নিজে থেকে বানানোর বদলে)।
একটা তৃতীয় বাস্তব বিকল্প — বেছে না নিয়ে ধার করা। Embedded জগতে নিজে থেকে crash-consistency মেকানিজম ডিজাইন করার চেয়ে প্রায় সবসময় ভালো: বিদ্যমান, battle-tested, flash-অপ্টিমাইজড filesystem ব্যবহার করা (F2FS — Flash-Friendly File System, যেটা log-structured, বা JFFS2/UBIFS, যেগুলো raw NAND flash-এর জন্য ডিজাইন করা, wear-leveling built-in)। এই লেসনের পুরো আলোচনা — journaling বনাম CoW-এর মৌলিক trade-off বোঝা — মূল্যবান ঠিক এই সিদ্ধান্তটা যুক্তিসহকারে নেওয়ার জন্য, শূন্য থেকে বানানোর জন্য না।
Level 12-এর সাথে যোগ: এই একই “reserved overhead বনাম dynamic overhead” trade-off cloud storage সিস্টেমেও ফিরে আসে — log-structured merge-tree ভিত্তিক ডাটাবেস (RocksDB, Cassandra) ঠিক এই copy-on-write-এর মতো write-amplification সমস্যায় ভোগে, আর compaction (এক ধরনের garbage collection) টিউন করাটাই তাদের অপারেশনাল চ্যালেঞ্জের একটা প্রধান অংশ — Level 12-এর storage engine আলোচনায় এই সংযোগ আরো গভীরভাবে আসবে।
এরপর কী
এই মডিউলের পরের ধাপ
fd-র তিন স্তর (লেসন ১৬), তারপর inode-এর ভেতরের গঠন আর directory-র প্রকৃত অর্থ (লেসন ১৭), আর এখন crash-এ কী বাঁচে আর কেন (লেসন ১৮) — এই তিনটা লেসন একসাথে filesystem-এর একটা সম্পূর্ণ, ভূমি-থেকে-চূড়া মানসিক মডেল তৈরি করেছে: open()-এর ফেরত দেওয়া একটা ছোট সংখ্যা থেকে শুরু করে ডিস্কের raw bitmap আর journal পর্যন্ত।
এই মডেলটা এখন থেকে বারবার ফিরে আসবে ভিন্ন ভিন্ন প্রসঙ্গে: Level 8-এর ডাটাবেস module-এ WAL আর ACID durability ঠিক এই লেসনের journaling-এর নীতিই এক স্তর উপরে প্রয়োগ করবে; Level 9-এর distributed systems-এ দেখবেন কেন network filesystem-এ (NFS) এই লেসনের local atomicity গ্যারান্টিগুলো ধরে রাখা কাঠামোগতভাবে কঠিন; আর Level 11-এর performance module-এ data= mode বাছাই, fsync ব্যাচিং, আর write amplification-এর measurement আরো গভীরভাবে ফিরে আসবে প্রোডাকশন সিস্টেম টিউন করার প্রসঙ্গে।
filesystem module-এর মূল বিষয়বস্তু এখানেই সম্পূর্ণ — পরের লেসনগুলোতে Level 4-এর বাকি অংশ device driver ও I/O subsystem-এর দিকে এগোবে, যেখানে এই লেসনের “ডিস্কে write পাঠানো” প্রক্রিয়াটা নিজেই খোলা হবে block layer, I/O scheduler, আর device driver পর্যন্ত।
আরও পড়ুন
- fsync(2) -- Linux manual page — Michael Kerrisk, man-pages project · fsync-এর প্রামাণ্য গ্যারান্টি, আর কেন directory entry-র durability-র জন্য directory fsync লাগে তার সংজ্ঞা
- ext4(5) -- Linux manual page, data= mount option · journal, ordered, writeback তিন মোডের প্রামাণ্য বর্ণনা
- Ted Ts'o -- "Don't fear the fsync!" ও ext4 delayed allocation আলোচনা (LWN.net, 2009) · ২০০৯-এর ext4 zero-length-file ঘটনার প্রাথমিক প্রেক্ষাপট আর ext4 মেইনটেইনারের নিজের ব্যাখ্যা
- Pillai et al. -- "All File Systems Are Not Created Equal: On the Fault Tolerance of File Systems" (OSDI 2014) · বাস্তব filesystem-এ crash-consistency বাগ পদ্ধতিগতভাবে খুঁজে বের করার গবেষণা -- ext4/btrfs/xfs সহ