DDIA·مطالعهSTUDY

نقشه راهRoadmap / فصل 11Chapter 11 / حل تمرین‌هاSolutions

حل آموزشی تمرین‌های فصل 11Chapter 11 exercise solutions, explained

هر حل از سؤال مصاحبه شروع می‌کند، فرض‌های آموزشی را روشن می‌گوید، گام‌به‌گام استدلال می‌کند و نمودارهای کوچک دارد که خواندنشان را یاد می‌گیری. هر حل به تمرین خودش برمی‌گردد. Each solution starts from the interview question, states its teaching assumptions, reasons step by step, and shows small diagrams you learn to read aloud. Every solution links back to its exercise.

44 حل تشریحیworked solutions· متناظر با بخش تمرین‌های فصل 11Companion to Chapter 11 exercises

11 تمرین ۱ — pipeline در برابرِ تجمیعِ hashExercise 1 — pipeline vs hash aggregation

L2 · SeniorL2 · Seniorوابسته به نقشRole-specific30 دقیقه بحث30 min discussion

بازگشت به صورت تمرینBack to the exercise · ارزیابی و سطحAssessment and level

سؤال مصاحبهInterview question
برای لاگی بزرگ‌تر از RAM، top URLها را حساب کن. تجمیع hash، مرتب‌سازی بیرونی، spill و شمارش تقریبی را مقایسه کن؛ بنچمارکی با خروجی برابر زیر سقف حافظه اعلام‌شده طراحی کن و آستانه شکست همان محیط را گزارش بده.
Compute top URLs for a log larger than RAM. Compare hash aggregation, external sort, spilling and approximate counts; design a benchmark with equal outputs under a stated memory cap, and report the failure threshold for that environment.

پاسخ ۶۰ثانیه‌ایThe 60-second answer

وقتی working set ممکن است از RAM بگذرد، پیش‌فرض external sort است: chunk کران‌دار در حافظه، runهای مرتب spilled روی دیسک، ادغام k-way که شمارش را جاری می‌کند — همیشه تمام می‌شود و به‌جای مردن با I/O کُند می‌شود. تجمیع hash را نگه دار تا وقتی کلیدهای متمایز راحت در RAM جا می‌شوند. به sketch فقط وقتی دست ببر که top-100 تقریبی صریح مجاز شده باشد. تریدآف اصلی زمان در برابر قطعیت است: sort برای تضمینِ تمام شدن I/O اضافه می‌پردازد.
Default to external sort when the working set may exceed RAM: bounded chunk in memory, sorted runs spilled to disk, k-way merge streams the counts — it always finishes and degrades with I/O instead of dying. Keep hash aggregation while distinct keys comfortably fit RAM. Reach for a sketch only when an approximate top-100 is explicitly allowed. The top trade-off is time versus certainty: sort pays extra I/O to guarantee completion.

صورت مسئله و فرض‌هاProblem and assumptions

  • لاگ آموزشی: ۲۰۰٬۰۰۰ خط با ۱۹٬۷۸۲ URL متمایز (محبوبیت چوله)، از دموی قابل‌اجرای پایین — نه بنچمارک پروداکشن.
  • سقف حافظه از اول اعلام‌شده: یک chunk بیست‌هزارخطی در RAM؛ هرچه بیشتر شود باید به فایل موقت spill شود.
  • top-5 دقیق لازم است؛ حالت تقریبی رژیم جداست با بودجه خطای خودش.
  • تک‌ماشین با دیسک موقت محلی؛ shuffle توزیع‌شده مال تمرین‌های بعدی است.
  • Teaching log: 200,000 lines with 19,782 distinct URLs (skewed popularity), from the runnable demo below — not a production benchmark.
  • Memory cap stated up front: one 20,000-line chunk in RAM; anything past it must spill to temp files.
  • Exact top-5 required; the approximate variant is a separate regime with its own error budget.
  • Single machine with local temp disk; distributed shuffle belongs to the next exercises.

چرا این روش و این عددها؟Why this method and these numbers?

hash aggregation برای U کلید متمایز، state متناسب U می‌خواهد؛ زیادشدن تعداد خطوط با U ثابت همان رشد حافظه را ندارد. external sort با chunk محدود، بقیه را روی دیسک می‌برد و IO را به جای RAM می‌پردازد. ۲۰۰هزار خط و chunk بیست‌هزار باعث spill قابل مشاهده می‌شود؛ این اعداد آستانهٔ عمومی نیستند. زمان را همراه peak memory و حجم temporary بنویس تا طرح سریع‌تر ولی خارج از بودجه برنده معرفی نشود.

Hash aggregation holds state proportional to distinct keys U; more lines with fixed U do not have the same memory growth. External sorting bounds RAM by spilling chunks and paying disk I/O. Two hundred thousand lines and 20k-line chunks force visible spills, not a universal threshold. Compare time with peak memory and temporary bytes so an over-budget design does not win on speed alone.

مدل ذهنیMental model

شمارش یعنی کنار هم آوردن کلیدهای برابر. hash آن‌ها را در RAM با یک شمارنده به‌ازای هر کلید متمایز گروه می‌کند؛ sort با کنار هم آوردن برابرها روی دیسک گروه می‌کند. working set — کلیدهای متمایز ضربدر سربار هر ورودی — مشخص می‌کند کدام گروه‌بندی جا می‌شود.
Counting is grouping equal keys together. Hashing groups them in RAM with one counter per distinct key; sorting groups them on disk by bringing equals side by side. The working set — distinct keys times per-entry overhead — decides which grouping fits.

حل گام‌به‌گامStep by step

  1. نام‌گذاری working set کلیدهای متمایز را در سربار هر ورودی ضرب کن: آن حاصل working set است و همان — نه جمع خط‌ها — مشخص می‌کند hash جا می‌شود یا نه.
  2. hash با راه فرار spill پارتیشن‌های داغ را به شمارنده‌های RAM و سردها را به فایل‌های spill مرتب بفرست؛ هر دو با شمارش دقیق روی یک ranker همگرا می‌شوند.
  3. sort که همیشه تمام می‌شود chunkهای کران‌دار را مرتب کن، runها را spill کن، با O(run) خط در RAM ادغام k-way کن، برابرهای مجاور را بشمار و top-K را رتبه بده.
  4. بنچمارک خروجی برابر ورودی، سقف و K را فیکس کن؛ top-K یکسان هر دو مسیر را assert کن؛ بعد زمان و بایت spill را مقایسه کن — دموی پایین دقیقاً همین را می‌کند.
  5. تقریب دقت را شل می‌کند اگر فقط top-100 تقریبی خواسته شد، بودجه خطا بده و sketch یا نمونه‌برداری-بعد-شمارش بردار: حافظه کوچک در برابر خطای کران‌دار.
  1. Name the working set Multiply distinct keys by per-entry overhead: that product is the working set, and it — not total lines — decides whether hash fits.
  2. Hash with a spill escape Route hot partitions to RAM counters and cold ones to sorted spill files; both reconverge on one ranker with exact counts.
  3. Sort that always finishes Sort bounded chunks, spill runs, k-way merge with O(runs) lines in RAM, count adjacent equals, rank top-K.
  4. Benchmark equal outputs Fix input, cap and K; assert identical top-K on both paths; then compare time and spill bytes — the demo below does exactly this.
  5. Approximation relaxes exactness If only an approximate top-100 is asked, grant an error budget and pick a sketch or sample-then-count: tiny memory for bounded error.

طراحی مشخصConcrete design

مرحله‌ها: extract (URL هر خط) ← partition-or-chunk (RAM کران‌دار) ← spill store (runهای مرتب روی دیسک موقت) ← merge (k-way با یک خط به‌ازای هر run در RAM) ← rank (heapِ top-K).

پیکربندی: یک chunk_cap صریح (خط یا بایت)؛ دایرکتوری spill با رزرو فضای آزاد؛ seed قطعی برای سازنده آموزشی.

قراردادها: runها مرتب و checksummedاند؛ ادغام O(run) خط نگه می‌دارد نه O(url)؛ ranker فقط K ورودی نگه می‌دارد؛ هر دو مسیر برای ورودی یکسان top-K یکسان می‌دهند.

Stages: extract (URL per line) → partition-or-chunk (bounded RAM) → spill store (sorted runs on temp disk) → merge (k-way, one line per run in RAM) → rank (top-K heap).

Config: one explicit chunk_cap (lines or bytes); spill directory with free-space reserve; deterministic seed for the teaching generator.

Contracts: runs are sorted and checksummed; the merge holds O(runs) lines, never O(urls); the ranker keeps K entries; both paths emit identical top-K for identical input.

مثال محاسبه‌شدهWorked example

عددهای دمو (آموزشی، همین محیط): ۲۰۰٬۰۰۰ خط، ۱۹٬۷۸۲ متمایز، سقف chunk بیست‌هزار ← ۱۰ run و ۱٬۵۱۷٬۴۸۶ بایت spill. هر دو مسیر /p/0:7453، /p/1:1842، /p/2:1287، /p/3:1066، /p/4:913 چاپ می‌کنند؛ equal_top5=true.

تفسیر: اول برابری (جواب یکسان) بعد هزینه (بایت spill و زمان). سقف یا cardinality را عوض کن و دوباره اجرا کن — یافته سطر است نه حکایت.

Demo numbers (teaching, this environment): 200,000 lines, 19,782 distinct, chunk cap 20,000 → 10 runs, 1,517,486 spilled bytes. Both paths print /p/0:7453, /p/1:1842, /p/2:1287, /p/3:1066, /p/4:913; equal_top5=true.

Interpretation: equality first (same answer), then cost (spill bytes and time). Move the cap or the cardinality and re-run — the row, not the anecdote, is the finding.

گام 1 از 3 · خط فکریStep 1 of 3 · reasoning thread · تجمیع hash با سقف حافظه: پارتیشن داغ می‌ماند، سرد spill می‌شودHash aggregation with a memory cap: hot partitions stay, cold ones spill · جریان‌دادهdataflowباز کردن در صفحه جدا ↗Open standalone ↗

دانلود نمودار مستقلDownload standalone diagram

روایت مدرس / چطور این نمودار را بخوانیمTeacher narration / how to read this diagram
لاگی که ممکن است از RAM بگذرد top URL می‌خواهد. چپ‌به‌راست بخوان: یک جریان وارد می‌شود، سقف آن را به شاخه مقیم و spilled تقسیم می‌کند و یک ranker می‌بندد.
A log that may exceed RAM needs top URLs. Read left to right: one stream enters, the cap splits it into resident and spilled branches, one ranker closes.
  1. منبع و استخراج: لاگ immutable به‌ازای هر خط یک URL می‌دهد — همان ورودی مشترک هر دو مسیر.
  2. پارتیشنر با سقف: پارتیشن‌های داغ در شمارنده‌های RAM تجمیع می‌شوند و سردها به‌صورت فایل spill مرتب می‌روند.
  3. یک ranker: شمارش‌های جزئی به‌علاوه شمارش‌های ادغام‌شده spill یک top-K دقیق واحد می‌شوند.
  1. Source and extract: the immutable log yields one URL per line — the same input both paths share.
  2. Partitioner with a cap: hot partitions aggregate in RAM counters, cold ones leave as sorted spill files.
  3. One ranker: partial counts plus merged spill counts become a single exact top-K.

نقطهٔ تصمیم: نقطه تصمیم partitioner است: سقفش هر پارتیشن را به RAM یا دیسک می‌فرستد و سقف یک پیکربندی اعلام‌شده است نه ثابتِ کشف‌شده.

رد خرابی: بدون یال spill شکل دروغ می‌گوید: بعد از سقف، job به‌جای کُند شدن OOM می‌شود. پیکان خط‌چین را دنبال کن و بگو چه چیزی می‌پردازد (I/O دیسک) و چه چیزی حفظ می‌شود (شمارش دقیق).

تضمین نشان‌داده‌شده: شکل نشان می‌دهد حافظه کجا تمام و دیسک کجا شروع می‌شود و هر دو شاخه روی یک ranker همگرا می‌شوند. هیچ آستانه عمومی را ثابت نمی‌کند — نقطه spill محیط‌خاص می‌ماند.

تضمین نشان‌نداده‌شده: درباره shuffle توزیع‌شده، کلیدهای داغ چوله روی workerها یا sketchهای تقریبی چیزی نمی‌گوید — آن‌ها گام‌های بعدی‌اند نه این شکل.

Decision point: The decision point is the partitioner: its cap routes each partition to RAM or to disk, and the cap is a stated config, not a discovered constant.

Failure trace: Without the spill edge the diagram lies: past the cap the job OOMs instead of slowing. Trace the dashed arrow and name what pays (disk I/O) and what is preserved (exact counts).

Guarantee shown: The figure shows where memory ends and disk begins, and that both branches reconverge on one ranker. It does not prove any universal threshold — the spill point stays environment-specific.

Not shown: It says nothing about distributed shuffle, skewed hot keys across workers, or approximate sketches — those are the next steps, not this figure.

خودآزماییSelf-check

مسیر hash حالت را کجا نگه می‌دارد و بعد از سقف چه می‌شود؟

پاسخ: شمارنده‌های RAM برای پارتیشن‌های داغ، فایل‌های spill مرتب برای سردها و یک ranker که هر دو را به top-K دقیق جمع می‌کند.

Where does the hash path keep state, and what happens past the cap?

Answer: RAM counters for hot partitions, sorted spill files for cold ones, one ranker summing both into an exact top-K.

گام 2 از 3 · خط فکریStep 2 of 3 · reasoning thread · مرتب‌سازی بیرونی با chunk کران‌دار: runها spill می‌شوند و ادغام جاری استExternal sort with a bounded chunk: runs spill, the merge streams · جریان‌دادهdataflowباز کردن در صفحه جدا ↗Open standalone ↗

دانلود نمودار مستقلDownload standalone diagram

روایت مدرس / چطور این نمودار را بخوانیمTeacher narration / how to read this diagram
همان لاگ ولی از مسیر sort. پنج مرحله را دنبال کن: استخراج، مرتب‌سازی یک chunk، spill runها، ادغام-شمارش، رتبه‌بندی.
The same log, now through the sort path. Follow the five stages: extract, sort one chunk, spill runs, merge-count, rank.
  1. مرتب‌سازی chunk: هر chunk کران‌دار در RAM مرتب، نوشته و فراموش می‌شود.
  2. فایل‌های run: دیسک همه runهای مرتب را نگه می‌دارد؛ ترافیک کل خطی با ورودی می‌ماند.
  3. ادغام به‌علاوه شمارش: یک خط به‌ازای هر run در RAM، همسایه‌های برابر همان‌جا شمرده و بعد رتبه‌بندی می‌شوند.
  1. Chunk sort: each bounded chunk is sorted in RAM, written out, forgotten.
  2. Run files: disk holds every sorted run; total traffic stays linear in input.
  3. Merge plus count: one line per run in RAM, equal neighbours counted on the fly, then ranked.

نقطهٔ تصمیم: نقطه تصمیم اندازه chunk است: chunk بزرگ‌تر یعنی run کمتر و ادغام ارزان‌تر، تنظیم‌شده با RAM و هرگز حدسی.

رد خرابی: spill با دیسک پر وسط اجرا job را می‌اندازد: رزرو و شمارنده run می‌گیرندش. run کوتاه‌شده بی‌صدا شمارش را خراب می‌کند — checksum آن سوراخ را می‌بندد.

تضمین نشان‌داده‌شده: شکل نشان می‌دهد RAM دقیقاً یک chunk به‌علاوه یک خط به‌ازای هر run نگه می‌دارد — O(run) و هرگز O(url) نه. سرعت را قول نمی‌دهد: گلوگاه آشکارا به نرخ ترتیبی دیسک می‌رود.

