Lesson 10 / 19
Aggregation Pipeline
Staged transformation built in its own implementation, and the same question answered under three different stage orders: records processed rising from 43,610 to 124,606, a blocking stage's records held in memory rising from 2,418 to 40,276, and the result staying the same across all three orders.
Contents
The previous lesson built the evaluator that says whether a document satisfies a condition. Most of the library’s questions do not end there: “how many copies of each author’s books are under repair,” “the five most-borrowed books,” “the number of shelved copies per branch.” What these questions have in common is that the answer sits not in individual documents but in the aggregate of documents, and a condition expression cannot aggregate.
The document model does this work with an aggregation pipeline: a chain of ordered stages. Each stage takes a stream of records, transforms it, and hands it to the next. The word is the same one used for the pipeline that chains shell commands together, and the resemblance is real; where they part is the unit of the stream — what flows through a shell is bytes, what flows through here is records, and the stages operate on records. This lesson’s question is not what the stages do, but what their order costs.
Streaming Stage and Blocking Stage
Stages split into two classes. A streaming stage takes a record, processes it, releases it: filtering, unwinding an array, and projection all work this way, holding a single record in memory at a time. A blocking stage must see the entire stream to produce its result: sorting holds the whole stream in memory, grouping holds the group table. A pipeline’s memory cost is the number of records its blocking stages hold at any one time.
// pipeline.mjs — staged transformation. Each stage takes a stream of records, transforms // it, and hands it to the next. The records in/out per stage and the records that stage // holds in memory are counted. export function run(source, stages) { let stream = source, peak = 0, processed = 0; const report = []; for (const stage of stages) { const name = Object.keys(stage)[0], recordsIn = stream.length; let memory = 1; // streaming stage: holds a single record if (name === "filter") stream = stream.filter(stage.filter); else if (name === "unwind") { // opens an array into its elements const field = stage.unwind; stream = stream.flatMap((b) => (b[field] ?? []).map((o) => ({ ...b, [field]: o }))); } else if (name === "group") { const table = new Map(); for (const b of stream) { const key = stage.group.key(b); table.set(key, stage.group.combine(table.get(key) ?? stage.group.empty(key), b)); } stream = [...table.values()]; memory = table.size; // blocking: the group table sits in memory } else if (name === "sort") { memory = recordsIn; // blocking: the entire stream sits in memory stream = [...stream].sort(stage.sort); } else if (name === "limit") stream = stream.slice(0, stage.limit); else if (name === "project") stream = stream.map(stage.project); processed += recordsIn; peak = Math.max(peak, memory); report.push({ name, recordsIn, recordsOut: stream.length, memory }); } return { result: stream, report, processed, peak }; } export function print(label, stats) { console.log(`${label}: records processed ${stats.processed}, peak memory ${stats.peak} records`); for (const r of stats.report) console.log(` ${r.name.padEnd(8)} in ${String(r.recordsIn).padStart(6)}` + ` -> out ${String(r.recordsOut).padStart(6)} memory ${r.memory}`); }
The unwind stage is specific to the document model, and it is the pipeline’s most expensive stage: a book document carrying a copy array turns into as many records as the array has elements. 20,000 book documents, once unwound, become 59,494 records. Every stage after this one processes about three times as many records.
Same Question, Three Orders
NS7 (assumption): the catalog is 20,000 book documents, each book has 1–5 copies and 2–4 tags, and the seed is 424242. The question is fixed: the five authors ranked highest by number of copies under repair at the Kadikoy branch. Three pipelines answer this question. A puts the filter at the very start; B unwinds the array first and filters afterward; C groups without filtering at all, and filters over the groups. All three must give the correct answer — what is measured is not correctness, but cost.
// order-measurement.mjs — the same question is answered with three different stage // orders. The result is the same, records processed and peak memory are not. // pipeline.mjs is in the same directory. import { run, print } from "./pipeline.mjs"; let seed = 424242; // visible seed const random = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648; const BRANCH = ["Central", "Bahcelievler", "Kadikoy", "Beyoglu", "Konak", "Nilufer"]; const STATUS = ["shelved", "checked_out", "in_repair"]; const TAGS = ["fiction", "history", "children", "poetry", "science", "reference"]; const CATALOG = []; for (let i = 1; i <= 20000; i += 1) { const copy = [], tag = []; for (let j = 0, n = 1 + Math.floor(random() * 5); j < n; j += 1) copy.push({ barcode: `B${String(i * 10 + j).padStart(7, "0")}`, branch: BRANCH[Math.floor(random() * 6)], status: STATUS[Math.floor(random() * 3)] }); for (let j = 0, n = 2 + Math.floor(random() * 3); j < n; j += 1) tag.push(TAGS[Math.floor(random() * 6)]); CATALOG.push({ _k: `K-${String(i).padStart(5, "0")}`, author: `Author ${i % 4000}`, publication_year: 1950 + (i % 75), tag, copy }); } console.log(`catalog ${CATALOG.length} books, ` + `${CATALOG.reduce((t, b) => t + b.copy.length, 0)} copies`); // Question: top 5 authors by number of copies under repair at the Kadikoy branch. const REPAIR = (k) => k.branch === "Kadikoy" && k.status === "in_repair"; const GROUP = { key: (b) => b.author, empty: (k) => ({ author: k, count: 0 }), combine: (g, b) => ({ ...g, count: g.count + 1 }) }; const SORT = (a, b) => b.count - a.count || (a.author < b.author ? -1 : 1); const A = run(CATALOG, [ { filter: (b) => b.copy.some(REPAIR) }, // filter first: at the book level { unwind: "copy" }, { filter: (b) => REPAIR(b.copy) }, // filter the unwound element { group: GROUP }, { sort: SORT }, { limit: 5 }, ]); const B = run(CATALOG, [ { unwind: "copy" }, { filter: (b) => REPAIR(b.copy) }, { group: GROUP }, { sort: SORT }, { limit: 5 }, ]); const C = run(CATALOG, [ { unwind: "copy" }, { group: { key: (b) => `${b.author}|${b.copy.branch}|${b.copy.status}`, empty: (k) => ({ author: k.split("|")[0], branch: k.split("|")[1], status: k.split("|")[2], count: 0 }), combine: (g, b) => ({ ...g, count: g.count + 1 }) } }, { filter: (g) => g.branch === "Kadikoy" && g.status === "in_repair" }, { sort: SORT }, { limit: 5 }, ]); print("A filter first", A); print("B unwind then filter", B); print("C group first then filter", C); const summary = (r) => r.result.map((g) => `${g.author}=${g.count}`).join(" "); console.log(`A result: ${summary(A)}`); console.log(`all three the same: ${summary(A) === summary(B) && summary(B) === summary(C)}`); console.log(`records processed A ${A.processed} B ${B.processed} C ${C.processed}` + ` B/A ${(B.processed / A.processed).toFixed(2)} C/A ${(C.processed / A.processed).toFixed(2)}`); console.log(`peak memory A ${A.peak} B ${B.peak} C ${C.peak}` + ` C/A ${(C.peak / A.peak).toFixed(1)}x`);
catalog 20000 books, 59494 copies A filter first: records processed 43610, peak memory 2418 records filter in 20000 -> out 3340 memory 1 unwind in 3340 -> out 11926 memory 1 filter in 11926 -> out 3508 memory 1 group in 3508 -> out 2418 memory 2418 sort in 2418 -> out 2418 memory 2418 limit in 2418 -> out 5 memory 1 B unwind then filter: records processed 87838, peak memory 2418 records unwind in 20000 -> out 59494 memory 1 filter in 59494 -> out 3508 memory 1 group in 3508 -> out 2418 memory 2418 sort in 2418 -> out 2418 memory 2418 limit in 2418 -> out 5 memory 1 C group first then filter: records processed 124606, peak memory 40276 records unwind in 20000 -> out 59494 memory 1 group in 59494 -> out 40276 memory 40276 filter in 40276 -> out 2418 memory 1 sort in 2418 -> out 2418 memory 2418 limit in 2418 -> out 5 memory 1 A result: Author 1068=4 Author 111=4 Author 1530=4 Author 1580=4 Author 1618=4 all three the same: true records processed A 43610 B 87838 C 124606 B/A 2.01 C/A 2.86 peak memory A 2418 B 2418 C 40276 C/A 16.7x
The Cost of the Order
The three pipelines return the same five authors with the same counts; the
all three the same line confirms it. Where they part is cost, and cost is measured with
two separate numbers.
Records processed is 43,610 in A, 87,838 in B, 124,606 in C. The difference between A and B comes only from the first filter: A feeds the unwind stage 3,340 of the 20,000 books, B feeds it all of them. The unwind stage produces 11,926 records in A, 59,494 in B — five times as many. Against that, A runs one extra stage, and its first filter looks at 20,000 documents. Overall the ratio is 2.01. The rule is this: a filter is placed not where it will narrow the stream the most, but before the stage that multiplies records; unwind is a multiplier.
A’s first filter is at the document level, not the element level, and it is the loose form the previous lesson measured: it returns 3,340 documents. Of the 11,926 records that unwinding then produces, only 3,508 pass the second filter. This looseness is not a mistake, it is a deliberate choice — a document-level filter does not miss the correct result, it only lets extra records through. This is the only condition on a filter placed early in a pipeline: it must be inclusive, it does not need to be narrowing.
The second number gives memory. Peak memory in A and B is 2,418 records — the size of the group table, that is, the number of distinct authors with a copy under repair at Kadikoy. In C the same number is 40,276, 16.7 times as many. C’s mistake is placing the grouping before the filter: the grouping now holds not just the authors of interest, but every combination of the author–branch–status triple. This number grows with the product of the group key as the collection grows, and a blocking stage exceeding its memory limit leads to the pipeline spilling to disk or being rejected outright.
The final limit stage sits in the same place in all three and reduces no work in any of
them: sorting has already seen all 2,418 records. Limiting does not shrink the work of the
blocking stage that precedes it — only a form of sorting that itself keeps just the top
five can do that, and that is a decision inside the sort stage, not in the pipeline’s
order.
Summary
- An aggregation pipeline is made of ordered stages; streaming stages hold a single record, blocking stages (grouping, sorting) hold the entire stream or the group table in memory.
- The unwind stage multiplies records: 20,000 book documents turn into 59,494 records.
- The same question gives the same answer under three orders, but processes 43,610, 87,838, and 124,606 records; moving the filter before the multiplying stage roughly halves the records processed.
- It is enough for an early filter to be inclusive; A’s document-level filter lets 3,340 documents through, and of the 11,926 records that unwinding them produces, only 3,508 remain in the result.
- Placing grouping before filtering raises peak memory from 2,418 records to 40,276 (16.7×).
Next Step
One thing the three pipelines have in common went unnoticed: all three read the entire collection, 20,000 documents, in their first stage. The stage order changed the records processed and the memory held, not the number of documents read. The only thing that changes that number is the index. In the document model, the index is not structurally different from the index measured in the relational course — but the data it is built over is different: an array field produces more than one entry for a document, an embedded field is addressed by a dotted path, and an index may be asked to cover only part of the documents. The next lesson builds these three cases and counts the index size and the write cost.
To keep your progress and take notes, Log in
My notes
Log in to take notes.