এই পর্বে যা শিখবেন
- Conflict-এর চার উৎস, আর কখন conflict এড়ানোই সেরা নকশা
- LWW কোথায় চলে আর কোথায় নিঃশব্দে ডেটা হারায় — একটি সিমুলেটরে মেপে
- Siblings, version vector আর নিজের merge function; Dynamo-র shopping cart-এর শিক্ষা
- CRDT-র গণিত: join-semilattice, state-based বনাম operation-based, আর কেন যেকোনো ক্রমে মিলে যায়
- G-Counter থেকে OR-Set আর টেক্সট CRDT; OT-র সাথে তুলনা, আর CRDT কোথায় খাটে না
১ · Conflict কোথা থেকে আসে — চারটি উৎস
একটি পরিবারের বাজারের ফর্দ ভাবুন, যা একটি কাল্পনিক অ্যাপে সবার ফোনে আছে। মা ফেরিঘাটে, নেটওয়ার্ক নেই; ফর্দে “ডিম” যোগ করলেন। একই সময়ে বাবা অফিসের ল্যাপটপে যোগ করলেন “চাল”। ঘণ্টাখানেক পরে মায়ের ফোন নেটওয়ার্ক পেল। এখন ফর্দে কী থাকবে? এখানে উত্তর সহজ — দুটিই। কিন্তু যদি মা “চিনি” কেটে দেন আর বাবা একই সময়ে চিনির পরিমাণ “২ কেজি” করেন? তখন দুই কপির মধ্যে একটি write conflict, আর কোনো একটি নিয়মে সব কপিকে একই উত্তরে আনতে হবে — conflict resolution.
বাজারের কাগজের ফর্দের দুটি কপি। বাবা কারওয়ান বাজারে, মা নিউমার্কেটে; দুজনের ফোনই বন্ধ। প্রত্যেকে নিজের কপিতে টিক দিচ্ছেন, নতুন জিনিস লিখছেন, কিছু কেটে দিচ্ছেন। রাতে দুটি কাগজ পাশাপাশি রেখে একটি ফর্দ বানাতে হবে। “টিক” মেলানো সহজ — যেকোনো কাগজে টিক থাকলেই কেনা হয়েছে। কিন্তু একজন কেটেছেন আর অন্যজন সেটিই নতুন করে লিখেছেন — তখন? এই পর্বের পুরো বিষয় এই রাতের টেবিল: কোন মেলানোর নিয়ম কাগজ যে ক্রমেই আসুক একই ফর্দ দেয়।
এখানে “একসাথে” মানে ঘড়ির একই মুহূর্ত নয়। দুটি লেখা concurrent, যদি কোনোটির লেখক অন্যটির খবর না জেনে লিখে থাকেন — মাঝে দশ মিনিট বা দশ ঘণ্টা থাকলেও। আনুষ্ঠানিক সংজ্ঞাটি Lamport-এর happens-before সম্পর্ক; সেটি আর তা ধরার vector clock পর্ব ৩৯-এর বিষয়, এখানে আমরা ব্যবহার করব। Conflict চারটি জায়গা থেকে আসে:
🌏 Multi-leader
প্রতিটি region-এ একটি leader স্থানীয় লেখা নেয়, পরে async-এ বিনিময় করে (multi-leader replication, পর্ব ২২)। দুই region একই রেকর্ড বদলালে দুই leader-ই “OK” বলেছে।
lag: ms থেকে সেকেন্ড🧮 Leaderless
Dynamo-ধাঁচে যেকোনো replica লেখা নেয় (leaderless replication). Quorum থাকলেও দুটি client একই key-তে একসাথে লিখলে দুটি সংস্করণ তৈরি হয় — পর্ব ৪১.
siblings জন্ম নেয় এখানে📱 Offline-first ফোন
Offline-first অ্যাপে প্রতিটি ফোনই একটি leader. নেটওয়ার্ক না থাকলে ঘণ্টা বা দিন ধরে আলাদা চলে — মাঠ-জরিপ, নোট, হিসাবের খাতার অ্যাপ।
lag: ঘণ্টা থেকে সপ্তাহ✍️ একসাথে সম্পাদনা
Collaborative editing-এ প্রতিটি browser-এর কপি নিজেই বদলায়, বদল যায় WebSocket-এ (পর্ব ১০)। প্রতি keystroke-এ ছোট ছোট conflict.
lag ছোট, conflict অগণিতচারটির কাঠামো একই: কয়েকটি replica বিচ্ছিন্ন অবস্থায় আলাদা বদল নেয় (diverge), যোগাযোগ ফিরলে মেলে (converge). নিচে সবচেয়ে সরল রূপ:
বিচ্ছিন্ন হয়ে আলাদা, মিলে আবার এক
Two replicas diverge while offline, then merge to the same state
Conflict এড়ানোর একমাত্র নিশ্চিত উপায় হলো প্রতিটি লেখার আগে কপিগুলোকে একমত করানো — consensus বা lock (পর্ব ৪২, পর্ব ৪৩)। কিন্তু তাতে বিচ্ছিন্ন অবস্থায় লেখা বন্ধ, আর প্রতিটি লেখায় round trip — CAP-এর সেই দর-কষাকষি (পর্ব ৪০)। যে নকশা বিচ্ছিন্ন অবস্থায়ও লেখা নিতে চায়, তাকে পরে মেলানোর নিয়ম মেনে নিতেই হবে। প্রশ্ন শুধু: কোন নিয়ম, আর তাতে কী হারায়।
২ · প্রথম কৌশল: conflict এড়ানো — প্রতিটি key-এর একটি “বাড়ি”
সবচেয়ে ভালো conflict হলো যেটি কখনো ঘটে না। Conflict avoidance-এর ধারণা সরল: multi-leader সিস্টেমেও প্রতিটি key-এর একটি “বাড়ির” region বা replica ঠিক করে দিন, আর সেই key-এর সব লেখা সেখানেই পাঠান। ধরা যাক একটি কাল্পনিক ride-sharing অ্যাপের প্রতিটি ব্যবহারকারীর প্রোফাইল তাঁর নিবন্ধনের দেশের region-এ “বাস” করে। ঢাকার ব্যবহারকারীর প্রোফাইলের লেখা সবসময় ঢাকার কাছের region-এ; অন্য region-গুলো শুধু কপি পড়ে। প্রতিটি রেকর্ডের কার্যত একটিই leader — single-leader-এর নিরাপত্তা, কিন্তু region ভাগ করে।
এটি অনেক ক্ষেত্রেই যথেষ্ট — ব্যবহারকারীর নিজের ডেটা, একটি দোকানের মালামাল, একটি chat-এর বার্তা (পর্ব ৫৫)। কিন্তু চারটি জায়গায় ভাঙে:
- বাড়ি বিকল হলে: লেখা অন্য region-এ সরাতে হয়; পুরোনো বাড়ির না-পৌঁছানো লেখাগুলো ফিরে এলে নতুন বাড়ির লেখার সাথে conflict.
- বাড়ি বদলালে: ব্যবহারকারী দেশ বদলালেন বা নতুন region খুলল — সরানোর মুহূর্তে দুই জায়গাতেই লেখা যেতে পারে।
- ভাগ করা ডেটায়: group document বা ভাগ করা ফর্দের একক “মালিক” নেই; সব লেখা এক জায়গায় গেলে দূরের সদস্যের latency বাড়ে।
- Offline ফোনে: নেটওয়ার্কই নেই, “বাড়িতে” পাঠানোর প্রশ্নই ওঠে না।
এড়ানো না গেলে মেলানোর পথগুলো এক নজরে — প্রতিটি নিচের অংশগুলোতে বিস্তারিত:
| কৌশল | কে মেলায় | কী হারাতে পারে | কোথায় মানায় |
|---|---|---|---|
| এড়ানো (বাড়ির region) | কেউ না — conflict হয় না | বাড়ি বিকল বা বদলের মুহূর্তে নিশ্চয়তা | মালিকানা স্পষ্ট এমন ডেটা |
| Last-write-wins | Database, timestamp দেখে | Concurrent লেখার সবগুলো একটি বাদে; ঘড়ি ভুলে পরের লেখাও | একবার লেখা key, cache, “শেষ দেখা” ধরনের মান |
| Siblings + অ্যাপের merge | পড়ার সময় অ্যাপ বা ব্যবহারকারী | কিছু না — তবে merge ভুল হলে অদ্ভুত ফল | কার্ট, প্রোফাইল, যেখানে মানুষ বাছতে পারেন |
| নিজের merge function (লেখার সময়) | Database-এর ভেতরে অ্যাপের কোড | নিয়মটি যতটুকু ফেলে দেয় | নির্দিষ্ট ব্যবসার নিয়ম (বুকিংয়ের বিকল্প সময়) |
| CRDT | Data type নিজেই, গাণিতিক নিয়মে | কিছু না — কিন্তু semantics আগে থেকে বাঁধা | Counter, set, map, টেক্সট |
| Coordination (consensus, lock) | লেখার আগেই একমত | বিচ্ছিন্ন অবস্থায় লেখা নেওয়ার ক্ষমতা | ব্যালান্স, অনন্য username, সিট |
৩ · Last-write-wins — সহজ মিল, নিঃশব্দ ক্ষতি
Last-write-wins (LWW)-এ প্রতিটি লেখার সাথে একটি timestamp যায়; দুটি সংস্করণ মুখোমুখি হলে বড় timestamp-এরটি টিকে থাকে, সমান হলে replica-র নাম দিয়ে নিষ্পত্তি। নিয়মটি নির্ধারিত, ক্রমের ওপর নির্ভর করে না, তাই সব কপি একই মানে মেলে — convergence নিশ্চিত। Apache Cassandra প্রতিটি column-এর মানের জন্য এভাবেই LWW ব্যবহার করে বলে তার documentation-এ বলা আছে। পর্ব ২২-এর demo-তে দেখেছেন, দুটি concurrent লেখার একটি LWW নিঃশব্দে ফেলে দেয়। এখানে আরও অস্বস্তিকর একটি সত্য মাপব: ঘড়ি ভুল হলে concurrent নয়, পরপর লেখাও হারায়।
দুটি replica, A আর B. B-র ঘড়ির ভুল (clock skew) seed দিয়ে এলোমেলো বাছা; replication-এ ১০০ ms লাগে। একটি key-তে ছয়টি সম্পাদনা, প্রতিটি আগেরটির ৬০ থেকে ৪০০ ms পরে, এলোমেলো replica-তে। প্রতিটি সম্পাদনা আগেরটি দেখে লেখা কি না (“saw-prev”) আর sync-এর পর সবাই কোন মান দেখছে, দুটিই ছাপা হবে; পাশে Lamport clock-এর হিসাব (পর্ব ৩৯): যা দেখেছে তার চেয়ে এক বড় সংখ্যা।
// LWW on two replicas: B's clock is off by a seeded amount. Which "later" edit is lost?
function mulberry32(a) { return () => { a = a + 0x6D2B79F5 | 0;
let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
const rand = mulberry32(46);
const skewB = Math.round((rand() - 0.5) * 1200); // B's clock error: -600..+600 ms
const delay = 100; // replication delay A <-> B (ms)
const writes = []; let real = 0;
for (let i = 1; i <= 6; i++) {
real += Math.round(60 + rand() * 340); // next edit 60..400 ms later
const at = rand() < 0.5 ? "A" : "B";
const seen = writes.filter(w => w.at === at || real - w.real >= delay); // already arrived
const lam = 1 + Math.max(0, ...seen.map(w => w.lam)); // Lamport: beat everything seen
writes.push({ id: "w" + i, at, real, ts: real + (at === "B" ? skewB : 0), lam,
sawPrev: i === 1 ? "-" : seen.some(w => w.id === "w" + (i - 1)) ? "yes" : "no" });
}
const pick = (key, list) => list.reduce((a, b) =>
(b[key] > a[key] || (b[key] === a[key] && b.at > a.at)) ? b : a);
console.log(`B clock skew ${skewB} ms, replication delay ${delay} ms`);
console.log("edit at real wall-stamp lamport saw-prev value after sync (wall LWW)");
writes.forEach((w, i) => console.log(`${w.id} ${w.at} ${String(w.real).padStart(4)} ` +
`${String(w.ts).padStart(10)} ${String(w.lam).padStart(7)} ${w.sawPrev.padEnd(8)} ` +
pick("ts", writes.slice(0, i + 1)).id));
for (const [name, key] of [["wall-clock LWW", "ts"], ["Lamport LWW", "lam"]]) {
const win = pick(key, writes);
const lost = writes.filter(w => w.real > win.real).map(w => w.id);
console.log(`${name}: final value from ${win.id}; later-in-real-time edits lost: ` +
(lost.length ? lost.join(", ") : "none"));
}
const shown = new Set(writes.map((w, i) => pick("ts", writes.slice(0, i + 1)).id));
console.log("edits never shown after sync (wall LWW): " +
writes.filter(w => !shown.has(w.id)).map(w => w.id).join(", "));আউটপুট:
B clock skew -528 ms, replication delay 100 ms
edit at real wall-stamp lamport saw-prev value after sync (wall LWW)
w1 A 371 371 1 - w1
w2 A 701 701 2 yes w2
w3 B 854 326 3 yes w2
w4 B 916 388 4 yes w2
w5 A 1229 1229 5 yes w5
w6 B 1356 828 6 yes w5
wall-clock LWW: final value from w5; later-in-real-time edits lost: w6
Lamport LWW: final value from w6; later-in-real-time edits lost: none
edits never shown after sync (wall LWW): w3, w4, w6
B-র ঘড়ি 528 ms পিছিয়ে। প্রতিটি সম্পাদনার “saw-prev” কলামে yes — কেউই concurrent নয়, সবাই আগেরটি দেখে লিখেছেন। তবু w3 আর w4 (B-তে, stamp 326 আর 388) w2-র 701-কে হারাতে পারল না: sync-এর পর সবখানে w2-ই থাকল। B-র ব্যবহারকারী নিজের দুটি সম্পাদনা এক মুহূর্ত দেখলেন, তারপর উধাও। আসল সময়ে সবশেষের সম্পাদনা w6 (১,৩৫৬ ms), কিন্তু তার stamp 828 < 1229 — চূড়ান্ত মান w5-এর। মোট তিনটি সম্পাদনা sync-এর পর কখনো দেখাই গেল না, আর কোনো error-ও এল না। Lamport কলামে প্রতিটি লেখার সংখ্যা যা দেখেছে তার চেয়ে বড়, তাই কারণ-ফলে পরের লেখা সবসময় জেতে — কিছুই হারায়নি। তবে সতর্কতা: সত্যিকারের concurrent দুটি লেখা একই Lamport সংখ্যা পেতে পারে, তখন replica-র নামই বিজয়ী ঠিক করে, অর্থাৎ একটি তখনো হারায়।
ভবিষ্যতের timestamp সবচেয়ে বিপজ্জনক। একটি node-এর ঘড়ি এক ঘণ্টা এগিয়ে থাকলে তার একটি লেখা পরের এক ঘণ্টার সব লেখাকে হারিয়ে দেয় — সবাই “OK” পায়, কিন্তু কিছুই বদলায় না। NTP সাধারণত একই data center-এ ঘড়ি কয়েক ms বা তার কমের মধ্যে রাখে (internet-এ কয়েক দশ ms পর্যন্ত), কিন্তু বিকল NTP, virtual machine থেমে থাকা বা হাতে বসানো ভুল সময়ে পার্থক্য সেকেন্ড-মিনিট ছাড়াতে পারে (পর্ব ৩৮)। ফোনের ঘড়ি ব্যবহারকারী নিজেই বদলাতে পারেন — offline অ্যাপে ফোনের timestamp দিয়ে LWW তাই প্রায় সবসময় ভুল সিদ্ধান্ত।
তাহলে LWW কি বাতিল? না — কোথায় নিরাপদ, সেটি জানা দরকার: (১) যে key একবারই লেখা হয় (key একটি UUID হলে concurrent লেখা কার্যত হয় না); (২) cache আর আনুমানিক মান, যেমন “ড্রাইভারের শেষ অবস্থান”, যার পরের update কয়েক সেকেন্ডেই আসছে; (৩) হারালেও ক্ষতি নেই এমন সেটিং, যেমন theme-এর রং। কিন্তু টাকা, গণনা, তালিকা বা ব্যবহারকারীর লেখায় LWW-এর হারানোটা একটি bug.
৪ · Siblings রেখে পড়ার সময় মেলানো — Dynamo-র shopping cart
LWW-এর উল্টো দর্শন: database কোনো সিদ্ধান্ত নেবে না, শুধু চিনবে কোন সংস্করণগুলো concurrent, আর সবগুলোই রেখে দেবে। এই একসাথে টিকে থাকা সংস্করণগুলোর নাম siblings. চেনার যন্ত্র version vector — প্রতিটি replica-র জন্য একটি counter; একটি vector অন্যটির প্রতিটি ঘরে সমান বা বড় হলে সেটি “নতুন” আর পুরোনোটি ফেলা যায়, নইলে দুটি concurrent (তুলনার নিয়ম পর্ব ৩৯-এ, leaderless store-এ siblings কীভাবে জন্মায় পর্ব ৪১-এ)। পড়ার সময় client সব sibling আর একটি মেলানো vector পায়; অ্যাপ সেগুলো মিলিয়ে একটি নতুন সংস্করণ লেখে, আর সেই vector সব sibling-কে ঢেকে দেয় বলে তারা মুছে যায়।
২০০৭ সালে Amazon-এর প্রকৌশলীরা (DeCandia ও সহকর্মীরা) SOSP সম্মেলনে “Dynamo: Amazon’s Highly Available Key-value Store” প্রকাশ করেন। Paper-টির নিজের উদাহরণ shopping cart: কার্টে “যোগ করুন” কখনো হারানো চলবে না, এমনকি network ভাগ হলেও। তাই Dynamo concurrent সংস্করণগুলো vector clock দিয়ে চিনে রাখে, আর কার্ট-অ্যাপ সেগুলো union দিয়ে মেলায়। Paper নিজেই এর দাম স্বীকার করে: এতে “add to cart” কখনো হারায় না, কিন্তু মুছে ফেলা জিনিস আবার ফিরে আসতে পারে (deleted items can resurface).
কেন ফিরে আসে, ধাপে ধাপে দেখুন। কার্টে দুধ আর চিনি; ফোন আর ল্যাপটপ দুটি আলাদা replica-র সাথে কথা বলছে, আর মাঝের network ভাগ হয়ে গেছে।
| Replica A-র কার্ট | |
| A-র version vector | |
| Replica B-র কার্ট | |
| B-র version vector | |
| তুলনা বা সিদ্ধান্ত |
- শুরু — দুই কপি এক। কার্টে দুধ আর চিনি, vector
{A:1}— A-তে একটি লেখা হয়েছিল, B সেটি পেয়েছে। - Network ভাগ; ফোন থেকে চিনি মোছা। ফোনটি A-র সাথে কথা বলছে। A-র কার্ট এখন শুধু দুধ, vector
{A:2}. B এর কিছুই জানে না। - ল্যাপটপ থেকে চাল যোগ। ল্যাপটপ B-র সাথে কথা বলছে, B-র কাছে তখনো পুরোনো কার্ট (দুধ, চিনি)। নতুন সংস্করণ: দুধ, চিনি, চাল; vector
{A:1, B:1}. - পড়া — দুটি sibling. Network ফিরল।
{A:2}-এর B-ঘর ০, অন্যটির ১;{A:1, B:1}-এর A-ঘর ১, অন্যটির ২। কেউ কাউকে ঢাকে না — concurrent. LWW হলে একটি নিঃশব্দে হারাত; এখানে দুটিই ফেরত এল। - Union-এ মেলানো — চিনি ফিরে এল। অ্যাপ নিল union: দুধ, চিনি, চাল. নতুন vector দুই vector-এর ঘরে ঘরে বড় মানের ওপর A-র এক ধাপ,
{A:3, B:1}— দুটি sibling-কেই ঢাকে, তাই তারা মুছে গেল। কিন্তু union জানে না চিনি মোছা হয়েছিল; সে শুধু দেখে একটি তালিকায় চিনি আছে — ঠিক Dynamo paper-এর স্বীকারোক্তি। - প্রতিকার — মোছাও মনে রাখা। প্রতিটি সংস্করণ “কী মোছা হয়েছে” তালিকাও (tombstone) বহন করলে মেলানো হয় “union বিয়োগ মোছা”: দুধ, চাল. নতুন সমস্যা: পরে কেউ সত্যিই আবার চিনি যোগ করলে tombstone সেটিকেও আটকাবে — সমাধান অংশ ৭-এর OR-Set.
একই গল্প কোডে — version vector-এর তুলনা, union আর tombstone-এর ফল নিজে মিলিয়ে দেখুন:
// Dynamo-style shopping cart: concurrent versions kept as siblings, merged by union.
const covers = (a, b) => Object.keys(b).every(r => (a[r] || 0) >= b[r]); // a has seen b?
const relation = (a, b) => covers(a, b) && covers(b, a) ? "equal" :
covers(a, b) ? "first is newer" : covers(b, a) ? "second is newer" : "concurrent";
const bump = (vv, r) => ({ ...vv, [r]: (vv[r] || 0) + 1 });
const vmax = (a, b) => { const o = { ...a }; for (const r in b) o[r] = Math.max(o[r] || 0, b[r]); return o; };
const fmt = v => `[${v.items.join(", ")}] ${JSON.stringify(v.vv)}`;
let base = { items: ["milk", "sugar"], vv: { A: 1 } }; // both replicas start here
// Network split. Phone talks to replica A, laptop talks to replica B.
const onA = { items: base.items.filter(x => x !== "sugar"), vv: bump(base.vv, "A") };
const onB = { items: [...base.items, "rice"], vv: bump(base.vv, "B") };
console.log("replica A (phone removed sugar):", fmt(onA));
console.log("replica B (laptop added rice): ", fmt(onB));
console.log("compare version vectors:", relation(onA.vv, onB.vv), "-> keep both as siblings");
// Read returns both siblings; the app merges them by union, then writes the result.
const union = [...new Set([...onA.items, ...onB.items])];
const merged = { items: union, vv: bump(vmax(onA.vv, onB.vv), "A") };
console.log("union merge:", fmt(merged));
console.log("deleted item back?", merged.items.includes("sugar") ? "YES, sugar reappeared" : "no");
console.log("merged vs siblings:", relation(merged.vv, onA.vv) + ",", relation(merged.vv, onB.vv));
// Fix attempt: carry a 'removed' list (tombstones) with each version.
onA.removed = ["sugar"]; onB.removed = [];
const removed = new Set([...onA.removed, ...onB.removed]);
console.log("union minus tombstones:", JSON.stringify(union.filter(x => !removed.has(x))));
console.log("user adds sugar again later:", JSON.stringify([...union, "sugar"]
.filter((x, i, arr) => arr.indexOf(x) === i && !removed.has(x))), "(blocked by the tombstone)");আউটপুট:
replica A (phone removed sugar): [milk] {"A":2}
replica B (laptop added rice): [milk, sugar, rice] {"A":1,"B":1}
compare version vectors: concurrent -> keep both as siblings
union merge: [milk, sugar, rice] {"A":3,"B":1}
deleted item back? YES, sugar reappeared
merged vs siblings: first is newer, first is newer
union minus tombstones: ["milk","rice"]
user adds sugar again later: ["milk","rice"] (blocked by the tombstone)
তৃতীয় লাইনে concurrent — দুটি sibling রাখতেই হবে। Union-এ YES, sugar reappeared: মোছাটি কোনো সংস্করণের তালিকায় ছাপ রাখেনি, তাই মেলানোর সময় তার কোনো ভোট নেই। মেলানো vector {"A":3,"B":1} দুটি sibling-এর চেয়েই নতুন (first is newer দুবার), তাই পরের পড়ায় একটিই সংস্করণ। Tombstone যোগ করলে ["milk","rice"] — ঠিক। কিন্তু শেষ লাইনে দেখুন, পরে আবার যোগ করা চিনিও আটকে গেল: “একবার মোছা মানে চিরকাল মোছা” — এটি অংশ ৭-এর 2P-Set-এর আচরণ।
নিজের merge function — লেখার সময়, না পড়ার সময়
Siblings-এর ভালো দিক হলো মেলানোর সিদ্ধান্ত যায় সেই জায়গায়, যে ডেটার অর্থ জানে — অ্যাপ। একটি merge function দুই জায়গায় চলতে পারে:
- পড়ার সময়: Database siblings ফেরত দেয়, অ্যাপ মেলায় বা ব্যবহারকারীকে বাছতে দেয়। Riak-এর মতো Dynamo-ধাঁচের store-এ siblings রাখার setting আছে বলে documentation-এ পাওয়া যায়; Apache CouchDB সব conflicting revision রেখে একটি নির্ধারিত “বিজয়ী” দেখায়, অ্যাপ চাইলে বাকিগুলো এনে মেটায়।
- লেখার সময়: Replica conflict দেখামাত্র অ্যাপের দেওয়া কোড চালায়। ঐতিহাসিক উদাহরণ Xerox PARC-এর Bayou (Terry ও সহকর্মীরা, SOSP ১৯৯৫): প্রতিটি লেখার সাথে একটি “dependency check” আর একটি “merge procedure” — যেমন সভাকক্ষের চাওয়া সময় দখল হলে তালিকার পরের বিকল্প সময় নেওয়া।
যে merge function বিভিন্ন replica-য় বিভিন্ন ক্রমে চলবে, তাকে চারটি শর্ত মানতে হবে: নির্ধারিত (একই input-এ একই output, এলোমেলো বা ঘড়ি-নির্ভর নয়), ক্রমে উদাসীন (A আর B মেলানো = B আর A), দলে উদাসীন ((A, B), তারপর C = A, তারপর (B, C)), আর পুনরাবৃত্তিতে উদাসীন (একই জিনিস দুবার মেলালেও একই ফল)। একটিও ভাঙলে দুটি replica একই সংস্করণ পেয়েও আলাদা ফলে পৌঁছাতে পারে। এই চার শর্ত মানে এমন data type-ই হলো CRDT — পরের অংশের বিষয়।
৫ · Strong eventual consistency আর CRDT-র গণিত
Eventual consistency শুধু বলে: লেখা থামলে শেষ পর্যন্ত সব কপি মিলবে — কবে, কোন নিয়মে, মাঝে কী হারাবে, তা বলে না। CRDT (conflict-free replicated data type) একটি কড়া নিশ্চয়তা দেয়, যার নাম strong eventual consistency (SEC): যে দুটি replica একই পরিবর্তনগুলো পেয়েছে, তারা যে ক্রমেই পাক, তখনই একই অবস্থায় — কোনো আলাদা মেটানোর ধাপ, কোনো sibling, কোনো coordination ছাড়াই। খেয়াল করুন, এটি CAP-এর C (linearizability) নয়: দুটি replica তখনো আলাদা জিনিস দেখাতে পারে, যদি তারা এখনো আলাদা পরিবর্তন পেয়ে থাকে।
নাম আর আনুষ্ঠানিক কাঠামো এসেছে Marc Shapiro, Nuno Preguiça, Carlos Baquero ও Marek Zawirski-র ২০১১ সালের দুটি লেখা থেকে — INRIA-র একটি বিস্তারিত technical report (“A comprehensive study of Convergent and Commutative Replicated Data Types”) আর SSS সম্মেলনের paper “Conflict-free Replicated Data Types”. তাঁরা দেখান, অবস্থা পাঠানো আর কাজ পাঠানো — দুই নকশা সমান শক্তিশালী, আর counter, register, set, graph-এর একটি তালিকা দেন। শিকড় পুরোনো: ২০০০-এর দশকের সহযোগী টেক্সট সম্পাদনার কাজ (WOOT, Treedoc, Logoot).
State-based CRDT — অবস্থা পাঠান, join করুন
State-based CRDT (CvRDT)-এ প্রতিটি replica মাঝে মাঝে নিজের পুরো অবস্থা অন্যদের পাঠায়, আর গ্রহীতা নিজের অবস্থার সাথে একটি merge করে। শর্ত তিনটি:
- Commutative: merge(a, b) = merge(b, a) — কে কার কাছে আগে পাঠাল, তাতে কিছু যায় আসে না।
- Associative: merge(merge(a, b), c) = merge(a, merge(b, c)) — কোন পথে gossip ঘুরল, তাতে কিছু যায় আসে না (পর্ব ৪৪)।
- Idempotent: merge(a, a) = a — একই অবস্থা দুবার এলে ক্ষতি নেই; network-এর duplicate বা retry নিরাপদ (idempotency, পর্ব ৩৪)।
সঙ্গে একটি চতুর্থ শর্ত: প্রতিটি local update অবস্থাকে শুধু “ওপরে” তোলে, কখনো নামায় না। গণিতের ভাষায় অবস্থাগুলো একটি join-semilattice: একটি আংশিক ক্রম (যেমন “সেট A, সেট B-র উপসেট”), যেখানে যেকোনো দুটি অবস্থার একটি সবচেয়ে ছোট ঊর্ধ্বসীমা আছে — join, আর merge ঠিক সেটিই। সেটের জন্য join মানে union, সংখ্যার জন্য max.
ধরুন মোট কয়েকটি update ঘটেছে, প্রতিটি কোনো একটি replica-তে, আর প্রতিটির ফলে তৈরি হয়েছে একটি অবস্থা s₁, s₂, …, sₖ. একটি replica এগুলোর যেকোনো উপসেট, যেকোনো ক্রমে, যেকোনো দলে, যতবার খুশি পেয়ে merge করেছে। Associative আর commutative বলে merge-এর ক্রম আর বন্ধনী সরিয়ে লেখা যায়: ফল = sᵢ-গুলোর join. Idempotent বলে একই sᵢ দুবার এলে join বদলায় না। তাই ফল শুধু নির্ভর করে কোন অবস্থাগুলো পৌঁছেছে তার সেটের ওপর — ক্রম, দল বা পুনরাবৃত্তির ওপর নয়। একই সেট পাওয়া দুটি replica তাই একই অবস্থায় — SEC. আর যেহেতু update শুধু ওপরে তোলে, একটি replica-র নিজের লেখা পরের কোনো merge-এ “নিচে” নেমে হারাতে পারে না।
Operation-based CRDT — কাজ পাঠান, কিন্তু ক্রম মেনে
Operation-based CRDT (CmRDT)-এ পুরো অবস্থা নয়, শুধু কাজটি যায়: “+১”, “চাল যোগ, tag b2”. বার্তা ছোট, কিন্তু network-এর কাছে দাবি বেশি। Concurrent কাজগুলোকে commute করতে হয় (+৩ আর +২ যেকোনো ক্রমে ৫)। আর প্রতিটি কাজ প্রতিটি replica-তে ঠিক একবার পৌঁছাতে হয় — “+১” দুবার এলে গণনা ভুল — এবং প্রায়ই কারণ-ফলের ক্রমে (causal delivery). উদাহরণ: replica C-তে “tag a1 মুছুন” যদি “দুধ যোগ, tag a1”-এর আগে পৌঁছায়, C তখন মোছার মতো কিছু পায় না; পরে যোগ এলে দুধ C-তে থেকে যায় চিরকাল, অথচ অন্য সবখানে নেই। তাই CmRDT সাধারণত একটি নির্ভরযোগ্য causal broadcast স্তরের ওপর বসে, যা vector clock দেখে বার্তা আটকে রাখে যতক্ষণ না তার কারণ-বার্তা আসে। ধারণাটি পর্ব ৩৫-এর event log-এর কাছাকাছি; পার্থক্য, সেখানে সাধারণত একটি মোট ক্রম, এখানে শুধু কারণ-ফলের আংশিক ক্রম।
দুইয়ের মাঝামাঝি delta-state CRDT (Almeida, Shoker ও Baquero-র ২০১৫ সালের দিকের কাজ): শুধু সাম্প্রতিক বদলের ছোট অংশ পাঠানো হয়, যা নিজেও একটি অবস্থা, তাই একই join দিয়ে জোড়া যায়।
৬ · Counter CRDT — G-Counter আর PN-Counter
একটি অনলাইন ক্লাসে কতজন যোগ দিলেন, তিনটি region-এর তিনটি replica গুনছে। সরল দুই উপায়ই ভাঙে। মান বসানো (“count = 42”) আসলে একটি register, LWW-তে একসাথে আসা বাড়ানো হারায়। “+১” বার্তা পাঠানো CmRDT হিসেবে চলে, কিন্তু at-least-once delivery-তে retry হলে একই “+১” দুবার গোনা হয়। G-Counter-এর কৌশল: প্রতিটি replica-র জন্য একটি আলাদা ঘর। Replica শুধু নিজের ঘর বাড়ায়; মান = সব ঘরের যোগফল; merge = ঘরে ঘরে বড় মানটি (element-wise max). একই replica-র ঘর শুধু বাড়ে, তাই বড়টিই সবচেয়ে নতুন।
| Replica A | |
| Replica B | |
| Replica C | |
| মান (A · B · C) | |
| কী ঘটল |
- শুরু। তিনটি replica, প্রতিটির কাছে তিন ঘরের একটি vector — A-র ঘর, B-র ঘর, C-র ঘর। সব শূন্য।
- যোগাযোগ ছাড়াই গোনা। A-তে তিনজন, B-তে দুজন, C-তে একজন যোগ দিলেন। প্রত্যেকে শুধু নিজের ঘর বাড়াল। প্রত্যেকের নিজের চোখে মান যথাক্রমে ৩, ২, ১ — কেউই পুরো ছবি জানে না।
- A নিল B-র অবস্থা। ঘরে ঘরে বড়টি: A-ঘরে max(৩, ০) = ৩, B-ঘরে max(০, ২) = ২, C-ঘরে ০। A-র vector [3, 2, 0], মান ৫।
- C নিল A-র অবস্থা। max([0, 0, 1], [3, 2, 0]) = [3, 2, 1] — মান ৬। C কখনো B-র সাথে কথা না বলেও B-র দুজনকে জানল, কারণ A তাদের খবর বহন করছিল।
- সবাই মিলল। B আর A দুজনেই C-র অবস্থা নিল — সবার [3, 2, 1], মান ৬, আসল যোগফলের সমান। কোন ক্রমে কে কার কাছে গেল, তাতে ফল বদলায়নি।
- পুরোনো বার্তা আবার এলো। দেরিতে আসা B-র পুরোনো [0, 2, 0]. ঘরে ঘরে max — কিছুই বদলাল না, মান ৬। “B-র মান যোগ করুন” ধরনের সরল নিয়মে হতো ৬ + ২ = ৮ — দুবার গোনা। এটাই idempotent merge-এর মূল্য।
কমানোও চাই? PN-Counter দুটি G-Counter রাখে: P-তে সব বাড়ানো, N-এ সব কমানো; মান = P-র যোগফল − N-এর যোগফল। দুটিই শুধু বাড়ে, তাই merge আগের মতোই ঘরে ঘরে max. নিচের সিমুলেটরে তিনটি replica offline অবস্থায় যোগ দেওয়া (+১) আর বেরিয়ে যাওয়া (−১) গোনে; তারপর চারবার এলোমেলো gossip-ক্রমে মেলে, আর সরল দুটি নিয়মের সাথে তুলনা:
// G-Counter and PN-Counter on 3 replicas: merge in any order, any number of times.
function mulberry32(a) { return () => { a = a + 0x6D2B79F5 | 0;
let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
const rand = mulberry32(46), R = ["A", "B", "C"];
const gMerge = (x, y) => { const o = { ...x }; // element-wise max
for (const r in y) o[r] = Math.max(o[r] || 0, y[r]); return o; };
const gVal = g => Object.values(g).reduce((s, v) => s + v, 0);
const pnMerge = (x, y) => ({ P: gMerge(x.P, y.P), N: gMerge(x.N, y.N) });
const pnVal = s => gVal(s.P) - gVal(s.N);
const same = (x, y) => R.every(r => (x.P[r] || 0) === (y.P[r] || 0) && (x.N[r] || 0) === (y.N[r] || 0));
// Offline, each replica counts people joining (+1) and leaving (-1) a live class
const st = {}; let trueTotal = 0;
for (const r of R) {
st[r] = { P: {}, N: {} };
for (let i = 0; i < 12; i++) {
const k = rand() < 0.7 ? "P" : "N"; st[r][k][r] = (st[r][k][r] || 0) + 1;
trueTotal += k === "P" ? 1 : -1;
}
console.log(`${r}: joins ${st[r].P[r] || 0}, leaves ${st[r].N[r] || 0}, local value ${pnVal(st[r])}`);
}
console.log("true total:", trueTotal);
for (let trial = 1; trial <= 4; trial++) { // random gossip orders
const c = { ...st }, log = [];
while (!(same(c.A, c.B) && same(c.B, c.C))) {
const f = Math.floor(rand() * 3), from = R[f], to = R[(f + 1 + Math.floor(rand() * 2)) % 3];
c[to] = pnMerge(c[to], c[from]); log.push(from + ">" + to); // "to" pulls from "from"
}
console.log(`gossip ${log.join(" ")}: A=${pnVal(c.A)} B=${pnVal(c.B)} C=${pnVal(c.C)}, G=${gVal(c.A.P)}`);
}
const all = pnMerge(pnMerge(st.A, st.B), st.C), again = pnMerge(pnMerge(all, st.B), all); // re-deliver
console.log("merge the same states again:", pnVal(again), "(idempotent)");
const naiveSum = pnVal(st.A) + pnVal(st.B) + pnVal(st.C);
console.log("naive 'add totals' merge, once:", naiveSum, "| B delivered twice:", naiveSum + pnVal(st.B));
console.log("naive 'max of totals' merge:", Math.max(...R.map(r => pnVal(st[r]))));আউটপুট:
A: joins 8, leaves 4, local value 4
B: joins 9, leaves 3, local value 6
C: joins 10, leaves 2, local value 8
true total: 18
gossip C>B A>C C>B B>C C>B B>A: A=18 B=18 C=18, G=27
gossip A>C B>A C>B B>C A>C B>C A>B B>A: A=18 B=18 C=18, G=27
gossip A>C C>A C>A B>A C>A B>A B>A B>C C>B: A=18 B=18 C=18, G=27
gossip C>A B>C A>B A>C C>A: A=18 B=18 C=18, G=27
merge the same states again: 18 (idempotent)
naive 'add totals' merge, once: 18 | B delivered twice: 24
naive 'max of totals' merge: 8
প্রতিটি replica শুরুতে শুধু নিজেরটুকু জানে (4, 6, 8); আসল মোট 18. চারটি gossip-ক্রম আলাদা, দৈর্ঘ্যও আলাদা (৬, ৮, ৯ আর ৫টি merge), কিছু merge একই জিনিস আবার পাঠিয়েছে — তবু চারবারই তিন replica 18-এ মিলেছে, আর শুধু-যোগ দেওয়ার G-Counter 27 = ৮ + ৯ + ১০। একই অবস্থা আবার মেলালেও 18 — idempotent. সরল “মোট যোগ করুন” নিয়ম একবারে ঠিক (18), কিন্তু B-র অবস্থা দুবার এলে 24; “সবচেয়ে বড় মোটটি নিন” নিয়মে 8 — বাকি দুই replica-র ১০ জন হারাল। প্রতি replica-র আলাদা ঘর থাকাতেই merge বুঝতে পারে কোন বাড়ানো “একই” আর কোনটি নতুন।
দুটি সতর্কতা। (১) যত replica, তত ঘর; প্রতিটি ফোনকে replica বানালে লাখো ঘর (অনুশীলন ১)। প্রচলিত সমাধান: ফোন সার্ভারের একটি replica-তে idempotency key-সহ “+১” পাঠায়, CRDT চলে শুধু সার্ভারের replica-দের মধ্যে। (২) PN-Counter শূন্যের নিচে যাওয়া আটকায় না — অংশ ৮ দেখুন।
৭ · Set, register আর OR-Set — add-wins-এর কৌশল
Counter-এর পরে সবচেয়ে কাজের CRDT হলো set আর register. সবচেয়ে সরল G-Set: শুধু যোগ, merge = union — ছবি ৪৬.১ ঠিক এটাই। মোছা চাইলে 2P-Set: একটি যোগের G-Set আর একটি মোছার G-Set (tombstone); জিনিস আছে যদি যোগে থাকে কিন্তু মোছায় না থাকে। অংশ ৪-এর tombstone-প্রতিকার এটাই — মোছা জেতে, মোছা জিনিস আর ফেরে না: কার্টে অচল, “নিষিদ্ধ ID-র তালিকা”-য় ঠিক যা দরকার।
OR-Set (observed-remove set) দুটি সমস্যাই সমাধান করে। কৌশল: প্রতিটি যোগ একটি অনন্য tag পায় (replica-র নাম + একটি ক্রমিক সংখ্যা)। মোছা সেই মুহূর্তে যে tag-গুলো দেখা যাচ্ছে শুধু সেগুলোকে tombstone করে। জিনিস আছে, যদি তার অন্তত একটি tag tombstone-এর বাইরে থাকে। Merge = tag-গুলোর union আর tombstone-এর union. একসাথে এক জায়গায় মোছা আর অন্য জায়গায় নতুন যোগ হলে নতুন যোগের tag মোছার সময় কেউ দেখেনি, তাই সেটি টিকে যায় — add-wins.
| A: দুধের tag | |
| A: tombstone | |
| A-তে দুধ আছে? | |
| B: দুধের tag | |
| B: tombstone | |
| B-তে দুধ আছে? |
- শুরু। A-তে দুধ যোগ হয়েছিল, tag
a1; B sync করেছে। দুই জায়গায় একই অবস্থা, tombstone খালি। - A দুধ মুছল। মোছা মানে “এখন যে tag-গুলো দেখছি সেগুলো মৃত”: tombstone-এ
a1. A-তে দুধ নেই। B এখনো জানে না। - একই সময়ে B আবার দুধ যোগ করল। হয়তো অন্য কেউ মনে করলেন দুধ লাগবে। নতুন যোগ, নতুন tag
b2. B-তে দুধের tag এখনa1আরb2. - B নিল A-র অবস্থা। Tag-এর union {a1, b2}, tombstone-এর union {a1}. বেঁচে থাকা tag
b2— দুধ আছে। A-র মোছা শুধু সেই যোগটি মুছেছে যা সে দেখেছিল। - A নিল B-র অবস্থা। একই union, একই ফল — দুই জায়গাতেই দুধ আছে। একসাথে মোছা আর যোগে যোগ জিতল, আর ফল আসার ক্রমের ওপর নির্ভর করেনি।
- A আবার মুছল — এবার সব দেখে। A এখন
a1আরb2দুটিই দেখে, দুটিকেই tombstone করল; sync-এর পর দুই জায়গাতেই দুধ নেই। পরে আবার যোগ করলে নতুন tag পাবে, যা কোনো tombstone-এ নেই। কিন্তুa1আরb2-এর চিহ্ন অবস্থায় রয়ে গেল — এটিই CRDT-র metadata-খরচ।
একই ঘটনা দুইভাবে: একটি সরল set, যা কাজগুলো আসার ক্রমে প্রয়োগ করে, আর OR-Set:
// Same item, concurrent remove (replica A) and add (replica B): naive set vs OR-Set.
// 1) Naive set: each replica applies operations in the order they arrive.
const naive = (ops) => { const s = new Set(["milk"]);
for (const [op, x] of ops) op === "add" ? s.add(x) : s.delete(x); return [...s]; };
const opA = ["remove", "milk"], opB = ["add", "milk"];
console.log("naive A (own remove, then B's add):", JSON.stringify(naive([opA, opB])));
console.log("naive B (own add, then A's remove):", JSON.stringify(naive([opB, opA])));
// 2) OR-Set: every add gets a unique tag; remove deletes only the tags it has seen.
const orNew = () => ({ adds: {}, removed: new Set() });
let seq = 0;
const orAdd = (s, x, rep) => { (s.adds[x] ||= new Set()).add(rep + ++seq); };
const orRemove = (s, x) => { for (const t of s.adds[x] || []) s.removed.add(t); };
const orHas = (s, x) => [...(s.adds[x] || [])].some(t => !s.removed.has(t));
const orMerge = (a, b) => { const m = orNew();
for (const s of [a, b]) {
for (const x in s.adds) for (const t of s.adds[x]) (m.adds[x] ||= new Set()).add(t);
for (const t of s.removed) m.removed.add(t);
} return m; };
const show = s => Object.keys(s.adds).map(x => `${x}{${[...s.adds[x]].join(",")}}`).join(" ") +
` removed{${[...s.removed].join(",")}} -> milk ${orHas(s, "milk") ? "present" : "absent"}`;
let A = orNew(); orAdd(A, "milk", "a"); // milk added once, tag a1
let B = orMerge(A, orNew()); // B has synced: same state
orRemove(A, "milk"); // A removes the milk it has seen (a1)
orAdd(B, "milk", "b"); // meanwhile B adds milk again (b2)
console.log("OR-Set A before sync:", show(A));
console.log("OR-Set B before sync:", show(B));
console.log("OR-Set A after sync:", show(orMerge(A, B)));
console.log("OR-Set B after sync:", show(orMerge(B, A)));
A = orMerge(A, B); orRemove(A, "milk"); // a remove that HAS seen b2
console.log("A removes again, then sync:", show(orMerge(orMerge(A, B), A)));আউটপুট:
naive A (own remove, then B's add): ["milk"]
naive B (own add, then A's remove): []
OR-Set A before sync: milk{a1} removed{a1} -> milk absent
OR-Set B before sync: milk{a1,b2} removed{} -> milk present
OR-Set A after sync: milk{a1,b2} removed{a1} -> milk present
OR-Set B after sync: milk{a1,b2} removed{a1} -> milk present
A removes again, then sync: milk{a1,b2} removed{a1,b2} -> milk absent
সরল set-এ দুটি কাজই দুই replica-তে পৌঁছেছে, তবু A-তে ["milk"] আর B-তে [] — চিরকালের জন্য আলাদা, কারণ “যোগ” আর “মোছা” commute করে না: ফল নির্ভর করে কোনটি শেষে এল। OR-Set-এ sync-এর আগে A-তে absent, B-তে present; sync-এর পর দুই দিকেই milk{a1,b2} removed{a1} -> milk present — একই অবস্থা, যোগ জিতেছে। শেষ লাইনে A-র দ্বিতীয় মোছা b2 দেখেছিল, তাই সবখানে absent. আর লক্ষ করুন, দুধ নেই তবু অবস্থায় দুটি tag আর দুটি tombstone রয়ে গেছে।
Register (একটি মান: নাম, ঠিকানা, theme) দুই রকম। LWW-Register মানের সাথে timestamp রাখে, merge বড়টি নেয় — অংশ ৩-এর LWW, CRDT-র পোশাকে। MV-Register concurrent মানগুলো version vector-সহ সবই রাখে — অংশ ৪-এর siblings-এর CRDT রূপ। এই ইটগুলো জুড়ে map বানানো যায়, যার প্রতিটি key-এর মান নিজেই একটি CRDT (কার্টের তালিকা OR-Set, প্রতিটি জিনিসের পরিমাণ PN-Counter) — JSON-ধাঁচের document CRDT এভাবেই গড়া।
| CRDT | অবস্থা | Merge | আচরণ | উদাহরণ ব্যবহার |
|---|---|---|---|---|
| G-Counter | প্রতি replica একটি সংখ্যা | ঘরে ঘরে max | শুধু বাড়ে | মোট view, মোট ডাউনলোড |
| PN-Counter | দুটি G-Counter | দুটিতেই ঘরে ঘরে max | বাড়ে-কমে, শূন্যের নিচেও যেতে পারে | কার্টে পরিমাণ, আনুমানিক উপস্থিতি |
| G-Set | একটি সেট | union | শুধু যোগ | দেখা হয়েছে এমন ID |
| 2P-Set | যোগ-সেট + মোছা-সেট | দুটিরই union | মোছা জেতে, আর ফেরে না | নিষিদ্ধ তালিকা |
| LWW-Register | মান + timestamp | বড় timestamp | একটি জেতে, বাকি হারায় | theme, কম ঝুঁকির সেটিং |
| MV-Register | মান + version vector-এর সেট | যে মান অন্যটি ঢাকে না, সব রাখা | concurrent মান সব দেখায় | প্রোফাইলের গুরুত্বপূর্ণ field |
| OR-Set | জিনিস → tag, আর tombstone | দুটিরই union | add-wins, আবার যোগ করা যায় | কার্ট, ট্যাগ, সদস্য তালিকা |
| Sequence CRDT | অনন্য ID-ওয়ালা উপাদানের ক্রম | ID-র ক্রম মেনে জোড়া | সবখানে একই ক্রম | একসাথে টেক্সট সম্পাদনা |
Semantics নকশার সিদ্ধান্ত, গণিতের নয়। Add-wins মানে: কেউ দুধ মুছলেন, তবু দুধ দেখাল — অন্য কেউ একই সময়ে যোগ করেছিলেন। কার্টে এটি ভালো। কিন্তু admin তালিকা থেকে একজনকে সরানো আর একসাথে কারও তাঁকে আবার যোগ করায় add-wins নিরাপত্তার ঝুঁকি — সেখানে remove-wins বা coordination. প্রতিটি feature-এ জিজ্ঞেস করুন: একসাথে যোগ আর মোছায় ব্যবহারকারী কী আশা করেন?
৮ · টেক্সট, collaborative editing আর CRDT-র দাম
টেক্সট সবচেয়ে কঠিন, কারণ অবস্থান সরে যায়। লেখা “CAT”. Rumana অবস্থান ১-এ “H” ঢোকালেন (“CHAT” হবে); একই সময়ে Tanvir অবস্থান ০-তে “!” ঢোকালেন। Tanvir-এর কপি “!CAT”-এ Rumana-র কাজ “অবস্থান ১-এ H” হুবহু প্রয়োগ করলে হয় “!HCAT” — ভুল, কারণ সামনে একটি অক্ষর বেড়েছে। দুটি প্রধান পথ:
- Operational transformation (OT): আসা কাজটিকে ইতিমধ্যে প্রয়োগ হওয়া concurrent কাজ অনুযায়ী রূপান্তর করা — “অবস্থান ১” হয়ে যায় “অবস্থান ২”, ফল “!CHAT”. Ellis ও Gibbs ১৯৮৯ সালে ধারণাটি প্রকাশ করেন। বাস্তবের অনেক ব্যবস্থায় একটি কেন্দ্রীয় সার্ভার সব কাজের একটি ক্রম ঠিক করে, যাতে রূপান্তরের নিয়ম সহজ থাকে; কেন্দ্র ছাড়া নির্ভুল রূপান্তরের নিয়ম লেখা কুখ্যাতভাবে কঠিন, আর গবেষণায় প্রকাশিত কয়েকটি OT algorithm পরে ভুল প্রমাণিত হয়েছে বলে জানা যায়।
- Sequence CRDT: অবস্থানের বদলে প্রতিটি অক্ষর একটি অনন্য, স্থায়ী পরিচয় পায় (যেমন replica + ক্রমিক সংখ্যা)। কাজ হয় “C-এর পরিচয়ের পরে H”, সংখ্যার অবস্থান নয় — তাই সামনে কিছু বাড়লেও অর্থ বদলায় না। একই জায়গায় একসাথে দুটি ঢোকানো হলে পরিচয়ের একটি নির্ধারিত ক্রম ঠিক করে কে আগে। RGA (Roh ও সহকর্মীরা, ২০১১) ধাঁচে প্রতিটি অক্ষর তার বাঁ-পাশের অক্ষরের পরিচয় মনে রাখে; Logoot (২০০৯) ধাঁচে দুই প্রতিবেশীর মাঝে নতুন একটি “ভগ্নাংশ” অবস্থান বানানো হয়। মোছা অক্ষর সাধারণত tombstone হয়ে থাকে, যাতে কেউ তার পরে ঢোকাতে চাইলে পরিচয়টি খুঁজে পাওয়া যায়। (ধারণাটি এখানে সরল করে বলা; প্রতিটি algorithm-এর খুঁটিনাটি আলাদা।)
| দিক | OT | Sequence CRDT |
|---|---|---|
| মূল কৌশল | অবস্থান-ভিত্তিক কাজ রূপান্তর | অক্ষরের অনন্য পরিচয় |
| কেন্দ্রীয় সার্ভার | প্রায়ই লাগে (ক্রম ঠিক করতে) | লাগে না; peer-to-peer বা offline চলে |
| Metadata | কম — document শুধু টেক্সট | প্রতি অক্ষরে পরিচয়, মোছা অক্ষরের tombstone |
| নির্ভুলতার প্রমাণ | রূপান্তরের নিয়মে কঠিন | Merge-এর গাণিতিক গুণে তুলনামূলক সহজ |
| দীর্ঘ offline | অনেক কাজ জমলে কষ্টকর | স্বাভাবিকভাবে সামলায় |
লেখার সময়ে বহুল আলোচিত দুটি open-source CRDT library হলো Automerge (JSON-ধাঁচের document; পেছনে Martin Kleppmann ও সহকর্মীদের গবেষণা) আর Yjs (Kevin Jahns-এর শুরু করা, টেক্সট-সম্পাদনায় জনপ্রিয়)। দুটিরই API, ফরম্যাট আর কার্যক্ষমতা সংস্করণভেদে বদলেছে, তাই documentation-এ মিলিয়ে নিন। Ink & Switch গবেষণাগোষ্ঠীর ২০১৯ সালের প্রবন্ধ “Local-first software” এই ধারার একটি দর্শন দেয়: local-first অ্যাপে ডেটার মূল কপি ব্যবহারকারীর নিজের device-এ, সার্ভার শুধু মেলানোর সহায়ক।
CRDT-র চারটি দাম
📦 Metadata বাড়ে
Counter-এ প্রতি replica একটি ঘর, OR-Set-এ প্রতি যোগে একটি tag, টেক্সটে প্রতি অক্ষরে একটি পরিচয়। CRDT-অবস্থা শুধু ডেটার চেয়ে কয়েক গুণ বড় হতে পারে।
অনুশীলন ১-এ হিসাব🪦 Tombstone আর garbage collection
Tombstone ফেলা যায় শুধু যখন নিশ্চিত যে সব replica মোছাটি দেখেছে — causal stability; তার জন্য জানতে হয় replica কারা, কে কতদূর পেয়েছে (পর্ব ৪৪)। তিন মাস offline একটি ফোন সবার পরিষ্কার আটকে রাখে, নয়তো ফিরে এলে তাকে পুরো নতুন কপি নিতে হয়।
membership না জানলে পরিষ্কার নেই😮 Semantics-এর চমক
গঠনে মিলে যাওয়া মানে অর্থে ঠিক নয়। Add-wins-এ মোছা জিনিস ফেরে; দুজন একই বানান-ভুল দুইভাবে ঠিক করলে দুটি সংশোধনই থেকে যায়; একই জায়গায় একসাথে টাইপ করা দুটি শব্দের অক্ষর কিছু algorithm-এ মিশে যেতে পারে বলে গবেষণায় দেখানো হয়েছে।
মেলে, কিন্তু মানে দাঁড়ায় কি?⚖️ Invariant রক্ষা করে না
“ব্যালান্স শূন্যের নিচে নয়”, “username অনন্য”, “সিট একবারই বিক্রি” — প্রতিটি replica আলাদাভাবে নিয়ম যাচাই করে পাশ করিয়ে দেয়, মেলানোর পর নিয়ম ভাঙা। এমন invariant-এ লেখার আগে একমত হতে হয়: consensus বা lock (পর্ব ৪২, ৪৩)।
টাকায় CRDT একা নয়সংখ্যায় দেখুন: একটি কাল্পনিক mobile wallet-এর ব্যালান্স ১০০ টাকা, দুটি region-এ PN-Counter হিসেবে। Network ভাগ; দুই region-এ ৮০ টাকা করে কেনাকাটা, প্রতিটি নিজের কপিতে ১০০ ≥ ৮০ দেখে অনুমোদন দেয়। মেলানোর পর ১০০ − ৮০ − ৮০ = −৬০. CRDT নিখুঁত কাজ করেছে — দুটি কমানোই মনে রেখেছে; ভুলটি নকশার। মাঝামাঝি কৌশল bounded counter বা escrow: ১০০ টাকার “খরচের অধিকার” আগেই ভাগ করা (ধরা যাক ৫০ + ৫০), প্রতিটি region নিজের ভাগে স্বাধীন, ভাগ ফুরালে অন্যের কাছে চায়। সীমা কখনো ভাঙে না, কিন্তু এক region-এ ৮০ টাকার কেনাকাটায় তখন অন্যটির সাথে কথা বলতে হয়।
আরও গভীরে: কোন সমস্যায় coordination ছাড়াই মেলানো সম্ভব, তার একটি তাত্ত্বিক উত্তর CALM নীতি (Consistency As Logical Monotonicity): Joseph Hellerstein ২০১০ সালের দিকে এটি অনুমান হিসেবে প্রস্তাব করেন, পরে Ameloot ও সহকর্মীরা একটি আনুষ্ঠানিক রূপ প্রমাণ করেন — মোটামুটি, যে প্রোগ্রামের ফল শুধু “বাড়ে” (monotonic), তা coordination ছাড়াই বিতরণ করা যায়; “নেই” ধরনের প্রশ্নে coordination লাগে। CRDT-র join-semilattice এই নীতির প্রকৌশল রূপ। Numeric invariant-এর escrow-ধারণা ১৯৮০-র দশকের database গবেষণা থেকে (O'Neil), আর Kleppmann ও Beresford-এর JSON CRDT paper (২০১৭) Automerge-ধাঁচের document মডেলের ভিত্তি।
পরীক্ষা ও ইন্টারভিউতে — বিশ্ববিদ্যালয়, চাকরি বা system design ইন্টারভিউর পরীক্ষায় এমন প্রশ্ন আসতে পারে:
- LWW-এর সমস্যা কী? — Concurrent লেখার একটি বাদে সব নিঃশব্দে হারায়, আর ঘড়ির পার্থক্যে কারণ-ফলে পরের লেখাও হারাতে পারে।
- State-based CRDT-র merge-এর তিন গুণ? — Commutative, associative, idempotent; সঙ্গে update অবস্থাকে শুধু ওপরে তোলে — তাই ক্রম, দল, duplicate কিছুতেই ফল বদলায় না।
- G-Counter-এ merge কেন max, যোগ নয়? — একই replica-র ঘর শুধু বাড়ে, তাই max সবচেয়ে নতুন মান দেয়; যোগ করলে duplicate বা পুরোনো অবস্থা দুবার গোনা হয়।
- OR-Set-এ একসাথে add আর remove হলে কী হয়? — Add জেতে, কারণ remove শুধু দেখা tag-গুলো মোছে, নতুন যোগের tag নয়।
- CRDT দিয়ে ব্যাংক ব্যালান্স রাখবেন? — শুধু CRDT নয়; “শূন্যের নিচে নয়” রক্ষায় consensus, lock বা escrow লাগে।
৯ · AI-এর যুগে conflict-নকশা যাচাই
“Offline-এ চলা নোট অ্যাপের sync বানিয়ে দিন” লিখলে AI মুহূর্তে পরিপাটি নকশা দেয় — প্রায়ই “Kafka, Redis, microservices আর timestamp দিয়ে conflict মেটানো।” শুনতে পেশাদার, কিন্তু কিছু ভুল প্রায় নিয়ম করে আসে:
| AI-এর ভুল | কেন ভুল | কীভাবে ধরবেন |
|---|---|---|
| “NTP আছে, তাই timestamp দিয়ে LWW নিরাপদ” | ঘড়ির পার্থক্যে পরপর লেখাও হারায়; ফোনের ঘড়ি ব্যবহারকারীর হাতে | অংশ ৩-এর সিমুলেটরে নিজের অনুমিত skew বসিয়ে দেখুন কতগুলো হারায় |
| “CRDT দিলে আর কোনো conflict থাকবে না” | গঠন মেলে, অর্থ নয় — add-wins, দুবার সংশোধন, মিশে যাওয়া লেখা | প্রতিটি feature-এ “একসাথে যোগ আর মোছায় কী চাই” লিখিয়ে নিন |
| “ব্যালান্স বা স্টক PN-Counter-এ রাখুন” | Invariant ভাঙে; দুই replica একই শেষ টাকা খরচ করে | Network ভাগ ধরে দুই দিকে একই লেখা হাতে হিসাব করুন |
| “প্রতিটি ফোন একটি replica, vector clock দিয়ে” | লাখো ঘরের vector আর অসীম tombstone; কবে পরিষ্কার হবে বলে না | অনুশীলন ১-এর মতো metadata আর tombstone-এর আকার হিসাব |
| Library-র বানানো API, সীমা বা কার্যক্ষমতার সংখ্যা | Automerge, Yjs বা database-এর CRDT সুবিধা সংস্করণভেদে বদলায় | অফিসিয়াল documentation আর নিজের ছোট benchmark |
AI-কে ডেটার ধরন আর প্রত্যাশিত আচরণ দিন, আর প্রতিটি সিদ্ধান্তের failure-mode চান:
AI-এর দেওয়া conflict-নকশা হাতে পেলে — যাচাইয়ের চেকলিস্ট
প্রশ্ন একটাই: কী হারাতে রাজি, আর কোন নিয়ম কখনো ভাঙা চলবে না।
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। সূত্র মুখস্থ নয়, কোন নকশা কেন বেছে নেওয়া হয় সেটাই আসল।
প্র ০১Dynamo-র নকশাকারেরা জেনেশুনে “মুছে ফেলা জিনিস ফিরে আসা” মেনে নিলেন কেন? এটি কি একটি ভুল, নাকি সঠিক ব্যবসায়িক সিদ্ধান্ত?
২০০৭ সালের Dynamo paper-এর মূল কথা: কিছু সেবার জন্য “সবসময় লেখা নিতে পারা” সবচেয়ে বড় প্রয়োজন। Paper-এ shopping cart-কে এমন সেবার উদাহরণ হিসেবে দেখানো হয়েছে — ক্রেতা “কার্টে যোগ করুন” চাপলেন আর error পেলেন, এটি সরাসরি হারানো বিক্রি আর আস্থার ক্ষতি। তাই নকশা এমন, যাতে network ভাগ হলেও, কয়েকটি সার্ভার বিকল হলেও লেখা নেওয়া হয়, আর পরে concurrent সংস্করণগুলো vector clock দিয়ে চিনে রেখে অ্যাপ মেলায়।
মেলানোর নিয়ম বাছতে দুটি ভুলের একটি মানতে হয়: (ক) কোনো যোগ হারানো, বা (খ) কোনো মোছা হারানো। Union মানে (খ)। হিসাবটা অসম: ফিরে আসা জিনিস ক্রেতা চেকআউটের সময় দেখে এক ক্লিকে মোছেন; কিন্তু হারানো জিনিস হয়তো খেয়ালই করেন না, আর বিক্রিটাই হয় না। আর ভুলটি ঘটে শুধু concurrent সংস্করণ হলে, যা paper অনুযায়ী তুলনামূলক বিরল ছিল।
তবু এটি শেষ উত্তর নয়। পরে CRDT-র গবেষণা দেখাল, OR-Set দিয়ে দুটি ভুলই এড়ানো যায়: যোগ হারায় না, আর দেখে মোছা জিনিস ফেরে না; দাম tag আর tombstone-এর metadata. Riak-এর মতো কিছু Dynamo-ধাঁচের database পরে set, counter, map-এর মতো CRDT সরাসরি দেওয়া শুরু করে বলে তাদের documentation-এ পাওয়া যায় — মেলানোর দায় অ্যাপ থেকে database-এ গেল। আর শিক্ষাটি বড়: কার্টে বাড়তি জিনিস ছোট বিরক্তি, পেমেন্টে বাড়তি কাটা টাকা গুরুতর ঘটনা — তাই একই সিস্টেমের ভেতরেও অংশভেদে আলাদা নিয়ম।
মূল উপলব্ধি: “ভুল” একটি মেলানোর নিয়মের ধর্ম নয়, ব্যবসার প্রেক্ষাপটের ধর্ম। ভালো নকশাকার প্রথমে জিজ্ঞেস করেন কোন ভুলের দাম কম, তারপর সেই ভুল মেনে নেওয়া নিয়মটি বাছেন — আর ভালো data type পেলে দুটি ভুলই এড়ান।
প্র ০২Cassandra-র মতো বড় database যদি LWW-এর ওপর চলে, তাহলে LWW কি সত্যিই খারাপ? ঘড়িকে বিশ্বাসযোগ্য করার কোনো উপায় কি আছে?
LWW খারাপ নয় — তার একটি স্পষ্ট চুক্তি আছে, আর সেই চুক্তি না জেনে ব্যবহার করাটা খারাপ। Apache Cassandra-য় প্রতিটি column-এর মানের সাথে একটি timestamp থাকে, আর conflict-এ বড়টি জেতে বলে তার documentation-এ বলা আছে। সুবিধা বিশাল: কোনো sibling নেই, পড়া সরল, একই মান বারবার লিখলেও ফল একই — retry নিরাপদ। যে নকশায় প্রতিটি সারি একবার লেখা হয় (ঘটনার log, sensor-এর রিডিং, UUID key), সেখানে দুর্বলতা কার্যত চোখেই পড়ে না।
বিপদ আসে যখন একই মান কয়েক জায়গা থেকে কাছাকাছি সময়ে বদলায়। তখন দুটি আলাদা ঝুঁকি: (ক) সত্যিকারের concurrent লেখার একটি হারায় — ঘড়ি নিখুঁত হলেও; (খ) ঘড়ির পার্থক্যে কারণ-ফলে পরের লেখাও হারায় — অংশ ৩-এর সিমুলেটরের w6. Kleppmann তাঁর Designing Data-Intensive Applications (২০১৭) বইয়ে এমন একটি উদাহরণ দেখান।
ঘড়িকে বিশ্বাসযোগ্য করার সবচেয়ে বিখ্যাত চেষ্টা Google-এর Spanner (২০১২)। তার TrueTime একটি সময় নয়, একটি ব্যবধান দেয় — “এখন এই দুই মুহূর্তের মাঝে কোথাও”; GPS আর atomic clock-এর সাহায্যে অনিশ্চয়তা কয়েক ms-এ রাখা হয় বলে paper-এ বর্ণনা আছে। Commit-এর সময় Spanner অনিশ্চয়তার সমান অপেক্ষা করে (commit wait), যাতে পরের transaction নিশ্চিতভাবে বড় timestamp পায় — বিশেষ হার্ডওয়্যার আর অপেক্ষার দামে। সস্তা পথ Hybrid Logical Clock (Kulkarni ও সহকর্মীরা, ২০১৪ সালের দিকে): ঘড়ির কাছাকাছি থাকে, কিন্তু Lamport-এর মতো কারণ-ফলে পরের ঘটনাকে বড় সংখ্যা দেয় — (খ) দূর হয়, (ক) থাকে।
মূল উপলব্ধি: LWW বেছে নেওয়ার আগে দুটি প্রশ্ন: এই key কি একাধিক জায়গা থেকে একসাথে বদলাতে পারে? আর পারলে একটি হারানো লেখার দাম কত? প্রথমটির উত্তর “না” বা দ্বিতীয়টির “প্রায় শূন্য” হলে LWW সরল ও সৎ সমাধান; নইলে logical clock, siblings বা CRDT.
প্র ০৩একসাথে document সম্পাদনার জন্য OT প্রায় তিন দশক ধরে চলছে। তাহলে CRDT নিয়ে এত আগ্রহ কেন, আর কোনটি “জিতবে”?
Ellis ও Gibbs ১৯৮৯ সালে তাঁদের GROVE ব্যবস্থার সাথে operational transformation-এর ধারণা প্রকাশ করেন। পরে বোঝা গেল, কেন্দ্র ছাড়া রূপান্তরের নিয়মকে কিছু কঠিন গাণিতিক শর্ত মানতে হয়; প্রকাশিত কয়েকটি OT algorithm পরে প্রতি-উদাহরণ দিয়ে ভুল প্রমাণিত হয়েছে বলে জানা যায়। ব্যবহারিক সমাধান একটি কেন্দ্রীয় সার্ভার, যে সব কাজের ক্রম ঠিক করে; ১৯৯৫ সালের Jupiter ব্যবস্থা এই ধাঁচের পরিচিত উদাহরণ, আর Google Wave (২০০৯)-এর প্রকাশিত নকশাও এর ওপর দাঁড়ানো বলে জানা যায়।
কেন্দ্রীয় সার্ভারসহ OT ভালো চলে: metadata কম, ইতিহাস স্পষ্ট। দুর্বলতা দুটি নতুন প্রয়োজনে: দীর্ঘ offline (কয়েক দিন পর হাজারো কাজ নিয়ে ফেরা) আর সার্ভার ছাড়া peer-to-peer বা local-first অ্যাপ। CRDT এখানে স্বাভাবিক: নির্ভুলতা আসে merge-এর গাণিতিক গুণ থেকে, ক্রম ঠিক করার কেউ লাগে না।
CRDT-র নিজের সমস্যাও ছিল — প্রথম দিকের sequence CRDT-র metadata আর গতি বড় document-এ কষ্ট দিত। Yjs, Automerge-এর মতো library compression আর ভালো ডেটা-কাঠামো দিয়ে দূরত্ব অনেক কমিয়েছে বলে তাদের প্রকাশিত benchmark দাবি করে (নিজের ডেটায় যাচাই দরকার)। আবার গবেষণায় দেখানো হয়েছে, একই জায়গায় একসাথে টাইপ করা দুটি শব্দের অক্ষর কিছু CRDT-তে মিশে যেতে পারে (interleaving) — গাণিতিকভাবে “মিলেছে”, কিন্তু পড়ার অযোগ্য। তাই জয়ী প্রেক্ষাপট-নির্ভর: সবসময় অনলাইন, কেন্দ্রীয় সার্ভারওয়ালা editor-এ OT যুক্তিসংগত; offline-ভারী বা local-first অ্যাপে CRDT স্বাভাবিক। অনেক ব্যবস্থা দুটির ধারণাই মেশায় — যেমন CRDT-র ওপর একটি কেন্দ্রীয় সার্ভারকে বিশেষ replica বানিয়ে ক্রম, অনুমতি আর backup-এর সুবিধা নেওয়া।
মূল উপলব্ধি: একই সমস্যার দুটি সমাধান আলাদা জায়গায় জটিলতা রাখে — OT রূপান্তরের নিয়মে আর কেন্দ্রীয় সার্ভারে, CRDT ডেটার metadata-য়। কোন জটিলতা আপনার পরিবেশে সস্তা, সেটাই সিদ্ধান্ত।
প্র ০৪একটি mobile wallet-এর ব্যালান্স কি CRDT দিয়ে রাখা যায়? না গেলে, সীমাটা ঠিক কোথায়?
ব্যালান্সের দুটি অংশ আলাদা করে দেখুন। হিসাব রাখা — কোন জমা আর কোন খরচ ঘটেছে — CRDT-র জন্য চমৎকার: প্রতিটি লেনদেন একটি অনন্য ID-সহ ঘটনা, ঘটনার সেট একটি G-Set, আর ব্যালান্স = জমার যোগফল − খরচের যোগফল। যেকোনো ক্রমে, যতবার খুশি মেলালেও হিসাব মিলবে। নিয়ম রক্ষা — “খরচের পরে ব্যালান্স শূন্যের নিচে যাবে না” — CRDT পারে না। কারণ নিয়মটি একটি “নেই” ধরনের প্রশ্ন: “এমন কোনো খরচ নেই যা আমি জানি না।” বিচ্ছিন্ন একটি replica তা কখনো নিশ্চিত জানতে পারে না।
অংশ ৮-এর হিসাবে ফল −৬০। তিনটি পথ। (১) Coordination: খরচের আগে consensus বা একটি “বাড়ির” leader-এ যাচাই (পর্ব ৪২, ৪৩) — নিরাপদ, কিন্তু network ভাগ হলে খরচ বন্ধ। (২) Escrow বা bounded counter: Patrick O'Neil-এর ১৯৮৬ সালের escrow-ধারণা আর CRDT জগতে Balegas ও সহকর্মীদের bounded counter (২০১৫) — সীমাকে আগেই কোটায় ভাগ করা, প্রতিটি replica নিজের কোটায় স্বাধীন। (৩) পরে ক্ষমা চাওয়া: ব্যবসা যেখানে অনুমতি দেয়, সেখানে খরচ নিয়ে পরে ঋণাত্মক ব্যালান্স ধরা আর মেটানো — ছোট সীমার মধ্যে।
তাত্ত্বিক ভিত্তি CALM নীতি: যে হিসাবের ফল শুধু বাড়ে (নতুন তথ্যে আগের উপসংহার বদলায় না), তা coordination ছাড়াই বিতরণ করা যায়; যে হিসাবে নতুন তথ্য আগের “হ্যাঁ”-কে “না” বানাতে পারে, তাতে coordination লাগে। “ব্যালান্স ≥ ৮০” আজ সত্য, কিন্তু একটি নতুন খরচের খবর এলে মিথ্যা — non-monotonic.
বাস্তব নকশা প্রায়ই মিশ্র: লেনদেনের log replicate করা, ছোট খরচ কোটার মধ্যে, বড় খরচে কেন্দ্রীয় যাচাই। Ledger আর idempotency-র নকশা পর্ব ২৫-এ, পূর্ণ case study পর্ব ৬০-এ।
মূল উপলব্ধি: CRDT ঘটনা মনে রাখে, নিয়ম রক্ষা করে না। প্রতিটি নিয়মের জন্য জিজ্ঞেস করুন: নতুন তথ্য কি আগের সিদ্ধান্তকে ভুল বানাতে পারে? পারলে, সেই সিদ্ধান্তের আগে কাউকে না কাউকে একমত হতেই হবে।
প্র ০৫“Local-first” অ্যাপ শুনতে চমৎকার — তাহলে সব অ্যাপ এভাবে বানানো হয় না কেন? তিন মাস offline থাকা একটি ফোন কী সমস্যা তৈরি করে?
Ink & Switch-এর ২০১৯ সালের প্রবন্ধ “Local-first software” (Kleppmann, Wiggins, van Hardenberg ও McGranaghan) দ্রুত সাড়া, offline কাজ, একসাথে সম্পাদনা আর ব্যবহারকারীর নিয়ন্ত্রণসহ সাতটি আদর্শের কথা বলে, আর CRDT-কে এর সম্ভাব্য প্রযুক্তি হিসেবে দেখায়। দুর্বল বা অনিয়মিত নেটওয়ার্কের এলাকায় — হাওর-চরের মাঠকর্মী, দূর গ্রামের স্বাস্থ্যকর্মী — এই আদর্শ বিশেষ অর্থবহ: অ্যাপ নেটওয়ার্কের জন্য অপেক্ষা করে না।
সমস্যাগুলো আসে সময়ের সাথে। প্রথমত tombstone পরিষ্কার: একটি tombstone ফেলা যায় শুধু যখন সব replica মোছাটি দেখেছে (causal stability). সার্ভার জানে না একটি ফোন চিরতরে হারিয়েছে, নাকি তিন মাস পরে ফিরবে। অপেক্ষা করলে metadata জমে; না করলে ফিরে আসা ফোনের পুরোনো অবস্থায় মোছা জিনিস ফিরতে পারে। প্রচলিত সমঝোতা: সার্ভার প্রতিটি device-এর শেষ sync রাখে, একটি সময়সীমার পর পুরোনো device-কে “বাদ” ধরে পরিষ্কার করে, আর সে ফিরলে তার না-পাঠানো বদল আলাদা করে দেখিয়ে পুরো নতুন কপি নিতে বাধ্য করে।
দ্বিতীয়ত schema বদল: নতুন সংস্করণ ডেটার গঠন বদলালে তিন মাস পুরোনো সংস্করণের কাজ মেলানো কঠিন (পর্ব ১২-এর schema evolution, এখানে আরও জটিল)। তৃতীয়ত অনুমতি: offline-এ একজনের অনুমতি বাতিল হয়েছে, কিন্তু তাঁর ফোন জানে না — ফিরে এসে তাঁর লেখা মানা হবে কি? এটি নীতির প্রশ্ন, প্রায়ই সার্ভারের যাচাই লাগে। তাই local-first সবচেয়ে মানায় ব্যক্তিগত বা ছোট দলের document, নোট, তালিকা আর মাঠের ফর্মে; টাকা বা কড়া অনুমতির জায়গায় সার্ভার-কেন্দ্রিক নকশা, সাথে offline cache.
মূল উপলব্ধি: CRDT “মেলানো” সমস্যা সমাধান করে, কিন্তু “কারা মিলবে, কতদিন অপেক্ষা, কাকে বিশ্বাস” — membership, সময়সীমা আর নীতির প্রশ্নগুলো নকশাকারকেই ঠিক করতে হয়।
অনুশীলন
খাতা-কলম আর উপরের সিমুলেটর পাশে রাখুন। আগে নিজে করুন, তারপর উত্তর মিলিয়ে নিন।
-
Metadata-র হিসাব: একটি কাল্পনিক নোট অ্যাপ। (ক) “মোট share” গোনার একটি G-Counter-এ ২০ লাখ ফোনের প্রতিটি নিজেই একটি replica; প্রতিটি ঘর ১৬ byte (৮ byte device ID + ৮ byte সংখ্যা)। পুরো counter-এর অবস্থা কত বড়? (খ) ফোনগুলো বদলে সার্ভারের ৩টি region-replica-র একটিতে idempotency key-সহ “+১” পাঠালে অবস্থা কত? (গ) একটি ভাগ করা কাজের তালিকা OR-Set-এ, তালিকার আকার মোটামুটি স্থির — দিনে ২,০০০টি যোগ আর ২,০০০টি মোছা। প্রতিটি tag ২৪ byte, আর সরল OR-Set যোগের tag আর tombstone দুটোই রাখে। কোনো পরিষ্কার না হলে ৯০ দিনে tag-এর metadata কত? (ঘ) ৭ দিনের পুরোনো tombstone আর তার tag নিরাপদে মোছা গেলে কত থাকে?
(ক) ২০,০০,০০০ × ১৬ = ৩,২০,০০,০০০ byte ≈ ৩২ MB — একটি সংখ্যার জন্য! প্রতিটি sync-এ পুরো অবস্থা পাঠানো অসম্ভব।
(খ) ৩ × ১৬ = ৪৮ byte. দাম: offline ফোনের বাড়ানো সার্ভারে পৌঁছানো পর্যন্ত অপেক্ষা, আর retry-তে duplicate ঠেকাতে idempotency key (পর্ব ৩৪)।
(গ) ৯০ দিনে যোগের tag ২,০০০ × ৯০ = ১,৮০,০০০, tombstone ১,৮০,০০০ — মোট ৩,৬০,০০০ × ২৪ = ৮৬,৪০,০০০ byte ≈ ৮.৬৪ MB. অথচ তালিকায় আছে মোটামুটি কয়েকশো জিনিস (ধরুন ২০০টি, যার tag মাত্র ২০০ × ২৪ = ৪,৮০০ byte). Metadata আসল ডেটার প্রায় ১,৮০০ গুণ।
(ঘ) শেষ ৭ দিনের: (২,০০০ + ২,০০০) × ৭ = ২৮,০০০ × ২৪ = ৬,৭২,০০০ byte ≈ ০.৬৭ MB — প্রায় ১৩ ভাগের ১ ভাগ। শর্ত: নিশ্চিত হতে হবে ৭ দিনের পুরোনো মোছা সব replica দেখেছে; যে device ৭ দিনের বেশি offline, তাকে ফিরে এলে পুরো নতুন কপি নিতে হবে।
-
আউটপুট অনুমান করুন: নিচের উদাহরণ-কোডে
mergeহলো G-Counter-এর ঘরে ঘরে max, আরvalueসব ঘরের যোগফল। চালানোর আগে প্রতিটি লাইনের আউটপুট লিখুন।A = { A: 5, B: 2, C: 0 }; B = { A: 3, B: 4, C: 1 }; C = { A: 5, B: 1, C: 6 }; x = merge(A, B); y = merge(x, C); z = merge(y, A); print(value(x), value(y), value(z)); print(value(A) + value(B) + value(C)); // naive: add the totals N = merge({ A: 1 }, { A: 1, C: 2 }); // decrements, PN-Counter print(value(y) - value(N));প্রথম লাইন:
10 15 15. x = ঘরে ঘরে max(A, B) = {A: ৫, B: ৪, C: ১} → ১০। y = max(x, C) = {A: ৫, B: ৪, C: ৬} → ১৫। z = max(y, A) — A-র প্রতিটি ঘর y-তে ইতিমধ্যে সমান বা বড়, তাই y-ই, ১৫ — idempotent আর পুরোনো অবস্থা ক্ষতিহীন।দ্বিতীয় লাইন:
27. ৭ + ৮ + ১২ = ২৭ — আসল ১৫-র চেয়ে অনেক বেশি, কারণ তিনটি অবস্থা অনেক একই বাড়ানো (A-ঘরের ৫, B-ঘরের ২) বারবার বহন করছে। মোট যোগ করা তখনই ঠিক, যখন প্রতিটি অবস্থায় শুধু নিজের ঘর থাকে আর প্রতিটি ঠিক একবার আসে।তৃতীয় লাইন:
12. N = max({A: ১}, {A: ১, C: ২}) = {A: ১, C: ২} → ৩ (A-র একই কমানো দুবার আসায় একবারই গোনা)। ১৫ − ৩ = ১২। -
কোন feature-এ কোন CRDT: একটি কাল্পনিক সামাজিক অ্যাপের প্রতিটি feature-এর জন্য উপযুক্ত CRDT (বা “CRDT নয়”) বলুন, কারণসহ। (ক) একটি পোস্টের like সংখ্যা — একজন একবারই like দিতে পারেন, আর তুলে নিতে পারেন। (খ) একটি ভিডিওর মোট view. (গ) একটি group-এর সদস্য তালিকা — বেরিয়ে গিয়ে আবার ঢোকা যায়। (ঘ) একই group-এর admin তালিকা। (ঙ) প্রোফাইলের display name. (চ) একসাথে লেখা একটি নোটের টেক্সট। (ছ) একটি ইভেন্টের ২০০টি টিকিটের শেষ কয়েকটি।
(ক) User ID-র OR-Set, like সংখ্যা = সেটের আকার। PN-Counter-এ দুই device থেকে দুবার like দুবার গোনা হতো; সেটে একজন একবারই, আর তুলে নিয়ে আবার দেওয়া যায়।
(খ) G-Counter, সার্ভারের replica-দের মধ্যে (প্রতি ফোনে নয়); view শুধু বাড়ে।
(গ) OR-Set (add-wins) — একজন বেরোলেন আর অন্য কেউ একসাথে তাঁকে আবার যোগ করলে যোগ জেতে, যা সাধারণত গ্রহণযোগ্য।
(ঘ) Remove-wins সেট বা coordination. একজন admin-কে সরানো আর একসাথে তাঁর অনুমতি টিকে থাকা নিরাপত্তার ঝুঁকি; অনুমতির বদল একটি কেন্দ্রীয় জায়গায় ক্রমানুসারে করাই নিরাপদ।
(ঙ) LWW-Register যথেষ্ট (ঝুঁকি কম; সার্ভারের বা logical timestamp দিয়ে, ফোনের ঘড়িতে নয়), অথবা MV-Register — concurrent দুটি নাম হলে ব্যবহারকারীকে বেছে নিতে দেওয়া।
(চ) Sequence CRDT (Yjs বা Automerge-এর মতো library), বা কেন্দ্রীয় সার্ভারসহ OT.
(ছ) CRDT নয়। “বিক্রি ≤ ২০০” একটি invariant; দুই replica একই শেষ টিকিট বেচতে পারে। শেষ অংশে coordination (পর্ব ৪২, ৪৩), বা আগেই টিকিটের কোটা replica-দের মধ্যে ভাগ (escrow). পূর্ণ নকশা পর্ব ৬০-এ।
-
একটি নকশার সমালোচনা: একটি কাল্পনিক NGO-র মাঠ-জরিপ অ্যাপের জন্য AI এই নকশা দিয়েছে: “প্রতিটি পরিবারের রেকর্ড একটি JSON সারি; প্রতিটি ফোন সারির সাথে নিজের ঘড়ির timestamp পাঠায়; সার্ভার পুরো সারির জন্য LWW করে; সদস্য বাদ দিলে সারি থেকে মুছে দেওয়া হয়; ‘পরিদর্শন সংখ্যা’ একটি পূর্ণসংখ্যা field, ফোন +১ করে পুরো সারি পাঠায়।” কর্মীরা কয়েক দিন offline থাকেন, আর একই পরিবারে দুজন কাজ করতে পারেন। সমস্যাগুলো ধরুন আর নতুন নকশা দিন।
সমস্যা: (১) ফোনের ঘড়ি — ভুল বা ইচ্ছাকৃতভাবে বদলানো; একটি এগিয়ে থাকা ঘড়ি দিনের পর দিন সবার লেখা আটকাবে (অংশ ৩)। (২) পুরো সারির LWW — একজন বয়স আর অন্যজন পেশা বদলালে একজনের পুরো কাজ হারায়, যদিও তাঁরা আলাদা field ছুঁয়েছিলেন। (৩) মোছা মানে সারি থেকে বাদ — পুরোনো কপিওয়ালা ফোন sync করলে সদস্য ফিরে আসবে (union হলে) বা নতুন যোগ হারাবে (LWW হলে)। (৪) পরিদর্শন সংখ্যা — দুজন একসাথে ৪ থেকে ৫ করলে ফল ৫, হওয়া উচিত ৬।
নতুন নকশা: নতুন রেকর্ড আর সদস্যের ID হিসেবে UUID. রেকর্ডটি CRDT map — প্রতিটি field আলাদা। সদস্য তালিকা OR-Set. বয়স-পেশার মতো field MV-Register: concurrent দুটি মান হলে supervisor দুটিই দেখে একটি বাছেন। পরিদর্শন সংখ্যা: প্রতিটি পরিদর্শন একটি অনন্য ID-ওয়ালা ঘটনা (G-Set), সংখ্যা = সেটের আকার — retry-তেও দুবার গোনা হয় না, আর প্রতি ফোনে একটি ঘরও লাগে না। ফোনের ঘড়ি শুধু দেখানোর তথ্য, সিদ্ধান্তে নয়। সার্ভার প্রতিটি ফোনের শেষ sync রাখে, সবাই দেখে ফেলা tombstone পরিষ্কার করে, আর বহুদিন অনুপস্থিত ফোনকে পুরো কপি নিতে বাধ্য করে। যে নিয়ম CRDT রাখতে পারে না (“একটি পরিবারে একজনই প্রধান”), সার্ভার sync-এর পর যাচাই করে supervisor-কে জানায়।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমDynamo-ধাঁচের store-এ concurrent লেখা থেকে মেলানো পর্যন্ত ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- ✍️ দুটি client একে অপরের খবর না জেনে একই key-তে লেখে
- 🔢 প্রতিটি সংস্করণ নিজের version vector পায়
- ⚖️ পড়ার সময় vector তুলনা — কেউ কাউকে ঢাকে না, তাই দুটি sibling ফেরত
- 🧩 অ্যাপ নিজের merge function দিয়ে sibling-গুলো মেলায়
- 💾 মেলানো মান দুই vector-কে ঢাকে এমন নতুন vector-সহ লেখা হয়, sibling-গুলো মুছে যায়
তিন replica-র একটি G-Counter-এ (ঘরের ক্রম A, B, C) একটি replica-র অবস্থা [4, 1, 0], আরেকটির [2, 3, 0]. দুটি merge করার পর counter-এর মান কত?
- ৭
- ১০
- ৫
- ৩
একটি state-based CRDT-র merge-এর জন্য নিচের কোনটি প্রয়োজনীয় শর্ত নয়?
- Commutative হওয়া
- Associative হওয়া
- Idempotent হওয়া
- বার্তাগুলো কারণ-ফলের ক্রমে, ঠিক একবার পৌঁছানো
একটি OR-Set-এ “দুধ” আছে (tag a1), দুই replica-ই সেটি দেখেছে। Network ভাগ থাকা অবস্থায় replica A দুধ মুছল, আর replica B একই সময়ে আবার দুধ যোগ করল। Sync-এর পর কী হবে?
- দুই replica-তেই দুধ নেই
- দুই replica-তেই দুধ আছে
- A-তে নেই, B-তে আছে — চিরকাল
- কোন বার্তা আগে পৌঁছায় তার ওপর নির্ভর করে
একটি LWW store-এ replica X-এর ঘড়ি ২ মিনিট এগিয়ে। আসল সময় ১০:০০:০০-এ X-এ একটি লেখা হলো (stamp ১০:০২:০০)। ১০:০১:০০-এ একজন সেই লেখাটি দেখে replica Y-তে নতুন মান লিখলেন (Y-এর ঘড়ি ঠিক, stamp ১০:০১:০০)। Sync-এর পর কোন মান থাকবে?
- Y-এর মান, কারণ সেটি আসল সময়ে পরে লেখা
- দুটিই sibling হিসেবে থাকবে
- X-এর মান — পরের লেখাটি নিঃশব্দে হারাবে
- Sync ব্যর্থ হয়ে error দেবে
নিচের কোন নিয়ম শুধু CRDT দিয়ে, coordination ছাড়া রক্ষা করা যায় না?
- একজন ব্যবহারকারী একটি পোস্টে একবারই like গোনা হবে
- মোট view কখনো কমবে না
- Wallet-এর ব্যালান্স কখনো শূন্যের নিচে যাবে না
- একসাথে সম্পাদিত নোটের টেক্সট সব কপিতে একই হবে
একটি shopping cart-এর দুটি concurrent সংস্করণ — একটিতে “চিনি” মোছা, অন্যটিতে চিনি আছে আর “চাল” যোগ — union দিয়ে মেলানো হলো, আর চিনি ফিরে এল। মূল কারণ কী?
- Version vector ভুল তুলনা করেছে
- মোছাটি কোনো সংস্করণে চিহ্ন রাখেনি, তাই union জানে না চিনি মোছা হয়েছিল
- একটি replica-র ঘড়ি পিছিয়ে ছিল
- Network একটি বার্তা হারিয়েছে
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- Conflict = দুটি কপি একে অপরের খবর না জেনে একই ডেটা বদলানো; উৎস multi-leader, leaderless, offline ফোন আর একসাথে সম্পাদনা। সম্ভব হলে প্রতিটি key-এর একটি বাড়ি দিয়ে এড়ান।
- LWW সরল আর মিলে যায়, কিন্তু concurrent লেখা আর ঘড়ির ভুলে পরের লেখাও নিঃশব্দে হারায়; একবার-লেখা key আর কম-ঝুঁকির মানে মানায়।
- Siblings + version vector কিছু হারায় না, মেলায় অ্যাপ; union-এ মোছা জিনিস ফেরে (Dynamo-র কার্ট)। Merge function হতে হবে নির্ধারিত, ক্রমে-দলে-পুনরাবৃত্তিতে উদাসীন।
- CRDT-এ merge commutative, associative, idempotent (join-semilattice) — একই পরিবর্তন পাওয়া replica একই অবস্থায়: strong eventual consistency. State-based অবস্থা পাঠায়, operation-based কাজ পাঠায় কারণ-ফলের ক্রমে।
- G/PN-Counter, G-Set, 2P-Set, LWW/MV-Register, OR-Set (add-wins), sequence CRDT — দাম metadata, tombstone আর semantics-এর চমক; invariant-এ coordination বা escrow লাগে।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৪৭ · Monolith বনাম microservices — service boundary, service discovery আর service meshপরবর্তী পর্বএকটি সিস্টেমকে কখন, কোথায় ভাগ করবেন।
- সিস্টেম ডিজাইন · কোর্স হোম৬৬টি পর্বপুরো কোর্সের রোডম্যাপ — কোন পর্ব কখন।
- পর্ব ৩৯ · সময় আর ক্রম — NTP, Lamport clock আর vector clockফিরে দেখুনHappens-before, vector clock আর version vector-এর তুলনার নিয়ম।
- পর্ব ৪১ · Quorum আর leaderless replicationফিরে দেখুনR + W > N, read repair, আর siblings কীভাবে জন্মায়।
- Distributed database — partitioning, sharding আর CAPপাশের ট্র্যাকDBMS কোর্সে replication, consistency আর CAP — database-এর চোখে।