تضمین نشان‌نداده‌شده: فقط یک ماشین را پوشش می‌دهد: ادغام توزیع‌شده، stragglerها و spill روی object store بیرون این قاب‌اند.

Decision point: The decision point is the chunk size: bigger chunks mean fewer runs and a cheaper merge, tuned against RAM, never guessed.

Failure trace: A disk-full spill fails the job mid-run: the reserve and the run counter catch it. A silently truncated run would corrupt counts — checksums close that hole.

Guarantee shown: The figure shows RAM holding exactly one chunk plus one line per run — O(runs), never O(urls). It does not promise speed: the bottleneck visibly moves to sequential disk rates.

Not shown: It covers one machine only: distributed merge, stragglers and object-store spills are outside this frame.

خودآزماییSelf-check

هنگام ادغام دقیقاً چه چیزی در RAM است و سقفش چیست؟

پاسخ: یک chunk به‌علاوه یک خط به‌ازای هر run: حافظه O(run)، ترافیک دیسک خطی و تمام شدن تضمینی.

What exactly lives in RAM during the merge, and what bounds it?

Answer: One chunk plus one line per run: O(runs) memory, linear disk traffic, completion guaranteed.

نمودار اسمبل نهاییFinal assembly diagram · یک top-K، سه رژیم: با working set انتخاب کن نه عادتSame top-K, three regimes: pick by working set, not by habit · جدول تصمیمdecision tableباز کردن در صفحه جدا ↗Open standalone ↗

دانلود SVG مستقلDownload standalone SVG

روایت مدرس / چطور این نمودار را بخوانیمTeacher narration / how to read this diagram
سه رژیم در سطرها و سه روش در ستون‌ها. سبز برنده سطر است؛ صورتی شکست می‌خورد یا ریسک شکست دارد.
Three regimes down the rows, three methods across the columns. Green wins the row; pink fails or risks failing.
  1. جا شدن در RAM: hash با یک pass دقیق می‌برد؛ sort کار می‌کند ولی I/O می‌پردازد؛ sketch زیاده‌روی است.
  2. گذر از RAM: hash spill می‌کند یا OOM می‌شود؛ sort با همیشه تمام شدن می‌برد؛ sketch اختیاری می‌ماند.
  3. top-100 تقریبی: sketch با حافظه کوچک می‌برد وقتی بودجه خطا داده شده باشد.
  1. Fits in RAM: hash wins with one exact pass; sort works but pays I/O; sketches are overkill.
  2. Exceeds RAM: hash spills or OOMs; sort wins by always finishing; sketches stay optional.
  3. Approximate top-100: the sketch wins on tiny memory once an error budget is granted.

نقطهٔ تصمیم: نقطه تصمیم سطر است نه ستون: اول رژیم (جا می‌شود، می‌گذرد، تقریبی) بعد روش.

رد خرابی: اول سلول صورتی را بخوان: hash بعد از RAM کُند نمی‌شود، می‌میرد. کار جدول این است که آن سطر با افت کارایی اشتباه خوانده نشود.

تضمین نشان‌داده‌شده: جدول هر دو نمودار گام را به‌علاوه رژیم تقریبی ترکیب می‌کند: هر سطر برنده و قیمتش را نام می‌برد. عددی اثبات نمی‌کند — مدرک با دموی پایین است.

تضمین نشان‌نداده‌شده: اندازه سلول‌ها بنچمارک نیست و آستانه‌ها قابل‌حمل نیستند: سقف، دیسک و cardinality تو سطر تو را مشخص می‌کند.

Decision point: The decision point is the row, not the column: regime first (fits, exceeds, approximate), method second.

Failure trace: Read the pink cell first: hash past RAM does not get slow, it dies. The table's job is making that row impossible to misread as a performance dip.

Guarantee shown: The table composes both step diagrams plus the approximate regime: each row names its winner and its price. It proves no numbers — the demo below carries the evidence.

Not shown: Cell sizes are not benchmarks and thresholds are not portable: your cap, disk and cardinality decide your row.

خودآزماییSelf-check

کدام محور جدول را اول می‌خوانی و چرا؟

پاسخ: سطر تصمیم می‌گیرد: جا شدن hash، گذر sort، تقریبیِ مجاز sketch — اندازه‌گیری‌شده per-environment.

Which axis of the table do you read first, and why?

Answer: The row decides: fits goes hash, exceeds goes sort, approximate-allowed goes sketch — measured per environment.

گزینه‌های جایگزینAlternatives

  • تجمیع hash کاملاً درون‌حافظه بدون مسیر spill: تا وقتی working set جا شود سریع‌ترین است، ولی اولین ورودیِ بزرگ‌تر job را با OOM می‌کشد نه کُندش می‌کند. فقط پشت نگهبانِ cardinalityِ اندازه‌گیری‌شده با fallback به spill نگهش دار.
  • sketch به‌علاوه heap (مثلاً count-min با heapِ top-K): حافظه کوچک کران‌دار و یک pass، ولی جواب‌ها تقریبی با کران خطای اعلام‌شده‌اند. فقط وقتی می‌برد که مصاحبه صریح top-100 تقریبی را مجاز کرده باشد.
  • Pure in-memory hash with no spill path: fastest while the working set fits, but the first oversized input kills the job with OOM instead of slowing it down. Keep it only behind a measured cardinality guard with a spill fallback.
  • Sketch plus heap (e.g. count-min with a top-K heap): tiny bounded memory and one pass, but answers are approximate with a stated error bound. Wins only when the interview explicitly allows an approximate top-100.

سناریوهای خرابیFailure scenarios

  • خواندن OOM به‌جای کُندی: jobِ hash به‌جای افت می‌میرد. نگهبان سقف صریح، مسیر spill و هشدار روی نسبت spill است — OOM را جواب غلط بدان نه جواب کُند.
  • پر شدن دیسک وسط spill: runها نمی‌نشینند و ادغام مجموعه ناقص می‌خواند. نگهبان رزرو فضای spill، checksum per-run و fail-fast به‌جای کوتاه‌شدن بی‌صداست.
  • بنچمارک نابرابر: ورودی، سقف یا K متفاوت روی دو مسیر مقایسه را بی‌معنا می‌کند. نگهبان فیکس کردن ورودی، سقف و K و assert کردن top-K یکسان قبل از زمان‌گیری است.
  • OOM misread as slowness: the hash job dies instead of degrading. Guard with an explicit cap, a spill path, and an alert on spill ratio — treat OOM as a wrong answer, not a slow one.
  • Disk-full mid-spill: runs stop landing and the merge reads a truncated set. Guard with a spill-space reserve, per-run checksums, and fail-fast instead of silent truncation.
  • Unequal benchmark: different inputs, caps or K across the two paths make the comparison meaningless. Guard by fixing input, cap and K, and asserting identical top-K before timing.

کد قابل اجراRunnable code

اجراشده و تأییدشده در همین محیطExecuted and verified in this environment

این کد آموزشی و ساده‌سازی‌شده است؛ فقط برای آشنایی با ایده. کد واقعی پروداکشن ملاحظات بسیار بیشتری دارد و هیچ‌کس این‌شکلی کد پروداکشن نمی‌زند.Simplified teaching code for building intuition only — not production code; nobody ships production code like this.

import java.util.*;

// Teaching demo: top-K URLs two ways under a stated memory cap.
// Hash aggregation keeps one counter per distinct URL (working set grows
// with cardinality); external sort keeps one bounded chunk in RAM and
// spills sorted runs to temp files, then k-way merges them.
// Both paths must print the SAME top-5: equal outputs, stated cap.
public class TopUrlsDemo {
    static final int LINES = 200_000, DISTINCT = 20_000, TOP = 5, CHUNK = 20_000;

    public static void main(String[] a) throws Exception {
        Random rnd = new Random(11);
        String[] urls = new String[DISTINCT];
        for (int i = 0; i < DISTINCT; i++) urls[i] = "/p/" + i;
        // Skewed popularity: low ids are hotter (like real logs).
        List<String> log = new ArrayList<>(LINES);
        for (int i = 0; i < LINES; i++)
            log.add(urls[(int) (DISTINCT * Math.pow(rnd.nextDouble(), 3.0))]);

        // Path A: hash aggregation (working set = DISTINCT counters).
        Map<String, Integer> counts = new HashMap<>();
        for (String u : log) counts.merge(u, 1, Integer::sum);
        List<Map.Entry<String, Integer>> ha = new ArrayList<>(counts.entrySet());
        ha.sort((x, y) -> y.getValue().compareTo(x.getValue()));

        // Path B: external sort with a bounded in-RAM chunk.
        List<java.nio.file.Path> runs = new ArrayList<>();
        List<String> chunk = new ArrayList<>(CHUNK);
        for (String u : log) {
            chunk.add(u);
            if (chunk.size() == CHUNK) runs.add(spill(chunk));
        }
        if (!chunk.isEmpty()) runs.add(spill(chunk));
        // K-way merge over run files, counting adjacent-equal runs.
        List<RunReader> rs = new ArrayList<>();
        for (java.nio.file.Path p : runs) rs.add(new RunReader(p));
        PriorityQueue<RunReader> pq = new PriorityQueue<>(Comparator.comparing(r -> r.head));
        for (RunReader r : rs) if (r.head != null) pq.add(r);
        List<String> topUrls = new ArrayList<>();
        List<Integer> topCounts = new ArrayList<>();
        String cur = null;
        int curN = 0;
        long spilledBytes = 0;
        for (java.nio.file.Path p : runs) spilledBytes += java.nio.file.Files.size(p);
        while (!pq.isEmpty()) {
            RunReader r = pq.poll();
            String u = r.head;
            r.advance();
            if (r.head != null) pq.add(r);
            if (u.equals(cur)) { curN++; continue; }
            if (cur != null) tally(topUrls, topCounts, cur, curN);
            cur = u; curN = 1;
        }
        if (cur != null) tally(topUrls, topCounts, cur, curN);
        for (RunReader r : rs) r.close();

        System.out.println("lines=" + LINES + " distinct=" + counts.size()
            + " chunk_cap=" + CHUNK + " runs=" + runs.size()
            + " spilled_bytes=" + spilledBytes);
        for (int i = 0; i < TOP; i++)
            System.out.println("hash[" + i + "]=" + ha.get(i).getKey() + ":" + ha.get(i).getValue()
                + " sort[" + i + "]=" + topUrls.get(i) + ":" + topCounts.get(i));
        boolean same = true;
        for (int i = 0; i < TOP; i++)
            same &= ha.get(i).getKey().equals(topUrls.get(i))
                && ha.get(i).getValue().equals(topCounts.get(i));
        System.out.println("equal_top" + TOP + "=" + same);
        System.out.println("OK");
    }

    static java.nio.file.Path spill(List<String> chunk) throws Exception {
        Collections.sort(chunk);
        java.nio.file.Path p = java.nio.file.Files.createTempFile("run", ".txt");
        java.nio.file.Files.write(p, chunk);
        chunk.clear();
        return p;
    }

    static void tally(List<String> urls, List<Integer> counts, String u, int n) {
        int i = 0;
        while (i < counts.size() && counts.get(i) > n) i++;
        if (i < TOP) { urls.add(i, u); counts.add(i, n); }
        if (urls.size() > TOP) { urls.remove(TOP); counts.remove(TOP); }
    }

    static class RunReader {
        java.io.BufferedReader br; String head;
        RunReader(java.nio.file.Path p) throws Exception {
            br = java.nio.file.Files.newBufferedReader(p);
            p.toFile().deleteOnExit();
            advance();
        }
        void advance() throws Exception { head = br.readLine(); if (head == null) br.close(); }
        void close() throws Exception { br.close(); }
    }
}

اجرا: javac TopUrlsDemo.java && java TopUrlsDemo — فقط استاندارد جاوا، فایل‌های موقت خودکار پاک می‌شوند.

خروجی مورد انتظار: lines=200000 distinct=19782 chunk_cap=20000 runs=10 spilled_bytes=1517486؛ هر دو مسیر از /p/0:7453 تا /p/4:913 چاپ می‌کنند؛ equal_top5=true؛ OK. (خروجی واقعی همین محیط)

Run: javac TopUrlsDemo.java && java TopUrlsDemo — stdlib Java only, temp files auto-cleaned.

Expected output: lines=200000 distinct=19782 chunk_cap=20000 runs=10 spilled_bytes=1517486; both paths print /p/0:7453 down to /p/4:913; equal_top5=true; OK. (actual output of this environment)

آزمون صحت و معیار عملیاتیCorrectness checks and operational criteria

روی ورودی مشترک top-K یکسان هر دو مسیر را assert کن؛ با سقف chunk و شمارش readerهای باز assert کن RAM ادغام O(run) می‌ماند؛ بایت spill و زمان هر اجرا را ثبت کن؛ وقتی نسبت spill از آستانه تنظیم‌شده گذشت هشدار بده؛ بعد از هر تغییر سقف یا فرمت چک برابری را دوباره اجرا کن.
Assert identical top-K across paths on shared input; assert merge RAM stays O(runs) by capping the chunk and counting open readers; record spill bytes and wall time per run; alert when spill ratio crosses the tuned threshold; re-run the equality check after any cap or format change.

پاسخ سؤال‌های پیگیریFollow-up answers

پیگیری (سینیور): اگر فقط top-100 تقریبی لازم باشد، کدام قید را می‌توان سبک‌تر کرد؟Follow-up (Senior): If only an approximate top-100 is required, which constraint can be relaxed?

دقت را شل کن نه انضباط حافظه را: sketchِ count-min با heapِ top-K (یا اول نمونه‌برداری بعد شمارش دقیق بازمانده‌ها) خطای کران‌دار را با حافظه کوچک معاوضه می‌کند. بودجه خطا را با همان واحد گزارش نام ببر، سقف و چک برابری روی نمونه‌ها را نگه دار و بگو تقریب حق سرو کدام کوئری‌ها را ندارد (صورت‌حساب، آستانه‌های سوءاستفاده).

Relax exactness, not memory discipline: a count-min sketch with a top-K heap (or sampling first, then exact-counting the survivors) trades a bounded error for tiny memory. Name the error budget in the same units as the report, keep the cap and the equality check on samples, and say which queries the approximation must never serve (billing, abuse thresholds).

پیگیری (سینیور): ورودی ده برابر شود ولی کلیدهای متمایز ثابت بمانند — چه چیزی عوض می‌شود؟Follow-up (Senior): Input grows tenfold but distinct keys stay flat — what changes?

هر بار یک متغیر را عوض کن و سطر را دوباره گزارش بده: ۱۰ برابر خط با cardinality ثابت فقط مسیر sort را خطی طولانی می‌کند، ولی ۱۰ برابر کلید متمایز مسیر hash را از سطر اول به دوم می‌برد. جواب صادق بنچمارک خروجی‌برابر را دوباره اجرا می‌کند نه برون‌یابی.

Change one variable at a time and re-report the row: 10x lines with the same cardinality only lengthen the sort path linearly, while 10x distinct keys move the hash path from the first row to the second. The honest answer re-runs the equal-output benchmark instead of extrapolating.

اشتباهات رایجCommon mistakes

  • نقل آستانه عمومی برای URL متمایز («hash در N میلیون URL می‌شکند»). نقطه spill به سربار ورودی، سقف heap و دیسک همان ماشین وابسته است — per-environment اندازه بگیر و هرگز ثابت نقل نکن.
  • خواندن OOM به‌جای کُندی عادی. jobِ hash بعد از RAM دیر تمام نمی‌شود — می‌میرد. مسیر spill و هشدار را قبل از رسیدن ورودی بزرگ طراحی کن.
  • بنچمارک کار نابرابر: ورودی، سقف حافظه یا K متفاوت و بعد اعلام برنده. اول ورودی، سقف و K را فیکس کن؛ خروجی یکسان را assert کن؛ بعد زمان را مقایسه کن.
  • Quoting a universal distinct-URL threshold ('hash breaks at N million URLs'). The spill point depends on entry overhead, heap cap and disk on that machine — measure it per environment, never quote it as a constant.
  • Treating OOM as ordinary slowness. A hash job past RAM does not finish late — it dies. Design the spill path and the alert before the big input arrives.
  • Benchmarking unequal work: different inputs, memory caps or K, then declaring a winner. Fix input, cap and K first; assert identical outputs; only then compare time.

منابع:Sources: Microsoft · technical interviewing · Atlassian · engineering interview handbook

22 تمرین ۲ — sort-merge join روی دو فایلِ بزرگ‌تر از حافظهExercise 2 — sort-merge join on two bigger-than-memory files

L2 · SeniorL2 · Seniorوابسته به نقشRole-specific40 دقیقه بحث40 min discussion

بازگشت به صورت تمرینBack to the exercise · ارزیابی و سطحAssessment and level

سؤال مصاحبهInterview question
کلیک‌های بزرگ و یک پروفایل یکتا به‌ازای user را با external sort و streaming merge join ترکیب کن. null/missing profile و skew را تعریف کن. سپس ورودی mixed reducer را مقایسه کن که در آن profile-first secondary sort یا buffering لازم است؛ تفاوت دو قرارداد را بگو.
Externally sort and merge clicks with one unique profile per user. Define missing-profile behavior and skew. Contrast this with a mixed reducer stream that needs profile-first ordering or buffering; explain the different contracts.

پاسخ ۶۰ثانیه‌ایThe 60-second answer

هر دو فایل را با spill واقعی بر اساس user_id مرتب بیرونی کن، بعد دو cursor را با نگه‌داشتن حداکثر یک پروفایل ادغام جاری کن: تساوی (url, dob) می‌دهد و کلیک‌های بدون پروفایل NULL شمارش‌شده می‌شوند. دو جریان مرتبِ جدا فقط ترتیب کلید join را لازم دارند — secondary sortِ profile-first مال قرارداد mixed-reducer است نه این‌جا. تریدآف اصلی I/O در برابر حافظه کران‌دار است: دو sort کامل ترافیک دیسک می‌پردازند تا حالت join از نوع O(1) تضمین شود.
Externally sort both files by user_id with real spills, then streaming-merge the two cursors while holding at most one profile: equality emits (url, dob), clicks without profiles become counted NULLs. Two separately sorted streams need only the join-key order — profile-first secondary sort belongs to the mixed-reducer contract, not here. The top trade-off is I/O for bounded memory: two full sorts cost disk traffic to guarantee O(1) join state.

صورت مسئله و فرض‌هاProblem and assumptions

  • فایل‌های آموزشی: ۳۰٬۰۰۰ کلیک روی ۳٬۵۰۰ شناسه و ۳٬۰۰۰ پروفایل یکتا (user_id,dob) — هر دو از سقف chunk هزارسطری می‌گذرند پس spill واقعی است نه نمایشی.
  • یک پروفایل یکتا به‌ازای هر کاربر که از اول اعلام شده؛ تاریخچه و تکراری‌ها حالت خرابی صریح‌اند نه fan-out بی‌صدا.
  • معنای left-join: هر کلیک دقیقاً یک‌بار می‌دهد، با NULL به‌علاوه شمارنده وقتی پروفایلش نیست.
  • Teaching files: 30,000 clicks over 3,500 ids, 3,000 unique profiles (user_id,dob) — both exceed the 1,000-row chunk cap, so spills are real, not staged.
  • One unique profile per user, declared up front; history and duplicates are handled as explicit failure cases, not silent fan-out.
  • Left-join semantics: every click emits exactly once, with NULL plus a counter when its profile is missing.

چرا این روش و این عددها؟Why this method and these numbers?

در دو جریان جدا، cursor پروفایل را تا user_id کلیک جلو می‌بریم؛ یکتایی پروفایل یعنی فقط یک رکورد لازم است. این O(1) دربارهٔ state join است، نه همهٔ حافظهٔ external sort. در mixed stream، کلیک زودرس بدون پروفایل نیاز به نگهداری دارد؛ profile-first همین buffering را حذف می‌کند. ۳۰هزار و سه‌هزار رکورد از chunk هزارسطری بزرگ‌ترند تا spill واقعی رخ دهد. duplicate profile فرض یکتایی را می‌شکند و باید با قرارداد جدا حل شود.

With separate streams, advance the profile cursor to the click’s user_id; unique profiles require one retained record. O(1) describes join state, not all external-sort memory. Early clicks in a mixed stream need buffering until their profile arrives; profile-first removes that buffering. Thirty thousand and three thousand records exceed the 1k-row chunk to force spills. Duplicate profiles violate the uniqueness premise and need a separate policy.

مدل ذهنیMental model

join دو خط مرتب را با کلید هم‌تراز می‌کند: دو cursor جلو می‌روند، سمت عقب‌مانده جبران می‌کند و تساوی می‌دهد. مرتب‌سازی است که خط‌ها را قدم‌زدنی می‌کند؛ خود ادغام هرگز به عقب نگاه نمی‌کند.
A join aligns two ordered lines by key: two cursors walk forward, the lagging side catches up, equality emits. Sorting is what makes the lines walkable; the merge itself never looks back.

حل گام‌به‌گامStep by step

  1. اعلام یکتایی و نوع join قبل از دست زدن به داده معنای left-join و یکتایی پروفایل را اعلام کن: هر کلیک یک‌بار می‌دهد و گمشده یعنی NULL به‌علاوه شمارنده.
  2. مرتب‌سازی هر دو آن‌سوی حافظه هر سمت را بر اساس user_id با chunkهای کران‌دار و spill واقعی مرتب کن؛ هر سمت دقیقاً یک جریان مرتب می‌دهد.
  3. ادغام با یک پروفایل نگه‌داشته هر دو ترتیب را با دو cursor قدم بزن: سمت عقب‌مانده را جلو ببر، حداکثر یک پروفایل نگه دار، روی تساوی (url, dob) بده و گمشده‌ها را بشمار.
  4. مقایسه قرارداد میکس جریان میکس را مقایسه کن: یک ورودی درهم به‌ازای هر reducer ترتیب profile-first می‌خواهد — یا بافرینگ صریح با کران. دو جریان جدا هیچ‌کدام را نمی‌خواهند.
  5. چولگی و تاریخچه، قیمت‌گذاشته چولگی را با salt-and-combine قیمت بگذار و تاریخچه را با قاعده live-at-ts فیصله بده؛ هر دو قبل از اجرا اعلام و حین آن رصد می‌شوند.
  1. Declare uniqueness and join type State left-join semantics and profile uniqueness before touching data: every click emits once, missing means NULL plus a counter.
  2. Sort both past memory Sort each side by user_id with bounded chunks and real spills; each side yields exactly one ordered stream.
  3. Merge with one profile held Walk both orders with two cursors: advance the lagging side, hold at most one profile, emit (url, dob) on equality, count the missing.
  4. Contrast the mixed contract Contrast the mixed stream: one interleaved input per reducer needs profile-first secondary sort — or explicit buffering with a bound. Two separate streams need neither.
  5. Skew and history, priced Price skew with salt-and-combine and settle history with the live-at-ts rule; both declared before the run, both monitored during it.

طراحی مشخصConcrete design

فرمت‌ها: کلیک‌ها user_id,url,ts و پروفایل‌ها user_id,dob؛ کلید sort هر دو سمت user_id؛ runهای spill فایل‌های موقت CSV مرتب‌اند.

ادغام: دو cursor فایلی؛ سمت عقب‌مانده جلو می‌رود؛ روی تساوی یک پروفایل نگه داشته و به‌ازای هر کلیک می‌دهد؛ کلیک‌های بی‌پروفایل به شمارنده NULL می‌روند و پروفایل‌های بدون کلیک رد می‌شوند.

چولگی و تاریخچه: کاربران داغ با salt شکافته و با گام combine جمع می‌شوند (با قیمت علنی)؛ پروفایل‌های نسخه‌دار با قاعده live-at-ts که قبل از اجرا اعلام شده join می‌شوند.

Formats: clicks user_id,url,ts, profiles user_id,dob; sort key user_id on both sides; spill runs are sorted CSV temp files.

Merge: two file cursors; advance the lagging side; on equality hold one profile and emit per click; missing clicks go to the NULL counter, profiles without clicks are skipped.

Skew and history: hot users split by salt with a combine step (priced openly); versioned profiles join by the live-at-ts rule declared before the run.

مثال محاسبه‌شدهWorked example

عددهای دمو (آموزشی، همین محیط): ۳۰٬۰۰۰ کلیک، ۳٬۰۰۰ پروفایل، سقف chunk هزار سطر ← spill واقعی هر دو سمت. ۲۵٬۶۹۲ بازدید داده شد و ۴٬۳۰۸ گمشده شمارش شد با max_profiles_held=1.

تفسیر: اول تمامیت (emitted به‌علاوه missing برابر کلیک‌ها)، بعد ناوردا (یک پروفایل) و بعد هزینه (I/O اسپیل). نمونه‌ها مثل /p/203 -> 1990-01-01 خوانده می‌شوند.

Demo numbers (teaching, this environment): 30,000 clicks, 3,000 profiles, chunk cap 1,000 rows → real spills on both sides. Emitted 25,692 visits, 4,308 counted missing, max_profiles_held=1.

Interpretation: completeness first (emitted plus missing equals clicks), then the invariant (one profile), then cost (spill I/O). Sample emits read /p/203 -> 1990-01-01.

گام 1 از 3 · خط فکریStep 1 of 3 · reasoning thread · دو جریان مرتبِ جدا، یک ادغام جاری و یک پروفایل نگه‌داشتهTwo separately sorted streams, one streaming merge, one profile held · جریان‌دادهdataflowباز کردن در صفحه جدا ↗Open standalone ↗

دانلود نمودار مستقلDownload standalone diagram

روایت مدرس / چطور این نمودار را بخوانیمTeacher narration / how to read this diagram
دو فایل که هرکدام از حافظه بزرگ‌ترند. هرکدام را با spill بر اساس user_id مرتب کن، بعد هر دو ترتیب را با دو cursor قدم بزن.
Two files, each bigger than memory. Sort each by user_id with spills, then walk both orders with two cursors.
  1. مرتب‌سازی هر دو سمت بر اساس user_id: chunkهای کران‌دار، spill واقعی و یک ترتیب مرتب برای هرکدام.
  2. قدم‌زدن با دو cursor: سمت عقب‌مانده جلو می‌رود؛ تساوی یک پروفایل نگه می‌دارد و به‌ازای هر کلیک می‌دهد.
  3. شمارش گمشده‌ها: کلیک‌های بدون پروفایل NULL با شمارنده می‌شوند، هرگز سکوت نه.
  1. Sort both sides by user_id: bounded chunks, real spills, one sorted order each.
  2. Walk with two cursors: advance the lagging side; equality holds one profile and emits per click.
  3. Count the missing: clicks without profiles become NULLs with a counter, never silence.

نقطهٔ تصمیم: نقطه تصمیم جعبه ادغام است: دو cursor به‌علاوه یک پروفایل نگه‌داشته کل حالت join است — هرچه بیشتر طراحی دیگری است.

رد خرابی: اگر پروفایل کلیکی هرگز نرسد ادغام نباید بایستد: cursor عقب‌مانده جلو می‌رود و کلیک در bin شمارش‌شده می‌نشیند. آن یال خط‌چین را دنبال کن — سکوت آن‌جا یعنی از دست رفتن داده.

تضمین نشان‌داده‌شده: شکل هر دو spill واقعی، قدم‌زدن دو cursor و ناوردای پروفایل O(1) را نشان می‌دهد. چولگی و تاریخچه نسخه‌دار را نشان نمی‌دهد — آن‌ها در اسمبلی می‌آیند.

تضمین نشان‌نداده‌شده: درباره قرارداد تک‌جریان میکس قولی نمی‌دهد: هیچ ترتیب profile-first این‌جا نیست، عمداً.

Decision point: The decision point is the merge box: two cursors plus one held profile is the whole join state — anything more is a different design.

Failure trace: If a click's profile never arrives, the merge must not stall: the lagging cursor advances and the click lands in the counted bin. Trace that dashed edge — silence there would be data loss.

Guarantee shown: The figure shows both real spills, the two-cursor walk, and the O(1) profile invariant. It does not show skew handling or versioned history — those arrive in the assembly.

Not shown: It promises nothing about the mixed single-stream contract: no profile-first ordering appears here, deliberately.

خودآزماییSelf-check

حداکثر حالت join چقدر است و پروفایل‌های گمشده کجا می‌روند؟

پاسخ: هر دو سمت مرتب بر اساس user_id، دو cursor قدم‌زننده، حداکثر یک پروفایل نگه‌داشته و گمشده‌ها شمارش‌شده.

What is the maximum join state, and where do missing profiles go?

Answer: Both sides sorted by user_id, two cursors walking, at most one profile held, missing counted.

گام 2 از 3 · خط فکریStep 2 of 3 · reasoning thread · یک جریان میکس به‌ازای هر reducer: ترتیب profile-first یا بافرینگOne mixed stream per reducer: profile-first ordering or buffering · جریان‌دادهdataflowباز کردن در صفحه جدا ↗Open standalone ↗

دانلود نمودار مستقلDownload standalone diagram

روایت مدرس / چطور این نمودار را بخوانیمTeacher narration / how to read this diagram
یک shuffle کلیک‌ها و پروفایل‌ها را درهم به یک reducer می‌رساند. ترتیب رسیدن داخل گروه دلخواه است.
One shuffle delivers clicks and profiles interleaved to a single reducer. Arrival order inside the group is arbitrary.
  1. پارتیشن بر اساس user_id: هر reducer مالک کل گروه‌هاست ولی ترتیب داخل دلخواه است.
  2. شاخه بالا: secondary sort پروفایل را اول و بعد کلیک‌ها را با ts می‌گذارد — reducer دوباره جاری می‌شود.
  3. شاخه پایین: بدون آن ترتیب، کلیک‌ها تا رسیدن پروفایل در بافر می‌مانند — کران‌دار یا پرریسک.
  1. Partition by user_id: each reducer owns whole groups, but order inside is arbitrary.
  2. Upper branch: secondary sort puts the profile first, then clicks by ts — the reducer streams again.
  3. Lower branch: without the ordering, clicks wait in a buffer until the profile lands — bounded or risky.

نقطهٔ تصمیم: نقطه تصمیم قرارداد است که قبل از سازوکار نام برده می‌شود: جریان میکس یعنی ترتیب-یا-بافر؛ جریان‌های جدا یعنی کلیدهای join کافی‌اند.

رد خرابی: شاخه خط‌چین را بدون کران بگیر و گروه عظیم بافر را لبریز می‌کند — همان OOM در جای تازه. شکل بافرینگ را ریسک حافظه قیمت می‌گذارد نه پیش‌فرض رایگان.

تضمین نشان‌داده‌شده: شکل دقیقاً نشان می‌دهد profile-first کجا بافرینگ را حذف می‌کند (گروه‌های میکس) و کجا لازم نیست (جریان‌های جدا). ادعا نمی‌کند یک قرارداد همیشه بهتر است.

تضمین نشان‌نداده‌شده: توزیع اندازه گروه و هزینه CPU کلید sort بیرون این قاب‌اند — per-workload اندازه‌گیری می‌شوند.

Decision point: The decision point is the contract, named before the mechanism: mixed stream means order-or-buffer; separate streams mean join keys suffice.

Failure trace: Take the dashed branch without a bound and a huge group overflows the buffer — the same OOM in a new place. The diagram prices buffering as memory risk, not as a free default.

Guarantee shown: The figure shows exactly where profile-first avoids buffering (mixed groups) and where it is unnecessary (separate streams). It does not claim one contract is always better.

Not shown: Group-size distribution and sort-key CPU costs are outside this frame — measured per workload.

خودآزماییSelf-check

ترتیب profile-first کجا بافرینگ را حذف می‌کند و کجا لازم نیست؟

پاسخ: جریان میکس: secondary sortِ profile-first با حالت O(1) جاری می‌شود؛ وگرنه کلیک‌ها با کران صریح بافر می‌شوند.

Where does profile-first ordering avoid buffering, and where is it unnecessary?

Answer: Mixed stream: profile-first secondary sort streams with O(1) state; otherwise buffer clicks with an explicit bound.

نمودار اسمبل نهاییFinal assembly diagram · کل join: ادغام مرتب، قاعده نسخه و گمشده شمارش‌شدهThe whole join: sorted merge, version rule, counted missing · جریان‌دادهdataflowباز کردن در صفحه جدا ↗Open standalone ↗

دانلود نمودار مستقلDownload standalone diagram

روایت مدرس / چطور این نمودار را بخوانیمTeacher narration / how to read this diagram
کل pipeline در یک قاب: هر دو را مرتب کن، نسخه‌ها را حل کن، ادغام کن، بده یا بشمار.
The full pipeline in one frame: sort both, resolve versions, merge, emit or count.
  1. مرتب‌سازی هر دو سمت و حل تاریخچه: هر کلیک به پروفایل زنده در ts خودش می‌رسد.
  2. ادغام با یک پروفایل نگه‌داشته و شمارش گمشده‌ها در کنار.
  3. چولگی به‌عنوان کار قیمت‌گذاشته‌شده همراه است: کلید سنگین را salt بزن، تکه‌ها را join کن و combine کن.
  1. Sort both sides and resolve history: each click meets the profile live at its ts.
  2. Merge with one profile held and count the missing on the side.
  3. Skew rides along as priced work: salt the heavy key, join parts, combine.

نقطهٔ تصمیم: نقطه‌های تصمیم دو جعبه تازه‌اند: قاعده نسخه (کلیک به کدام پروفایل وصل می‌شود) و bin گمشده (نبودن یعنی چه).

رد خرابی: یکتایی را با پروفایل تکراری بشکن و تساوی fan-out می‌کند: ادغام به‌ازای هر کپی پروفایل یک سطر می‌دهد. پیش‌شرط اعلام‌شده به‌علاوه assertِ dedupe نگهبان است — دنبال کن کجا شلیک می‌کند.

تضمین نشان‌داده‌شده: اسمبلی هر دو گام را به‌علاوه تاریخچه و چولگی ترکیب می‌کند: همان جعبه‌ها، همان برچسب‌ها و یک ناوردا سرتاسری. هنوز چیزی اندازه نمی‌گیرد — عددها با دموست.

تضمین نشان‌نداده‌شده: فاکتور salt و هزینه حل نسخه workload-measured است نه نمودارشده.

Decision point: The decision points are the two new boxes: the version rule (which profile a click joins) and the missing bin (what absence means).

Failure trace: Break uniqueness with duplicate profiles and equality fans out: the merge emits one row per profile copy. The declared precondition plus a dedupe assert is the guard — trace where it would fire.

Guarantee shown: The assembly composes both steps plus history and skew: same boxes, same labels, one invariant end to end. It still measures nothing — the demo carries the numbers.

Not shown: Salt factors and version-resolution costs are workload-measured, not diagrammed.

خودآزماییSelf-check

دو جعبه فقط-اسمبلی چه چیزی را تصمیم می‌گیرند؟

پاسخ: ادغام مرتب به‌علاوه قاعده نسخه اعلام‌شده به‌علاوه bin گمشده شمارش‌شده، با چولگی قیمت‌گذاشته‌شده به‌صورت salt-and-combine.

What do the two assembly-only boxes decide?

Answer: Sorted merge plus a declared version rule plus a counted missing bin, with skew priced as salt-and-combine.

گزینه‌های جایگزینAlternatives

  • join پخشی (سمت-map): وقتی سمت پروفایل در RAM جا می‌شود، یک‌بار در جدول hash بگذار و کلیک‌ها را از کنارش جاری کن — سمت بزرگ اصلاً sort نمی‌شود. تا وقتی پروفایل‌ها جا می‌شوند می‌برد؛ روزی که جا نشوند می‌بازد و به همین حل برمی‌گردی.
  • join میکس-reducer با secondary sortِ profile-first: جواب درست وقتی یک shuffle کلیک‌ها و پروفایل‌ها را درهم می‌رساند. کلید sort قوی‌تر را می‌پردازد تا جاری بودن را پس بگیرد؛ برای دو فایل ازقبل‌جدا آن را نپرداز.
  • Broadcast (map-side) join: when the profile side fits in RAM, load it into a hash table once and stream clicks past it — no sort of the big side at all. Wins while profiles fit; loses the day they stop fitting, when you fall back to this solution.
  • Mixed-reducer join with profile-first secondary sort: the right answer when one shuffle delivers clicks and profiles interleaved. It pays a stronger sort key to recover streaming; do not pay it for two already-separate files.

سناریوهای خرابیFailure scenarios

  • پروفایل تکراری یکتایی اعلام‌شده را می‌شکند: تساوی به چند سطر به‌ازای هر کلیک fan-out می‌کند. نگهبان assertِ dedupe روی سمت پروفایل قبل از ادغام است و job را بینداز — هرگز fan-out را بی‌صدا نده.
  • چولگی کاربر داغ: یک کاربر سهم عظیم کلیک دارد و worker joinش می‌خوابد ولی بقیه بیکارند. نگهبان اندازه‌گیری توزیع کلید، salt زدن کلید سنگین، join تکه‌ها و بعد combine است — قیمت‌گذاشته‌شده نه پنهان.
  • تاریخچه نسخه‌دار بدون قاعده: چند پروفایل یک کاربر را شریک‌اند و join تعریف‌نشده است. نگهبان قاعده live-at-ts اعلام‌شده قبل از اجراست؛ بدون آن به‌جای join بایست.
  • Duplicate profiles break declared uniqueness: equality fans out into several rows per click. Guard with a dedupe assert on the profile side before the merge, and fail the job — never emit the fan-out silently.
  • Hot-user skew: one user owns a huge click share and its join worker stalls while others idle. Guard by measuring key distribution, salting the heavy key, joining parts, then combining — priced, not hidden.
  • Versioned history without a rule: several profiles share one user and the join is undefined. Guard with the live-at-ts rule declared before the run; without it, stop instead of joining.

کد قابل اجراRunnable code

اجراشده و تأییدشده در همین محیطExecuted and verified in this environment

این کد آموزشی و ساده‌سازی‌شده است؛ فقط برای آشنایی با ایده. کد واقعی پروداکشن ملاحظات بسیار بیشتری دارد و هیچ‌کس این‌شکلی کد پروداکشن نمی‌زند.Simplified teaching code for building intuition only — not production code; nobody ships production code like this.

import java.util.*;

// Teaching demo: sort-merge join on two files bigger than the memory cap.
// clicks(user_id,url,ts) + profiles(user_id,dob), one unique profile per
// user. Each side is externally sorted by user_id (bounded chunk, spill to
// temp files), then a streaming merge holds AT MOST one profile record in
// memory and emits (url,dob) per visit. Missing profiles are counted, not
// silently dropped. Two separately sorted streams: NO secondary sort needed.
public class SortMergeJoinDemo {
    static final int CLICKS = 30_000, USERS = 3_000, CHUNK = 1_000;

    public static void main(String[] a) throws Exception {
        Random rnd = new Random(12);
        java.nio.file.Path clickFile = java.nio.file.Files.createTempFile("clicks", ".csv");
        java.nio.file.Path profFile = java.nio.file.Files.createTempFile("profiles", ".csv");
        List<String> buf = new ArrayList<>();
        for (int i = 0; i < CLICKS; i++)
            buf.add((rnd.nextInt(USERS + 500)) + ",/p/" + rnd.nextInt(400) + "," + (1_700_000_000L + i));
        java.nio.file.Files.write(clickFile, buf);
        buf.clear();
        for (int u = 0; u < USERS; u++) buf.add(u + ",1990-01-" + String.format("%02d", 1 + (u % 28)));
        java.nio.file.Files.write(profFile, buf);

        java.nio.file.Path sortedClicks = externalSort(clickFile);
        java.nio.file.Path sortedProfiles = externalSort(profFile);

        // Streaming merge: advance the lagging cursor; equality emits.
        var clicks = java.nio.file.Files.newBufferedReader(sortedClicks);
        var profs = java.nio.file.Files.newBufferedReader(sortedProfiles);
        String c = clicks.readLine(), p = profs.readLine();
        long emitted = 0, missing = 0, heldProfiles = 0, maxHeld = 0;
        String heldDob = null;
        int heldUser = -1;
        List<String> sample = new ArrayList<>();
        while (c != null && p != null) {
            int cu = Integer.parseInt(c.split(",")[0]);
            int pu = Integer.parseInt(p.split(",")[0]);
            if (cu == pu) {
                heldUser = pu; heldDob = p.split(",")[1]; heldProfiles = 1;
                maxHeld = Math.max(maxHeld, heldProfiles);
                emitted++;
                if (sample.size() < 3) sample.add(c.split(",")[1] + " -> " + heldDob);
                c = clicks.readLine();
            } else if (cu < pu) {
                missing++; // click whose profile id is absent on the profile side
                c = clicks.readLine();
            } else {
                p = profs.readLine(); // profile with no clicks: skip
            }
        }
        while (c != null) { missing++; c = clicks.readLine(); }
        clicks.close(); profs.close();

        System.out.println("clicks=" + CLICKS + " profiles=" + USERS
            + " chunk_cap=" + CHUNK + " (both files exceed it: real spill)");
        for (String s : sample) System.out.println("emit " + s);
        System.out.println("emitted=" + emitted + " missing_profile=" + missing
            + " max_profiles_held=" + maxHeld);
        System.out.println("OK");
    }

    static java.nio.file.Path externalSort(java.nio.file.Path in) throws Exception {
        List<java.nio.file.Path> runs = new ArrayList<>();
        List<String> chunk = new ArrayList<>(CHUNK);
        for (String line : java.nio.file.Files.readAllLines(in)) {
            chunk.add(line);
            if (chunk.size() == CHUNK) runs.add(spill(chunk));
        }
        if (!chunk.isEmpty()) runs.add(spill(chunk));
        Comparator<String> byUser = Comparator.comparingInt(s -> Integer.parseInt(s.split(",")[0]));
        if (runs.size() == 1) return runs.get(0);
        // K-way merge of runs into one sorted file.
        java.nio.file.Path out = java.nio.file.Files.createTempFile("sorted", ".csv");
        List<java.io.BufferedReader> brs = new ArrayList<>();
        PriorityQueue<String[]> pq = new PriorityQueue<>((x, y) -> byUser.compare(x[0], y[0]));
        for (java.nio.file.Path p : runs) {
            var br = java.nio.file.Files.newBufferedReader(p);
            brs.add(br);
            String h = br.readLine();
            if (h != null) pq.add(new String[]{h, String.valueOf(brs.size() - 1)});
        }
        var w = java.nio.file.Files.newBufferedWriter(out);
        while (!pq.isEmpty()) {
            String[] e = pq.poll();
            w.write(e[0]); w.newLine();
            var br = brs.get(Integer.parseInt(e[1]));
            String h = br.readLine();
            if (h != null) pq.add(new String[]{h, e[1]});
        }
        w.close();
        for (var br : brs) br.close();
        return out;
    }

    static java.nio.file.Path spill(List<String> chunk) throws Exception {
        chunk.sort(Comparator.comparingInt(s -> Integer.parseInt(s.split(",")[0])));
        java.nio.file.Path p = java.nio.file.Files.createTempFile("run", ".csv");
        java.nio.file.Files.write(p, chunk);
        chunk.clear();
        p.toFile().deleteOnExit();
        return p;
    }
}

اجرا: javac SortMergeJoinDemo.java && java SortMergeJoinDemo — فقط استاندارد جاوا.

خروجی مورد انتظار: clicks=30000 profiles=3000 chunk_cap=1000 (هر دو فایل می‌گذرند: spill واقعی)؛ سه نمونه مثل /p/203 -> 1990-01-01؛ emitted=25692 missing_profile=4308 max_profiles_held=1؛ OK. (خروجی واقعی همین محیط)

Run: javac SortMergeJoinDemo.java && java SortMergeJoinDemo — stdlib Java only.

Expected output: clicks=30000 profiles=3000 chunk_cap=1000 (both files exceed it: real spill); three sample emits such as /p/203 -> 1990-01-01; emitted=25692 missing_profile=4308 max_profiles_held=1; OK. (actual output of this environment)

آزمون صحت و معیار عملیاتیCorrectness checks and operational criteria

assert کن emitted به‌علاوه missing برابر جمع کلیک‌هاست؛ assert کن هر فایل run مرتب است؛ assert کن حداکثر پروفایل نگه‌داشته برابر یک است؛ برای قطعیت دو بار اجرا کن؛ وقتی نسبت missing از آستانه گذشت هشدار بده؛ روی کلید پروفایل تکراری بینداز.
Assert emitted plus missing equals total clicks; assert each run file is sorted; assert max profiles held equals one; run twice for determinism; alert when the missing ratio crosses its threshold; fail on duplicate profile keys.

پاسخ سؤال‌های پیگیریFollow-up answers

پیگیری (سینیور): اگر برای هر کاربر چند نسخه پروفایل تاریخی باشد، کدام نسخه به کلیک وصل می‌شود؟Follow-up (Senior): With multiple historical profiles per user, which version belongs to a click?

هر کلیک را به نسخه پروفایل زنده در ts خودش وصل کن: پروفایل‌های هر کاربر را با بازه اعتبار مرتب کن و آنی که ts کلیک را دارد بردار. کلیک‌های بیرون همه بازه‌ها به bin گمشده با شمارنده version-miss می‌روند (جدا از هرگز-پروفایل-نداشته) و بازه‌های هم‌پوشان job را می‌اندازند — یعنی خود تاریخچه خراب است.

Join each click to the profile version live at its ts: order each user's profiles by validity interval and pick the one containing the click ts. Clicks outside every interval go to the missing bin with a version-miss counter (separate from never-had-profile), and overlapping intervals fail the job — they mean the history itself is corrupt.

پیگیری (استف): در جریان mixed-reducer بدون ترتیب profile-first، دقیقاً کجا می‌شکنی؟Follow-up (Staff): In a mixed reducer stream without profile-first ordering, where exactly does it break?

در جریان میکس بدون profile-first، reducer باید کلیک‌ها را تا رسیدن پروفایل بافر کند — پس بافر را کران بده (بعد از N گروه spill کن و بعد از M بایت بینداز) و high-water mark را رصد کن. شکستِ رد کردن ترتیب دقیق است: گروه‌های بی‌کران، حافظه بی‌کران و بعد OOM در بدترین لحظه.

In the mixed stream without profile-first, the reducer must buffer clicks until the profile arrives — so bound the buffer (spill it past N groups, fail past M bytes) and monitor its high-water mark. The breakage of skipping the ordering is exact: unbounded groups, unbounded memory, then OOM at the worst moment.

اشتباهات رایجCommon mistakes

  • الزام secondary sortِ profile-first برای دو جریان مرتبِ جدا. کار sort اضافه می‌کند با صفر سود درستی: ادغام فقط کلیدهای join را مقایسه می‌کند. profile-first مال قرارداد تک‌جریان میکس است — به‌جای اعمال یک قاعده همه‌جا بگو هرکدام دقیقاً کجا می‌شکنند.
  • انداختن بی‌صدای کلیک‌های بدون پروفایل. نبودن یک واقعیت کسب‌وکاری است (کاربر حذف‌شده، بعدِ دیررس) — NULL بده، بشمار و روی نسبتش هشدار بده.
  • نگه‌داشتن همه پروفایل‌ها در حافظه چون «بعدها کوچک‌اند». کوچکِ امروز بزرگِ فصل بعد است: ادغام ذاتاً یک پروفایل نگه می‌دارد پس رشد هرگز این تصمیم را برنمی‌گرداند.
  • Demanding profile-first secondary sort for two separately sorted streams. It adds sort work for zero correctness gain: the merge only compares join keys. Profile-first belongs to the mixed single-stream contract — say exactly where each breaks instead of applying one rule everywhere.
  • Silently dropping clicks without profiles. Absence is a business fact (deleted user, late dimension) — emit NULL, count it, alert on its ratio.
  • Holding every profile in memory because 'dimensions are small'. Small today is big next quarter: the merge holds one profile by construction, so growth never revisits this decision.

منابع:Sources: Microsoft · technical interviewing · Atlassian · engineering interview handbook

33 تمرین ۳ — یک shuffle سادهExercise 3 — a mini shuffle

L3 · Staff / PrincipalL3 · Staff / Principalوابسته به نقشRole-specific40 دقیقه بحث40 min discussion

بازگشت به صورت تمرینBack to the exercise · ارزیابی و سطحAssessment and level

سؤال مصاحبهInterview question
پارتیشن، sort محلی mapper و ادغام reducer را برای group-by طراحی کن. کلید داغ، حجم شبکه، combiner و checksum فایل میانی را تحلیل کن. خروجی میانی گمشده را از lineage immutable با attemptهای منزوی دوباره حساب کن.
Design partitioning, mapper-local sorting and reducer merge for group-by. Analyze hot keys, network volume, combiners and checksums. Recompute lost intermediate output from immutable lineage using isolated attempts.

پاسخ ۶۰ثانیه‌ایThe 60-second answer

با یک تابع پایدار همه‌جا پارتیشن کن (floorMod(hash, R))، در هر mapper محلی مرتب کن، به‌ازای هر پارتیشن یک فایل مرتب mPrR بنویس و به‌ازای هر پارتیشن در reducer ادغام k-way کن — رکوردهای هم‌کلید با حافظه کران‌دار کنار هم می‌نشینند. هرجا تابع اجازه دهد با combiner پیش‌تجمیع کن، هر فایل میانی را checksum بزن و فقط mapperهای گمشده را از splitهای immutable با attempt id تازه دوباره اجرا کن. تریدآف اصلی شبکه و چولگی است: shuffle هر رکورد را یک‌بار جابه‌جا می‌کند و کلید داغ می‌تواند یک reducer را غرق کند.
Partition with one stable function everywhere (floorMod(hash, R)), sort locally in each mapper, write one sorted mPrR file per partition, and k-way merge per partition in the reducer — same-key records land together in bounded memory. Pre-aggregate with a combiner where the function allows, checksum every intermediate file, and recompute only lost mappers from immutable splits under fresh attempt ids. The top trade-off is network and skew: shuffle moves every record once, and a hot key can swamp one reducer.

صورت مسئله و فرض‌هاProblem and assumptions

  • shuffle آموزشی: ۲ mapper و ۳ پارتیشن با ۱۲ کلید و ۲۵ مقدار (۳۰۰ رکورد) — هم‌مکانی و محاسبه دوباره قابل‌مشاهده‌اند نه بنچمارک.
  • تابع پارتیشن floorMod(hash, R) که روی همه workerها یکسان و با job نسخه‌بندی شده؛ عوض کردنش همه‌چیز را دوباره shuffle می‌کند.
  • mapperها قطعی و بدون side effectاند پس اجرای دوباره همان رکوردها را بازپخش می‌کند — به‌عنوان پیش‌شرط اعلام و با دمو راستی‌آزمایی شده.
  • Teaching shuffle: 2 mappers, 3 partitions, 12 keys × 25 values (300 records) — colocation and recompute are observable, not benchmarks.
  • Partition function floorMod(hash, R), identical on all workers and versioned with the job; changing it reshuffles everything.
  • Mappers are deterministic and side-effect free, so rerun replays the same records — stated as a precondition, verified by the demo.

چرا این روش و این عددها؟Why this method and these numbers?

hash(key) mod R همهٔ مقدارهای یک key را به reducer یکسان می‌فرستد؛ برای همین تابع و نسخهٔ آن باید روی همهٔ mapperها یکسان باشد. سه partition و دو mapper فقط مسیرها را واضح می‌کند. sort ترتیب داخل partition را می‌دهد و با partitioning یک کار نمی‌کند. deterministic و بدون side effect بودن mapper باعث می‌شود retry همان داده را تولید کند؛ اگر خروجی تکراری attemptها هم‌زمان پذیرفته شود، این کافی نیست و commit manifest یک attempt لازم است.

Hash(key) mod R colocates a key’s values, requiring identical versioned partition functions on all mappers. Three partitions and two mappers expose the paths. Sorting orders records within a partition and serves a different purpose. Deterministic, side-effect-free mappers reproduce output on retry, but publication must still select one attempt through a commit manifest.

مدل ذهنیMental model

shuffle یک sort توزیع‌شده است: هر worker یک زبان پارتیشن حرف می‌زند، هرکدام تکه خودش را مرتب می‌کند و reducer تکه‌ها را مثل mergesort که runها را ادغام می‌کند، ادغام می‌کند. اول توافق بعد جابه‌جایی.
A shuffle is a distributed sort: every worker speaks the same partition language, each sorts its own slice, and the reducer merges slices the way a merge sort merges runs. Agreement first, movement second.

حل گام‌به‌گامStep by step

  1. یک تابع همه‌جا یک تابع پارتیشن را روی همه workerها فیکس و با job نسخه‌بندی کن: کلید یکسان، پارتیشن یکسان، هر mapper و هر اجرا.
  2. مرتب‌سازی محلی و نوشتن فایل هر تکه پارتیشن را داخل mapper مرتب کن و یک فایل checksummedِ mPrR به‌ازای هر پارتیشن بنویس — قرارداد قابل fetch.
  3. fetch و ادغام به‌ازای هر پارتیشن فایل‌های یک پارتیشن را از همه mapperها fetch کن و با یک خط به‌ازای هر فایل در RAM ادغام k-way کن: کلیدهای یکسان کنار هم و مرتب می‌رسند.
  4. combiner و checksumها هرجا انجمنی بودن برقرار است با combiner پیش‌تجمیع کن و هر فایل را checksum بزن تا خرابی شناسایی شود نه بی‌صدا.
  5. اجرای محدود از lineage روی گم شدن، فقط mapper مالک را از split immutable خودش با attempt id تازه دوباره اجرا کن؛ بازپخش قطعی بازنویسی را برابر فایل گمشده می‌کند.
  1. One function everywhere Fix one partition function on every worker and version it with the job: same key, same partition, every mapper, every run.
  2. Sort locally, write files Sort each partition slice inside the mapper and write one checksummed mPrR file per partition — the fetchable contract.
  3. Fetch and merge per partition Fetch one partition's files from all mappers and k-way merge with one line per file in RAM: same keys arrive together, sorted.
  4. Combiner and checksums Pre-aggregate with a combiner where associativity holds, and checksum every file so corruption is detected, never silent.
  5. Scoped lineage rerun On loss, rerun only the owning mapper from its immutable split under a fresh attempt id; deterministic replay makes the rewrite equal the lost file.

طراحی مشخصConcrete design

فایل‌ها: یک فایل مرتب checksummed به‌ازای هر (mapper, partition) با نام mPrR؛ مجموعه فایل همان قرارداد shuffle است که reducer fetch می‌کند.

attemptها: هر اجرای mapper یک attempt id دارد؛ retry کل فایل‌ها را با id تازه بازنویسی می‌کند و هرگز به فایل شکست‌خورده الصاق نمی‌کند.

reducer: فایل‌های یک پارتیشن را از همه mapperها fetch می‌کند، با یک خط به‌ازای هر فایل در RAM ادغام k-way می‌کند و کلیدهای برابر را گروه می‌کند؛ combiner کار انجمنی (شمارش، جمع — هرگز میانگین تنها) را قبل از عبور هر بایت از شبکه پیش‌تجمیع می‌کند.

Files: one sorted checksummed file per (mapper, partition) named mPrR; the file set is the shuffle contract the reducer fetches.

Attempts: every mapper run carries an attempt id; a retry rewrites whole files under a fresh id and never appends to the failed one's.

Reducer: fetches one partition's files from all mappers, k-way merges with one line per file in RAM, groups equal keys; combiner pre-aggregates associative work (counts, sums — never a bare average) before any byte crosses the network.

مثال محاسبه‌شدهWorked example

عددهای دمو (آموزشی، همین محیط): ۲ mapper و ۳ پارتیشن با ۳۰۰ رکورد. قبل از خرابی هر reduce صد رکورد روی ۴ کلید با colocated=true sorted=true دارد؛ m1r2.txt خراب می‌شود؛ mapper شماره ۱ به‌عنوان attempt شماره ۱ از split خودش دوباره اجرا می‌شود و بعد از محاسبه دوباره همان ناورداها برقرارند.

تفسیر: بازیابی محدود است (یک mapper) و راستی‌آزمایی‌شده (همان گزاره‌ها قبل و بعد)، نه روایت‌شده.

Demo numbers (teaching, this environment): 2 mappers, 3 partitions, 300 records. Before corruption each reduce holds 100 records over 4 keys with colocated=true sorted=true; m1r2.txt is corrupted; mapper 1 reruns as attempt 1 from its split; after recompute the same invariants hold.

Interpretation: recovery is scoped (one mapper) and verified (same predicates before and after), not narrated.

گام 1 از 3 · خط فکریStep 1 of 3 · reasoning thread · یک تابع پارتیشن روی همه mapperها و فایل‌های مرتب mPrR بیرونSame partition function on every mapper, sorted mPrR files out · جریان‌دادهdataflowباز کردن در صفحه جدا ↗Open standalone ↗

دانلود نمودار مستقلDownload standalone diagram

روایت مدرس / چطور این نمودار را بخوانیمTeacher narration / how to read this diagram
splitهای immutable وارد دو mapper می‌شوند؛ یک پارتیشنر مشترک هر رکورد را می‌فرستد و فایل‌های مرتب به‌ازای هر پارتیشن می‌نشینند.
Immutable splits enter two mappers; one shared partitioner routes every record; sorted files land per partition.
  1. خواندن splitها: هر mapper مالک خط‌های immutable است و هیچ‌چیز مشترک نیست.
  2. پارتیشن یکسان: floorMod(hash, R) روی هر دو mapper و کلید یکسان به پارتیشن یکسان.
  3. مرتب‌سازی و نوشتن: یک فایل مرتب checksummedِ mPrR به‌ازای هر پارتیشن — قرارداد قابل fetch.
  1. Read splits: each mapper owns immutable lines, nothing shared.
  2. Partition identically: floorMod(hash, R) on both mappers, same key to same partition.
  3. Sort and write: one sorted checksummed mPrR file per partition — the fetchable contract.

نقطهٔ تصمیم: نقطه تصمیم جعبه پارتیشنر است: یک تابع، همه workerها و نسخه‌بندی‌شده — هم‌مکانی خاصیت توافق است نه شانس.

رد خرابی: دو worker با تابع پارتیشن متفاوت بی‌صدا یک کلید را روی reducerها می‌پاشند: گروه‌بندی بدون خطا می‌شکند. برچسب stable روی پارتیشنر نگهبان است — تابع را با job نسخه‌بندی کن.

تضمین نشان‌داده‌شده: شکل تابع مشترک واحد، sortهای محلی و قرارداد نام فایل را نشان می‌دهد. fetch، ادغام یا خرابی را نشان نمی‌دهد — آن‌ها گام‌های بعدی‌اند.

تضمین نشان‌نداده‌شده: حجم شبکه، چولگی و ریاضی combiner بیرون این قاب‌اند.

Decision point: The decision point is the partitioner box: one function, every worker, versioned — colocation is a property of agreement, not luck.

Failure trace: Two workers with different partition functions silently scatter one key across reducers: grouping breaks with no error. The stable tag on the partitioner is the guard — version the function with the job.

Guarantee shown: The figure shows the single shared function, the local sorts, and the named file contract. It does not show fetching, merging, or failure — those are the next steps.

Not shown: Network volume, skew and combiner math are outside this frame.

خودآزماییSelf-check

کدام توافق واحد هم‌مکانی کلید یکسان را قطعی می‌کند؟

پاسخ: splitهای یکسان داخل، تابع یکسان همه‌جا و فایل‌های مرتب mPrR بیرون — توافق است که هم‌مکان می‌کند.

What single agreement makes same-key colocation certain?

Answer: Identical splits in, same function everywhere, sorted mPrR files out — agreement is what colocates.

گام 2 از 3 · خط فکریStep 2 of 3 · reasoning thread · یک attemptِ mapper: نوشتن، commit یا محاسبه دوباره از lineageOne mapper attempt: write, commit, or recompute from lineage · چرخه‌حالتlifecycleباز کردن در صفحه جدا ↗Open standalone ↗

دانلود نمودار مستقلDownload standalone diagram

روایت مدرس / چطور این نمودار را بخوانیمTeacher narration / how to read this diagram
یک attemptِ mapper را چپ‌به‌راست دنبال کن؛ خرابی‌ها به کنترلر retry پایین می‌روند و با id تازه برمی‌گردند.
Follow one mapper attempt left to right; failures detour down to the retry controller and return with a fresh id.
  1. از scheduled به running به staged: split تخصیص یافت، رکوردها پارتیشن و مرتب شدند و فایل‌های mPrR به‌علاوه checksum نوشته شدند.
  2. دو راه به retrying: کرش حین اجرا یا عدم‌تطابق checksum روی فایل‌های staged — هر دو با اجرای دوباره lineage.
  3. commit با کامل شدن fetch؛ failed فقط وقتی بودجه retry تمام شود.
  1. Scheduled to running to staged: split assigned, records partitioned and sorted, mPrR files plus checksums written.
  2. Two roads to retrying: crash while running, or a checksum mismatch on staged files — lineage rerun either way.
  3. Committed on fetch completion; failed only when the retry budget is spent.

نقطهٔ تصمیم: نقطه‌های تصمیم دو یال امنیتی به retryingاند: کرش در برابر خرابی — مقصد یکسان، مدرک متفاوت و قاعده id تازه یکسان.

رد خرابی: کرش حین اجرا و فایل staged خراب هر دو روی retrying می‌نشینند — ولی با مدرک متفاوت (پردازش ازدست‌رفته در برابر عدم‌تطابق checksum). retry که نام فایل شکست‌خورده را دوباره استفاده کند می‌گذارد دو attempt هر دو committed به نظر برسند: idهای تازه آن سوراخ را می‌بندند.

تضمین نشان‌داده‌شده: شکل همه حالت‌های mapper و دو راه متفاوت برگشت به اجرا را نشان می‌دهد. سمت reducer را نشان نمی‌دهد — fetch و ادغام در اسمبلی‌اند.

تضمین نشان‌نداده‌شده: بودجه retry و lineage بین-job سیاست بیرون این چرخه است.

Decision point: The decision points are the two security edges into retrying: crash versus corruption — same destination, different evidence, same fresh-id rule.

Failure trace: A crash in running and a corrupt staged file both land on retrying — but with different evidence (lost process versus checksum mismatch). A retry that reuses the failed file name would let two attempts both look committed: fresh ids close that hole.

Guarantee shown: The figure shows every mapper state and the two distinct roads back to running. It does not show the reducer side — fetch and merge live in the assembly.

Not shown: Retry budgets and cross-job lineage are policy outside this lifecycle.

خودآزماییSelf-check

کدام دو رویداد attempt را برمی‌گردانند و موقع برگشت چه چیزی باید فرق کند؟

پاسخ: کرش و خرابی هر دو با id تازه از split immutable تلاش می‌کنند؛ فقط تمام شدن بودجه terminal است.

Which two events send an attempt back, and what must differ on return?

Answer: Crash and corruption both retry with fresh ids from the immutable split; only budget exhaustion is terminal.

نمودار اسمبل نهاییFinal assembly diagram · shuffle مینی سرتاسری: fetch، ادغام، گروه و محاسبه دوباره گمشدهMini shuffle end to end: fetch, merge, group, recompute the lost · جریان‌دادهdataflowباز کردن در صفحه جدا ↗Open standalone ↗

دانلود نمودار مستقلDownload standalone diagram

روایت مدرس / چطور این نمودار را بخوانیمTeacher narration / how to read this diagram
splitها در یک قاب به پارتیشن‌های گروه‌شده تبدیل می‌شوند و انحراف گم‌شدن-و-اجرای‌دوباره خط‌چین کشیده شده.
Splits become grouped partitions in one frame, with the loss-and-rerun detour drawn dashed.
  1. mapperها پارتیشن، sort و combine می‌کنند؛ فایل‌های مرتب mPrR به fetch می‌روند.
  2. fetch فایل‌های یک پارتیشن را با یک خط به‌ازای هر فایل در RAM ادغام می‌کند: کلیدهای یکسان کنار هم.
  3. با شکست checksum فقط mapper گمشده دوباره اجرا می‌شود؛ فایل‌هایش به همان fetch برمی‌گردند.
  1. Mappers partition, sort and combine; sorted mPrR files flow to fetch.
  2. Fetch merges one partition's files with one line per file in RAM: same keys together.
  3. On checksum failure only the lost mapper reruns; its files rejoin the same fetch.

نقطهٔ تصمیم: نقطه تصمیم جعبه rerun است: محدود به mapper گمشده، تغذیه‌شده از lineage immutable و ورود دوباره به fetch استاندارد.

رد خرابی: جفت خط‌چین را دنبال کن: fetch checksum بد را می‌گیرد، دقیقاً یک mapper از split خودش دوباره اجرا می‌شود و فایل‌های بازنویسی‌شده از همان fetch برمی‌گردند. اجرای دوباره کل job این‌جا اشتباه پرهزینه است.

تضمین نشان‌داده‌شده: اسمبلی هر دو گام را ترکیب می‌کند: همان mapperها، همان فایل‌ها و همان ادغام — به‌علاوه بازیابی محدودی که هر دو گام تلویح داشتند. عددها با دمو می‌مانند.

تضمین نشان‌نداده‌شده: فاکتور salt کلید داغ و کاربرد combiner به‌ازای هر تابع per-workload طراحی می‌شوند نه این‌جا ثابت.

Decision point: The decision point is the rerun box: scoped to the lost mapper, fed by immutable lineage, re-entering standard fetch.

Failure trace: Follow the dashed pair: fetch detects the bad checksum, exactly one mapper reruns from its split, rewritten files re-enter through the same fetch. Rerunning the whole job here would be the expensive mistake.

Guarantee shown: The assembly composes both steps: same mappers, same files, same merge — plus the scoped recovery both steps implied. Numbers stay with the demo.

Not shown: Hot-key salting factors and combiner applicability per function are designed per workload, not fixed here.

خودآزماییSelf-check

بعد از خراب شدن فایل، چه چیزی دوباره اجرا می‌شود و چه چیزی می‌ماند؟

پاسخ: fetch، ادغام k-way و خروجی گروه‌شده — به‌علاوه دقیقاً یک اجرای دوباره mapper روی گم شدن فایل.

After a file corrupts, what reruns and what stays?

Answer: Fetch, k-way merge, grouped output — plus exactly one mapper rerun on file loss.

گزینه‌های جایگزینAlternatives

  • پارتیشن‌بندی بازه‌ای: هر reducer مالک یک بازه کلید است پس خروجی سراسری مرتب می‌آید. وقتی پایین‌دست ترتیب کلی می‌خواهد می‌برد؛ روی چولگی می‌بازد چون یک بازه می‌تواند نصف داده را بگیرد بدون hash که پخشش کند.
  • پخش سمت کوچک و حذف کامل shuffle وقتی یک ورودی در RAM هر worker جا می‌شود. با حذف شبکه، combiner و ادغام می‌برد؛ روزی که سمت «کوچک» جا نشود دیگر نمی‌برد.
  • Range partitioning: each reducer owns a key range, so output comes out globally sorted. Wins when downstream needs total order; loses on skew, because one range can hold half the data with no hash to spread it.
  • Broadcast the small side and skip the shuffle entirely when one input fits in every worker's RAM. Wins by deleting network, combiner and merge; stops winning the day the 'small' side stops fitting.

سناریوهای خرابیFailure scenarios

  • فایل میانی خراب: بایت‌های دیسک دیگر با checksum نمی‌خوانند. نگهبان انداختن fetch آن پارتیشن و اجرای دوباره فقط mapper مالک از split خودش است — هرگز کل job نه.
  • کرش mapper وسط نوشتن: فایل mPrR ناقص در نگاه اول کامل به نظر می‌رسد. نگهبان attempt id به‌علاوه checksum است: retry کل فایل‌ها را با id تازه بازنویسی می‌کند و fetch فایل چک‌نشده را رد می‌کند.
  • mapper غیرقطعی یا side effect بیرونی: اجرای دوباره رکوردهای متفاوت بازپخش می‌کند یا نوشتن‌های بیرونی را تکرار می‌کند. نگهبان با ساخت است — نگاشت قطعی و بدون نوشتن بیرونی — چون هیچ پروتکل retry mapper دروغ‌گو را درست نمی‌کند.
  • عوض شدن تابع پارتیشن وسط job: فایل‌های قدیم و جدید روی جای کلید اختلاف دارند و گروه‌بندی بی‌صدا می‌شکند. نگهبان نسخه‌بندی تابع با job و رد fetchهای چندنسخه‌ای است.
  • Corrupt intermediate file: bytes on disk no longer match the checksum. Guard by failing that partition's fetch and rerunning only the owning mapper from its split — never the whole job.
  • Mapper crash mid-write: a partial mPrR file looks complete at a glance. Guard with attempt ids plus checksums: the retry rewrites whole files under a fresh id, and fetch refuses unchecked files.
  • Nondeterministic mapper or outside side effects: rerun replays different records or repeats external writes. Guard by construction — deterministic mapping, no outside writes — because no retry protocol fixes a mapper that lies.
  • Partition function changed mid-job: old and new files disagree on key placement and grouping silently breaks. Guard by versioning the function with the job and refusing mixed-version fetches.

کد قابل اجراRunnable code

اجراشده و تأییدشده در همین محیطExecuted and verified in this environment

این کد آموزشی و ساده‌سازی‌شده است؛ فقط برای آشنایی با ایده. کد واقعی پروداکشن ملاحظات بسیار بیشتری دارد و هیچ‌کس این‌شکلی کد پروداکشن نمی‌زند.Simplified teaching code for building intuition only — not production code; nobody ships production code like this.

import java.util.*;

// Teaching demo: a mini shuffle. M mappers partition records by
// floorMod(hash(key), R); each mapper writes ONE sorted file per partition
// (m{i}r{p}), like real map-output files. Each reducer k-way merges its
// partition's files, so same-key records land together, sorted.
// Then one intermediate file is corrupted: only that mapper reruns, from
// its immutable input split with a fresh attempt id — safe because the
// mapper is deterministic and side-effect free.
public class ShuffleDemo {
    static final int MAPPERS = 2, PARTITIONS = 3, KEYS = 12, PER_KEY = 25;

    public static void main(String[] a) throws Exception {
        java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("shuffle");
        // Immutable input splits: each mapper owns its lines.
        List<List<String>> splits = new ArrayList<>();
        Random rnd = new Random(13);
        for (int m = 0; m < MAPPERS; m++) {
            List<String> split = new ArrayList<>();
            for (int k = 0; k < KEYS; k++)
                for (int v = 0; v < PER_KEY; v++)
                    if ((k + v + m) % MAPPERS == 0)
                        split.add("key" + k + "=" + (m * 1000 + rnd.nextInt(999)));
            splits.add(split);
        }
        for (int m = 0; m < MAPPERS; m++) runMapper(dir, m, splits.get(m), 0);

        System.out.println("partitions=" + PARTITIONS + " mappers=" + MAPPERS
            + " partition_fn=floorMod(hash, " + PARTITIONS + ")");
        verify(dir, "before corruption");

        // Corrupt one intermediate file, then rerun ONLY that mapper.
        java.nio.file.Path victim = dir.resolve("m1r2.txt");
        java.nio.file.Files.writeString(victim, "GARBAGE\n");
        System.out.println("corrupted " + victim.getFileName());
        runMapper(dir, 1, splits.get(1), 1); // fresh attempt id, same input
        System.out.println("reran mapper=1 attempt=1 from immutable split");
        verify(dir, "after recompute");
        System.out.println("OK");
    }

    static void runMapper(java.nio.file.Path dir, int m, List<String> split, int attempt) throws Exception {
        List<List<String>> parts = new ArrayList<>();
        for (int p = 0; p < PARTITIONS; p++) parts.add(new ArrayList<>());
        for (String rec : split) {
            String key = rec.split("=")[0];
            parts.get(Math.floorMod(key.hashCode(), PARTITIONS)).add(rec);
        }
        for (int p = 0; p < PARTITIONS; p++) {
            Collections.sort(parts.get(p));
            java.nio.file.Path f = dir.resolve("m" + m + "r" + p + ".txt");
            java.nio.file.Files.write(f, parts.get(p)); // attempt rewrites whole file
        }
    }

    static void verify(java.nio.file.Path dir, String phase) throws Exception {
        boolean colocated = true, sorted = true;
        for (int p = 0; p < PARTITIONS; p++) {
            // Reducer: k-way merge of this partition's sorted map files.
            List<List<String>> files = new ArrayList<>();
            for (int m = 0; m < MAPPERS; m++)
                files.add(java.nio.file.Files.readAllLines(dir.resolve("m" + m + "r" + p + ".txt")));
            int[] pos = new int[MAPPERS];
            PriorityQueue<int[]> pq = new PriorityQueue<>(
                (x, y) -> files.get(x[0]).get(x[1]).compareTo(files.get(y[0]).get(y[1])));
            for (int m = 0; m < MAPPERS; m++)
                if (!files.get(m).isEmpty()) pq.add(new int[]{m, 0});
            List<String> merged = new ArrayList<>();
            String prev = null;
            while (!pq.isEmpty()) {
                int[] e = pq.poll();
                String rec = files.get(e[0]).get(e[1]);
                if (prev != null && prev.compareTo(rec) > 0) sorted = false;
                prev = rec;
                merged.add(rec);
                if (e[1] + 1 < files.get(e[0]).size()) pq.add(new int[]{e[0], e[1] + 1});
            }
            Set<String> keys = new HashSet<>();
            for (String r : merged) keys.add(r.split("=")[0]);
            for (String k : keys)
                colocated &= Math.floorMod(k.hashCode(), PARTITIONS) == p;
            System.out.println(phase + " reduce[" + p + "] records=" + merged.size()
                + " distinct_keys=" + keys.size());
        }
        System.out.println(phase + " colocated=" + colocated + " sorted=" + sorted);
        if (!colocated || !sorted) throw new IllegalStateException("shuffle broken: " + phase);
    }
}

اجرا: javac ShuffleDemo.java && java ShuffleDemo — فقط استاندارد جاوا.

خروجی مورد انتظار: partitions=3 mappers=2 partition_fn=floorMod(hash, 3)؛ قبل از خرابی هر reduce صد رکورد و ۴ کلید با colocated=true sorted=true دارد؛ m1r2.txt خراب شد؛ mapper=1 attempt=1 دوباره اجرا شد؛ بعد از محاسبه دوباره همان ناورداها؛ OK. (خروجی واقعی همین محیط)

Run: javac ShuffleDemo.java && java ShuffleDemo — stdlib Java only.

Expected output: partitions=3 mappers=2 partition_fn=floorMod(hash, 3); before corruption each reduce holds 100 records / 4 keys with colocated=true sorted=true; corrupted m1r2.txt; reran mapper=1 attempt=1; after recompute identical invariants; OK. (actual output of this environment)

آزمون صحت و معیار عملیاتیCorrectness checks and operational criteria

بعد از هر اجرا هم‌مکانی (هر کلید روی floorMod(hash, R) می‌نشیند) و مرتب بودن هر پارتیشن را assert کن؛ خرابی تزریق کن و خروجی گروه‌شده یکسان را assert کن؛ assert کن idهای attempt به‌ازای هر retry یکتایند و fetch فایل چک‌نشده را رد می‌کند؛ بایت shuffle و سهم کلید اول هر job را بسنج.
Assert colocation (every key lands on floorMod(hash, R)) and sortedness per partition after every run; inject corruption and assert identical regrouped output; assert attempt ids are unique per retry and fetch refuses unchecked files; measure shuffle bytes and top-key share per job.

پاسخ سؤال‌های پیگیریFollow-up answers

پیگیری (استف): یک key نصف داده را دارد؛ چگونه reducer را از hotspot نجات می‌دهی؟Follow-up (Staff): One key accounts for half the records. How do you prevent a reducer hotspot?

کلید داغ را با salt بشکن: موقع map پسوند shard تصادفی بچسبان تا رکوردهایش روی reducerهای زیادی پخش شوند، به‌ازای (key, salt) تجمیع کن و بعد shardها را در pass سبک دوم combine کن. قیمتش را علنی بگو — pass shuffle اضافه به‌علاوه combine نهایی — و سهم کلید اول را رصد کن تا فاکتور salt از اندازه‌گیری بیاید نه خرافه.

Split the hot key with salt: append a random shard suffix at map time so its records spread over many reducers, aggregate per (key, salt), then combine the shards in a second lightweight pass. Price it openly — extra shuffle pass plus final combine — and monitor the top-key share so the salt factor follows measurement, not superstition.

پیگیری (سینیور): combiner کی غلط است و به‌جایش چه چیزی را combine می‌کنی؟Follow-up (Senior): When is a combiner wrong, and what do you combine instead?

فقط با جمع-به‌علاوه-شمارش و هرگز میانگین تنها: combiner می‌تواند جمع و شمارش جزئی بدهد (انجمنی) و reducer آخر تقسیم می‌کند. combiner که میانگینِ میانگین‌ها بگیرد ذاتاً غلط است — به‌ازای هر تابع انجمنی بودن را قبل از اجازه combine اثبات کن.

Only with sum-plus-count, never a bare average: the combiner may emit partial sums and counts (associative), while the reducer divides at the end. A combiner that averages averages is wrong by construction — prove associativity for each function before allowing it to combine.

اشتباهات رایجCommon mistakes

  • اجرای دوباره کل job برای یک فایل گمشده. lineage دقیقاً mapper را نام می‌برد؛ اجرای محدود کل نکته splitهای immutable به‌علاوه انزوای attempt است.
  • استفاده دوباره از نام فایل بین attemptها طوری که دو اجرای یک mapper هر دو جاری به نظر برسند. id تازه به‌ازای هر retry؛ fetch دقیقاً یک نسل committed را می‌پذیرد.
  • side effect داخل mapper (نوشتن بیرونی، فراخوانی غیر-idempotent). بازپخش آن‌وقت دنیای بیرون را تکرار می‌کند — mapperها را خالص نگه دار وگرنه rerun تعریفاً ناامن است.
  • Rerunning the whole job for one lost file. Lineage names the exact mapper; scoped rerun is the whole point of immutable splits plus attempt isolation.
  • Reusing file names across attempts so two runs of one mapper both look current. Fresh attempt ids per retry; fetch accepts exactly one committed generation.
  • Side effects inside the mapper (external writes, non-idempotent calls). Replay then repeats the outside world — keep mappers pure or rerun is unsafe by definition.

منابع:Sources: Microsoft · technical interviewing · Atlassian · engineering interview handbook

44 تمرین ۴ — شبیه‌سازیِ بازتلاش و all-or-nothingExercise 4 — simulating retry and all-or-nothing

L3 · Staff / PrincipalL3 · Staff / Principalوابسته به نقشRole-specific40 دقیقه بحث40 min discussion

بازگشت به صورت تمرینBack to the exercise · ارزیابی و سطحAssessment and level

سؤال مصاحبهInterview question
خروجی attempt نسخه‌دار و manifest job را برای job چندtaskی روی ورودی immutable طراحی کن. کرش وسط نوشتن و قبل از انتشار تزریق کن. خواننده باید یک نسخه کامل pinشده ببیند؛ pointer اتمی یا CAS متناسب با storage با پاک‌سازی و retry امن انتخاب کن.
Design versioned attempt outputs and a job manifest for a multi-task job on immutable input. Inject mid-write and pre-publication crashes. Readers must see one complete pinned version; choose a storage-appropriate atomic pointer or CAS with safe cleanup and retry.

پاسخ ۶۰ثانیه‌ایThe 60-second answer

هر task را در فایل attempt checksummed خودش بنویس، با حذف ناقص‌ها و اجرای دوباره با id تازه retry کن، پشت barrier منتظر همه taskها بمان و بعد با یک تعویض اتمی pointer منتشر کن — manifest نسخه‌داری که خواننده‌ها pin می‌کنند. اتم را با storage جور کن: محلی rename محلی و روی object store نوشتن شرطی یا pointer نسخه‌دار. تریدآف اصلی فضا در برابر ایمنی است: نسخه‌های قدیم تا garbage collection بایت می‌خورند و آن اجاره خواننده‌هایی می‌خرد که هرگز نصف job را نمی‌بینند.
Write each task to its own checksummed attempt file, retry by deleting partials and rerunning under fresh ids, wait for every task at a barrier, then publish with one atomic pointer swap — a versioned manifest readers pin. Match the atom to the storage: local rename locally, conditional write or versioned pointer on object stores. The top trade-off is storage versus safety: old versions cost bytes until garbage collection, and that rent buys readers that never see half a job.

صورت مسئله و فرض‌هاProblem and assumptions

  • job آموزشی: ۴ task و ۵۰۰ سطر از shardهای immutable؛ killer با seed با احتمال ۰٫۵ به‌ازای هر attempt وسط نوشتن می‌میرد — خرابی‌ها تزریق می‌شوند نه آرزو.
  • فایل‌سیستم موقت محلی که تعویض pointer جانشین گام اتمی است؛ اتم object store (نوشتن شرطی) در متن طراحی شده با هزینه متفاوتش.
  • خواننده‌ها pointer را یک‌بار به‌ازای هر نشست خواندن حل و همان manifest را pin می‌کنند؛ انتشار تک‌نویسنده مگر قاعده CAS پیگیری بیاید.
  • Teaching job: 4 tasks × 500 rows from immutable shards; a seeded killer dies mid-write with probability 0.5 per task attempt — failures are injected, not hoped for.
  • Local temp filesystem where pointer swap stands in for the atomic step; the object-store atom (conditional write) is designed in text, with its different cost.
  • Readers resolve the pointer once per read session and pin that manifest; single-writer publishing unless the CAS follow-up applies.

چرا این روش و این عددها؟Why this method and these numbers?

فایل نیمه‌نوشته را منتشر نمی‌کنیم چون خواننده ممکن است خروجی ناقص را کامل تصور کند. هر attempt خروجی immutable دارد و manifest فقط attempt برندهٔ همهٔ taskها را معرفی می‌کند؛ تعویض pointer لحظهٔ رؤیت است. احتمال crash برابر ۰٫۵ برای تزریق پرتکرار خرابی است، نه نرخ خرابی واقعی. rename اتمیک فرض همان filesystem است؛ روی object store باید شرط نسخه/CAS همان سرویس بررسی شود و نمی‌توان از نام مشابه نتیجه گرفت.

Do not expose partial files as complete output. Each attempt writes immutable output; a manifest selects one winning attempt per task, and switching the pointer changes visibility. A 0.5 crash probability injects frequent failures, not a real failure rate. Atomic rename assumes one supporting filesystem; object storage needs its own documented conditional-write/CAS semantics.

مدل ذهنیMental model

commit یعنی انتشار نیست: taskها نامرئی تمام می‌شوند مثل رأی‌ها در صندوق مهرشده و یک چرخش pointer صندوق را باز می‌کند. همه‌چیز قبل از چرخش قابل retry و تمیزکاری است؛ همه‌چیز بعدش باید از قبل کامل باشد.
Commit is not publish: tasks finish invisibly like votes in a sealed box, and one pointer flip opens the box. Everything before the flip can be retried and cleaned; everything after it must already be whole.

حل گام‌به‌گامStep by step

  1. خروجی attempt نامرئی shardهای ورودی immutable را منجمد کن و بگذار هر task دقیقاً یک فایل attempt checksummed به‌ازای هر attempt id بنویسد — attemptها ذاتاً نامرئی‌اند.
  2. حذف ناقص و id تازه روی کرش اول فایل ناقص را حذف کن، بعد همان ورودی را با id تازه دوباره اجرا کن؛ فقط با checksum commit کن.
  3. barrier قبل از انتشار هر انتشار را پشت barrier نگه دار که taskهای committed را می‌شمارد: all-or-nothing در کد اعمال می‌شود نه در نثر وعده داده.
  4. یک تعویض اتمی pointer یک pointer را اتمی عوض کن — rename محلی روی فایل‌سیستم و نوشتن شرطی روی object store — تا خواننده‌ها فقط بین نسخه‌های کامل جابه‌جا شوند.
  5. pin و سرو و بازنشستگی خواننده‌ها به‌ازای هر نشست یک manifest را pin می‌کنند؛ نسخه‌های قدیم grace خود را سرو می‌دهند و بعد با شمارش pin جمع می‌شوند.
  1. Invisible attempt outputs Freeze immutable input shards and let each task write exactly one checksummed attempt file per attempt id — attempts are invisible by construction.
  2. Delete partial, fresh id On crash, delete the partial file first, then rerun the same input under a fresh id; commit only on checksum.
  3. Barrier before publish Hold every publish behind a barrier that counts committed tasks: all-or-nothing is enforced in code, not promised in prose.
  4. One atomic pointer swap Swap one pointer atomically — local rename on filesystems, conditional write on object stores — so readers move between complete versions only.
  5. Pin, serve, retire Readers pin one manifest per session; old versions serve out their grace, then garbage-collect with pin counts.

طراحی مشخصConcrete design

attemptها: task-{t}-attempt-{n}.txt با checksum؛ retry اول ناقص را حذف و n+1 را اجرا می‌کند؛ fetch و خواننده‌ها هرگز نام attempt نمی‌بینند، فقط manifest.

manifest: manifest-v3.txt دقیقاً فایل‌های attemptِ committed به‌علاوه نسخه را فهرست می‌کند؛ CURRENT یک نام manifest نگه می‌دارد و اتمی جابه‌جا می‌شود.

اتم انتشار به‌ازای هر storage: فایل‌سیستم محلی — rename اتمی CURRENT؛ object store — نوشتن شرطی (CAS) روی کلید pointer و بازنده کل انتشار را retry می‌کند؛ ناشرهای هم‌زمان با دقیقاً یک برنده CAS فیصله می‌یابند.

پاک‌سازی: attemptهای ناقص موقع retry حذف می‌شوند؛ نسخه‌های پشت‌سرگذاشته‌شده دوره grace برای خواننده‌های pinشده می‌مانند و بعد garbage-collect می‌شوند.

Attempts: task-{t}-attempt-{n}.txt with checksums; a retry deletes the partial first and runs n+1; fetch and readers never see attempt names, only manifests.

Manifest: manifest-v3.txt lists exactly the committed attempt files plus a version; CURRENT holds one manifest name and moves atomically.

Publish atom per storage: local filesystem — atomic rename of CURRENT; object store — conditional write (CAS) on the pointer key, loser retries the whole publish; concurrent publishers resolved by exactly one CAS winner.

Cleanup: partial attempts deleted on retry; superseded versions kept through a grace period for pinned readers, then garbage-collected.

مثال محاسبه‌شدهWorked example

عددهای دمو (آموزشی، همین محیط): ۴ task و ۵۰۰ سطر با احتمال کشتن ۰٫۵ به‌ازای هر attempt. attempt به‌ازای هر task [2, 0, 1, 0]؛ manifest-v1.txt با pointer CURRENT منتشر شد و خواننده ۲٬۰۰۰ از ۲٬۰۰۰ سطر را با all_or_nothing=true دید.

تفسیر: خرابی‌ها رخ دادند (سه retry) ولی خواننده دقیقاً یک نسخه کامل می‌بیند — retryها طبق طراحی نامرئی‌اند.

Demo numbers (teaching, this environment): 4 tasks × 500 rows with kill probability 0.5 per attempt. Attempts per task [2, 0, 1, 0]; manifest-v1.txt published via pointer CURRENT; reader sees 2,000 of 2,000 rows with all_or_nothing=true.

Interpretation: failures happened (three retries) yet the reader observes exactly one complete version — retries are invisible by design.

گام 1 از 3 · خط فکریStep 1 of 3 · reasoning thread · یک attemptِ task: کرش، حذف ناقص، id تازه و commitOne task attempt: crash, delete partial, fresh id, commit · توالیsequenceباز کردن در صفحه جدا ↗Open standalone ↗

دانلود نمودار مستقلDownload standalone diagram

روایت مدرس / چطور این نمودار را بخوانیمTeacher narration / how to read this diagram
task شماره ۲ وسط نوشتن می‌میرد، تمیز retry می‌کند و commit می‌شود؛ manifest فقط بعد از همه taskها منتشر می‌شود و خواننده pin می‌کند و می‌خواند.
Task 2 dies mid-write, retries cleanly, commits; the manifest publishes only after all tasks; a reader pins and reads.
  1. attempt به‌علاوه کرش: بایت‌های ناقص می‌نشینند، killer گزارش می‌دهد و هیچ‌چیز منتشر نمی‌شود.
  2. retry تمیز: اول ناقص حذف می‌شود، id تازه همان ورودی را دوباره اجرا می‌کند و checksum commit را تأیید می‌کند.
  3. انتشار به‌علاوه pin: یک manifest بعد از همه taskها، یک خواندن pointer و کل نتیجه.
  1. Attempt plus crash: partial bytes land, the killer reports, nothing is published.
  2. Clean retry: the partial is deleted first, a fresh id reruns the same input, checksum verifies the commit.
  3. Publish plus pin: one manifest after all tasks, one pointer read, the whole result.

نقطهٔ تصمیم: نقطه تصمیم پیام حذف-قبل-از-retry است: ناقص‌ها همیشه قبل از نوشتن هر بایت تازه می‌میرند.

رد خرابی: الصاق به فایل مرده به‌جای حذفش دو نسل سطر را درهم می‌کند: checksum حتی ممکن است روی زباله پاس شود. حذف امنیتی‌رنگ همان گامی است که مصاحبه‌گرها می‌پرسند — قبل از آن‌ها نامش ببر.

تضمین نشان‌داده‌شده: شکل کرش، retry تمیز، انتشار پشت-barrier و خواندن pinشده را در یک ردیابی نشان می‌دهد. یک task را نشان می‌دهد؛ barrier روی همه taskها در اسمبلی است.

تضمین نشان‌نداده‌شده: ناشرهای هم‌زمان و اتم‌های object store بیرون این ردیابی‌اند — چرخه‌حالت و ران‌بوک آن‌ها را می‌گیرند.

Decision point: The decision point is the delete-before-retry message: partials die before any new byte is written, always.

Failure trace: Appending to the corpse file instead of deleting it interleaves two generations of rows: the checksum may even pass on garbage. The security-colored delete is the step interviewers ask about — name it before they do.

Guarantee shown: The figure shows crash, clean retry, barrier-gated publish and pinned reading in one trace. It shows one task; the barrier over all tasks lives in the assembly.

Not shown: Concurrent publishers and object-store atoms are outside this trace — the lifecycle and runbook take them.

خودآزماییSelf-check

بین گزارش کرش و اجرای دوباره چه چیزی باید رخ دهد؟

پاسخ: ناقص را حذف کن، با id تازه دوباره اجرا کن، با checksum commit کن، بعد از همه taskها منتشر کن و pinشده بخوان.

What must happen between the crash report and the rerun?

Answer: Delete the partial, rerun with a fresh id, commit on checksum, publish after all tasks, read pinned.

گام 2 از 3 · خط فکریStep 2 of 3 · reasoning thread · حالت‌های خروجی: committed تا تعویض pointer نامرئی استOutput states: committed is invisible until the pointer swap · چرخه‌حالتlifecycleباز کردن در صفحه جدا ↗Open standalone ↗

دانلود نمودار مستقلDownload standalone diagram

روایت مدرس / چطور این نمودار را بخوانیمTeacher narration / how to read this diagram
خروجی یک task لاین اصلی را می‌رود؛ کرش‌ها به retry می‌روند و job با published یا aborted یا retired تمام می‌شود.
One task output travels the main lane; crashes detour to retry; the job ends published, aborted, or retired.
  1. از attempt به committed: بایت به‌علاوه checksum، تمام‌شده ولی پنهان — دیباگ می‌تواند نگاه کند و سرو نه.
  2. از committed به staged به published: همه taskها تمام شدند و بعد یک تعویض اتمی pointer آن‌ها را با هم مرئی می‌کند.
  3. retry روی کرش با id تازه؛ aborted خروجی staged را تمیز می‌کند؛ retired نسخه‌های قدیم را بعد از grace جمع می‌کند.
  1. Attempt to committed: bytes plus checksum, finished but hidden — debugging may look, serving may not.
  2. Committed to staged to published: all tasks done, then one atomic pointer swap makes them visible together.
  3. Retrying on crash with fresh ids; aborted cleans staged output; retired GCs old versions after grace.

نقطهٔ تصمیم: نقطه تصمیم حالت staged است: همه‌چیز قبلش ذاتاً نامرئی است و همه‌چیز بعدش یک تعویض اتمی واحد.

رد خرابی: انتشار از staged قبل از commit همه taskها نصف job را به خواننده‌ها نشان می‌دهد: گذار barrier نگهبان است و رد کردنش نقض کلاسیک all-or-nothing است. GC زودهنگام نسخه قدیم هم خواننده‌های pinشده را همان‌طور می‌شکند.

تضمین نشان‌داده‌شده: شکل سه حالت مرئی-یا-نه را که خروجی هر task می‌گذرد جدا می‌کند با retry و دو خروج terminal. اتم را نام می‌برد ولی پیاده‌سازی per-storage آن در ران‌بوک است.

تضمین نشان‌نداده‌شده: نرخ رقابت CAS و تنظیم دوره grace عددهای عملیاتی‌اند نه حالت.

Decision point: The decision point is the staged state: everything before it is invisible by construction, everything after it is one atomic swap.

Failure trace: Publishing from staged before every task commits shows readers half a job: the barrier transition is the guard, and skipping it is the classic all-or-nothing violation. Early GC of the old version breaks pinned readers the same way.

Guarantee shown: The figure separates the three visible-or-not states every task output passes through, with retry and the two terminal exits. It names the atom but implements it per storage in the runbook.

Not shown: CAS contention rates and grace-period tuning are operational numbers, not states.

خودآزماییSelf-check

کدام حالت تمام‌شده ولی نامرئی است و چه چیزی مرئیش می‌کند؟

پاسخ: committed تمام‌شده-ولی-پنهان است؛ staged منتظر همه taskهاست؛ published یک تعویض اتمی است و نسخه‌های قدیم بعد از grace بازنشسته می‌شوند.

Which state is finished yet invisible, and what makes it visible?

Answer: Committed is finished-but-hidden; staged waits for all tasks; published is one atomic swap; old versions retire after grace.

نمودار اسمبل نهاییFinal assembly diagram · ران‌بوک انتشار all-or-nothing: barrier، تعویض اتمی و خواندن pinشدهAll-or-nothing publish runbook: barrier, atomic swap, pinned reads · گردش‌کارworkflowباز کردن در صفحه جدا ↗Open standalone ↗

دانلود نمودار مستقلDownload standalone diagram

روایت مدرس / چطور این نمودار را بخوانیمTeacher narration / how to read this diagram
از shardهای منجمد شروع کن، attemptها را تا commit همه حلقه بزن، یک‌بار از barrier بگذر، pointer را عوض کن و به خواننده‌های pinشده سرو بده.
Start at frozen shards, loop attempts until all commit, cross the barrier once, swap the pointer, serve pinned readers.
  1. اجرا: shardهای منجمد به فایل‌های attempt checksummed و شکست‌های checksum به retry با id تازه برمی‌گردند.
  2. انتشار: barrier فقط مجموعه task کامل را می‌پذیرد و بعد یک تعویض اتمی جور با storage، CURRENT را جابه‌جا می‌کند.
  3. سرو و بازنشستگی: خواننده‌های pinشده یک نسخه را کامل می‌بینند؛ بازنده‌های مسابقه CAS retry می‌کنند و نسخه‌های قدیم بعد از grace جمع می‌شوند.
  1. Execute: frozen shards into checksummed attempt files, checksum failures back to a fresh-id retry.
  2. Publish: the barrier admits only complete task sets, then one storage-matched atomic swap moves CURRENT.
  3. Serve and retire: pinned readers see one version whole; losers of the CAS race retry; old versions GC after grace.

نقطهٔ تصمیم: نقطه‌های تصمیم barrier (هیچ‌چیز زود منتشر نمی‌شود) و تعویض pointer (دقیقاً یک اتم جور با store) هستند.

رد خرابی: ران‌بوک را از خواندن پاره به عقب بخوان: manifest pinنشده یعنی خواننده pointer را دو بار حل کرده؛ نصف job یعنی barrier رد شده؛ update گمشده بین ناشرها یعنی last-writer-wins بدون CAS. هر علامت جعبه گمشده خودش را نام می‌برد.

تضمین نشان‌داده‌شده: ران‌بوک هر دو گام را در یک صفحه قابل‌اجرا ترکیب می‌کند: همان attemptها و همان حالت‌ها به‌علاوه اتم جور با storage و قاعده بازنده-retry-می‌کند. دمو حالت محلی را اثبات می‌کند؛ object storeها از طراحی CAS پیروی می‌کنند.

تضمین نشان‌نداده‌شده: timeoutهای CAS، دوره‌های grace یا صورت‌حساب storage را فیکس نمی‌کند — آن‌ها per-deployment تنظیم می‌شوند.

Decision point: The decision points are the barrier (nothing publishes early) and the pointer swap (exactly one atom, matched to the store).

Failure trace: Read the runbook backwards from a torn read: no pinned manifest means the reader resolved the pointer twice; half a job means the barrier was skipped; a lost update between publishers means last-writer-wins without CAS. Each symptom names its missing box.

Guarantee shown: The runbook composes both steps into one operable page: same attempts, same states, plus the storage-matched atom and the loser-retries rule. The demo proves the local case; object stores follow the CAS design.

Not shown: It fixes no CAS timeouts, grace periods, or storage bills — those are tuned per deployment.

خودآزماییSelf-check

دو دروازه بین بایت‌های task و چشم خواننده چیست؟

پاسخ: همه taskها پشت barrier، یک تعویض اتمی جور با storage، خواندن pinشده، retry بازنده CAS و grace-بعد-GC.

What are the two gates between task bytes and reader eyes?

Answer: Barrier all tasks, one atomic storage-matched swap, pinned reads, CAS loser retries, grace-then-GC.

گزینه‌های جایگزینAlternatives

  • بازنویسی درجا روی خروجی زنده: بدون نسخه قدیم، بدون سفر در زمان و خواننده‌های وسط job مخلوط سطرهای قدیم و جدید را می‌بینند. فضای ارزان‌تر و همه‌چیز دیگر ضعیف‌تر — فقط وقتی قابل‌قبول است که حین job کسی نخواند و تاریخچه هرگز مهم نباشد.
  • دایرکتوری staging به‌علاوه rename روی فایل‌سیستم محلی: taskها در staging نسخه‌دار می‌نشینند و یک rename اتمی منتشرش می‌کند. محلی ساده و درست است — ولی rename همان اتم است و آن اتم به object storage منتقل نمی‌شود.
  • In-place overwrite of the live output: no old version, no time travel, and readers mid-job see a mix of old and new rows. Cheaper storage, weaker everything else — acceptable only when nobody reads during the job and history never matters.
  • Staging directory plus rename on a local filesystem: tasks land in a versioned staging dir, one atomic rename publishes it. Simple and correct locally — but the rename is the atom, and that atom does not transfer to object storage.

سناریوهای خرابیFailure scenarios

  • نگه‌داشتن ناقص و الصاق موقع retry: دو نسل درهم می‌شوند و checksum ممکن است روی زباله پاس شود. نگهبان حذف قبل از نوشتن است — خط‌های کامنت دمو جنازه و حذفش را نشان می‌دهند.
  • انتشار قبل از barrier: خواننده‌ها نصف job را می‌بینند و پایین‌دست کش می‌کنند. نگهبان barrier به‌عنوان کد است (taskهای committed را بشمار و انتشار زود را رد کن) نه عرف.
  • خواننده پاره: حل دو باره CURRENT در یک نشست می‌تواند روی تعویض سوار شود. نگهبان pin کردن یک‌بار به‌ازای هر نشست و خواندن فقط manifest pinشده است.
  • فرض rename محلی روی object storage: rename ممکن است copy-plus-delete باشد — مرئی، کُند و غیراتمی. نگهبان انتخاب اتم خود store (نوشتن شرطی، pointer نسخه‌دار) و تست صریح خواندن پاره است.
  • Keeping the partial and appending on retry: two generations interleave and checksums may pass on garbage. Guard by deleting before writing — the demo's commented lines show the corpse and its deletion.
  • Publishing before the barrier: readers see half the job and cache it downstream. Guard with the barrier as code (count committed tasks, refuse early publish), not as convention.
  • Torn reader: resolving CURRENT twice in one session can straddle a swap. Guard by pinning once per session and reading only the pinned manifest.
  • Local rename assumed on object storage: renames may be copy-plus-delete — visible, slow, non-atomic. Guard by choosing the store's own atom (conditional write, versioned pointer) and testing torn reads explicitly.

کد قابل اجراRunnable code

اجراشده و تأییدشده در همین محیطExecuted and verified in this environment

این کد آموزشی و ساده‌سازی‌شده است؛ فقط برای آشنایی با ایده. کد واقعی پروداکشن ملاحظات بسیار بیشتری دارد و هیچ‌کس این‌شکلی کد پروداکشن نمی‌زند.Simplified teaching code for building intuition only — not production code; nobody ships production code like this.

import java.util.*;

// Teaching demo: retry + all-or-nothing publication. A job of T tasks turns
// immutable input shards into output shards. A killer dies mid-write with
// probability P (seeded); retry DELETES the partial attempt output and reruns
// with a fresh attempt id. The manifest (versioned output pointer) is written
// only after EVERY task succeeds, so a reader pinning one manifest sees the
// whole result or nothing — never a mix of versions.
public class RetryPublishDemo {
    static final int TASKS = 4, ROWS = 500;
    static final double KILL_P = 0.5;

    public static void main(String[] a) throws Exception {
        Random killer = new Random(14);
        java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("job");
        int[] attempts = new int[TASKS];
        boolean done = false;
        while (!done) {
            done = true;
            for (int t = 0; t < TASKS; t++) {
                java.nio.file.Path out = dir.resolve("task-" + t + "-attempt-" + attempts[t] + ".txt");
                List<String> rows = new ArrayList<>(ROWS);
                boolean died = false;
                for (int r = 0; r < ROWS; r++) {
                    rows.add("shard" + t + ":row" + r);
                    if (killer.nextDouble() < KILL_P / ROWS) { died = true; break; }
                }
                if (died) {
                    java.nio.file.Files.write(out, rows); // partial file left behind
                    java.nio.file.Files.delete(out);      // retry deletes it first
                    attempts[t]++;
                    done = false;                         // whole job waits: no publish yet
                } else if (java.nio.file.Files.notExists(out)) {
                    java.nio.file.Files.write(out, rows); // committed attempt output
                }
            }
        }
        // All-or-nothing publish: one manifest naming the committed attempts.
        var manifest = new ArrayList<String>();
        manifest.add("version=v1 tasks=" + TASKS);
        for (int t = 0; t < TASKS; t++)
            manifest.add("task-" + t + "-attempt-" + attempts[t] + ".txt");
        java.nio.file.Path ptr = dir.resolve("CURRENT");
        java.nio.file.Files.write(dir.resolve("manifest-v1.txt"), manifest);
        java.nio.file.Files.writeString(ptr, "manifest-v1.txt"); // atomic pointer swap
        // Reader pins ONE manifest and counts only its files.
        String pinned = java.nio.file.Files.readString(ptr).trim();
        long visible = 0;
        for (String line : java.nio.file.Files.readAllLines(dir.resolve(pinned)))
            if (line.endsWith(".txt"))
                visible += java.nio.file.Files.readAllLines(dir.resolve(line)).size();
        System.out.println("attempts_per_task=" + Arrays.toString(attempts));
        System.out.println("published " + pinned + " via atomic pointer " + ptr.getFileName());
        System.out.println("reader sees rows=" + visible + " expected=" + (TASKS * ROWS)
            + " all_or_nothing=" + (visible == TASKS * ROWS));
        System.out.println("OK");
    }
}

اجرا: javac RetryPublishDemo.java && java RetryPublishDemo — فقط استاندارد جاوا.

خروجی مورد انتظار: attempts_per_task=[2, 0, 1, 0]؛ انتشار manifest-v1.txt با pointer اتمی CURRENT؛ خواننده rows=2000 می‌بیند برابر expected=2000 با all_or_nothing=true؛ OK. (خروجی واقعی همین محیط)

Run: javac RetryPublishDemo.java && java RetryPublishDemo — stdlib Java only.

Expected output: attempts_per_task=[2, 0, 1, 0]; published manifest-v1.txt via atomic pointer CURRENT; reader sees rows=2000 expected=2000 all_or_nothing=true; OK. (actual output of this environment)

آزمون صحت و معیار عملیاتیCorrectness checks and operational criteria

هر اجرا taskها را وسط نوشتن بکش و assert کن شمارش سطر خواننده all-or-nothing است (صفر یا همه سطرها، هرگز ناقص)؛ assert کن هیچ فایل ناقصی از retryها نماند؛ assert کن manifest دقیقاً attemptهای committed را نام می‌برد؛ دو ناشر را مسابقه بده و assert کن دقیقاً یک برنده CAS با retry بازنده داری؛ تست خواندن پاره با حل-بعد-خواندن روی تعویض.
Kill tasks mid-write on every run and assert reader row counts are all-or-nothing (0 or all rows, never partial); assert no partial files survive retries; assert the manifest names exactly the committed attempts; race two publishers and assert exactly one CAS winner with the loser retrying; resolve-then-read torn-read tests across a swap.

پاسخ سؤال‌های پیگیریFollow-up answers

پیگیری (استف): دو job هم‌زمان روی یک نام خروجی publish کنند؛ کدام نسخه برنده می‌شود؟Follow-up (Staff): Two jobs concurrently publish to the same output name. Which version wins?

دقیقاً یک برنده CAS: هر دو job نوشتن‌های شرطی روی کلید pointer با نسخه manifest خودشان مسابقه می‌دهند؛ store مرتبشان می‌کند، یکی موفق می‌شود و بازنده نسخه برنده را می‌بیند، در برابرش دوباره اعتبارسنجی (یا دوباره اجرا) می‌کند و انتشار را retry می‌کند — هرگز کورکورانه بازنویسی نه. خواننده‌ها تمام مدت دست‌نخورده‌اند چون هر manifest قدیم و جدید کامل می‌ماند و خواندن‌های pinشده هرگز سوار نمی‌شوند.

Exactly one CAS winner: both jobs race conditional writes on the pointer key with their manifest version; the store orders them, one succeeds, the loser observes the winner's version, re-validates (or re-runs) against it, and retries the publish — never overwrites blindly. Readers are undisturbed throughout because every manifest, old and new, stays complete and pinned reads never straddle.

پیگیری (سینیور): object store هیچ rename اتمی ندارد. اتم انتشار چیست؟Follow-up (Senior): The object store has no atomic rename. What is the publish atom?

از pointer نسخه‌دار به‌علاوه CAS استفاده کن: manifest تازه را زیر کلید نسخه‌دار تازه بنویس و بعد pointer را شرطی از نسخه قدیم موردانتظار به تازه بچرخان. اگر store اصلاً CAS ندارد، نویسنده را به یک ناشر تکی محدود کن (lease یا صف) و بگو — اتم ادعایی که نداری بدتر از گلوگاه مستند است.

Use a versioned pointer plus CAS: write the new manifest under a fresh versioned key, then conditionally flip the pointer from the expected old version to the new one. If the store offers no CAS at all, narrow the writer to a single publisher (lease or queue) and say so — a claimed atom you do not have is worse than a documented bottleneck.

اشتباهات رایجCommon mistakes

  • تعمیم اتمی بودن rename محلی به object storage. rename روی POSIX اتمی است؛ «rename» روی object store اغلب copy به‌علاوه delete است. به‌جای فرض سفر اتم آشنا، اتم خود store را انتخاب و تست کن.
  • خلط commit task با انتشار job. attemptِ committed تمام‌شده ولی پنهان است؛ فقط barrier به‌علاوه تعویض pointer چیزی را مرئی می‌کند. دیباگ attemptها را می‌خواند و سرو manifestها را.
  • جمع کردن نسخه‌هایی که خواننده‌های pinشده هنوز نگه داشته‌اند. نسخه قدیم تا پایان grace قرارداد زنده است — بعد از grace با شمارش pin جمع کن، هرگز فقط با سن نه.
  • Generalizing local rename atomicity to object storage. A POSIX rename is atomic; an object-store 'rename' is often copy plus delete. Choose and test the store's own atom instead of assuming the familiar one travels.
  • Confusing task commit with job publication. A committed attempt is finished but hidden; only the barrier plus the pointer swap makes anything visible. Debugging reads attempts, serving reads manifests.
  • Garbage-collecting versions that pinned readers still hold. The old version is a live contract until its grace expires — GC after grace, with pin counts, never by age alone.

منابع:Sources: Atlassian · engineering interview handbook · Jepsen · consistency models