Aggregation pipelines
August 29, 2026 · View on GitHub
any-store has a MongoDB-style aggregation framework: an ordered pipeline of
stages that filters, reshapes, unwinds, groups and sorts documents inside one
read-transaction snapshot. The leading part of the pipeline is compiled into a
regular query and executed by the access planner — secondary indexes, the
cost-based optimizer, full-text ($text) and vector sources all apply — and
only the stages the planner can't express run as streaming operators in Go.
iter, err := coll.Aggregate(`[
{"$match": {"status": "published"}},
{"$unwind": "$tags"},
{"$group": {"_id": "$tags", "n": {"$count": {}}, "lastEdit": {"$max": "$edited"}}},
{"$sort": {"n": -1}},
{"$limit": 10}
]`).Iter(ctx)
defer iter.Close()
for iter.Next() {
doc, _ := iter.Doc() // e.g. {"id":"go","n":42,"lastEdit":17498}
...
}
The pipeline is accepted in the same form as Find() filters: a JSON string,
[]byte (a marshaled anyenc value, not JSON text), *anyenc.Value, or
any JSON-marshalable Go value.
1. Stages
| Stage | Form | Notes |
|---|---|---|
$match | {"$match": <filter>} | The full Find() filter language, including $text and $knn clauses, plus $expr (aggregation expressions as predicates — see section 2.1). |
$sort | {"$sort": {"a": 1, "b.c": -1}} | 1 ascending, -1 descending; anyenc value order across types; missing sorts as null. Stable. |
$skip / $limit | {"$skip": 10} / {"$limit": 5} | |
$count | {"$count": "n"} | Terminal: emits the single document {"n": <count>}. |
$project | {"$project": {"a": 1, "b": "$x.y", "c": {"p": "$q"}}} | Strictly explicit: only listed fields appear (id included only if listed — unlike Mongo's implicit _id). Exclusion ("a": 0) is not supported. Bare numbers and booleans are include flags (Mongo): {"a": 5} includes the stored field a; a literal number needs {"$literal": 5}. |
$addFields / $set | {"$addFields": {"b": "$x.y"}} | Overlays computed fields; an expression evaluating to missing removes the field. All expressions are evaluated against the stage input (Mongo): a field added by the same stage is not visible to its sibling expressions. |
$unwind | "$tags" or {"path": "$tags", "preserveNullAndEmptyArrays": true} | Default drops documents whose path is missing/null/empty; preserve emits them as-is (empty array: field removed). Non-array values pass through. |
$group | see below | Hash aggregation. |
$lookup | {"$lookup": {"from"?: c, "localField": f, "foreignField": "id", "as": out}} | Self-join point lookup on the primary key — see section 4. |
$facet | {"$facet": {"name": [stage...], ...}} | Named sub-pipelines over one shared scan — see section 5. |
$out | {"$out": "coll"} | Replaces the target collection's contents with the results — see section 6. Must be the last stage; not allowed inside $facet. |
$merge | {"$merge": {"into": c, "on"?: "id", "whenMatched"?: m, "whenNotMatched"?: n}} or {"$merge": "coll"} | Upserts the results into the target by id — see section 6. Same placement rules as $out. |
Not supported in v1: cross-collection and pipeline-form $lookup (see
section 4), $bucket, exclusion projections,
nested (dotted) output field names, and compute expression operators beyond
the set of section 2 ($dateToString, $toUpper, ...) — the expression
parser rejects unknown operators explicitly so they can be added compatibly
later.
A pipeline that does not parse is rejected as a structured *query.ParseError
with Source "pipeline": Path locates the failure inside the pipeline
document with the stage index as the leading segment ("1.$match.a.$gt"),
Op names the stage or operator at fault, and
errors.Is(err, query.ErrUnknownOperator) identifies vocabulary misses —
unknown stage, accumulator, or expression operator. The stage and accumulator
vocabularies are exported as data (anystore.AggregateStages(),
anystore.AggregateAccumulators()), so consumers advertising the grammar
never hand-copy the lists.
2. Expressions
Inside $project/$addFields values, $group keys and accumulator arguments:
- Field references —
"$a.b.c"(dot paths into the document; FTS/_score","a.b.c"through two array levels yields nested arrays). A terminal segment returns the value as is:"a.0"isa's first element; out of range → missing) instead of collecting object fields named"0"`. - Literals — any non-
$value;{"$literal": "$kept-verbatim"}escapes a literal that starts with$. - Document/array expressions —
{"x": "$a", "y": 1}and["$a", 1]evaluate their members (Mongo expression-context rules); a missing member is omitted from objects and becomes null in arrays. - Compute operators — arithmetic, string, conditional and comparison
expressions, composable to any depth:
{"$cond": [{"$lt": ["$a", 10]}, "low", {"$concat": ["$a", "!"]}]}.
| Operator | Form | Notes |
|---|---|---|
$add, $multiply | {"$add": [e, ...]} | Variadic; an empty operand list yields the identity (0 / 1). $add with exactly one dateTime operand among numbers shifts the date by their sum of millis; two dateTimes → null. $multiply is numeric-only. |
$subtract, $divide | {"$subtract": [a, b]} | Exactly two operands. $subtract: [date, date] → millis number, [date, number] → date, [number, date] → null. $divide by zero → null. |
$abs | {"$abs": e} | |
$round | {"$round": [x, place?]} | Half to even (banker's: 1.5→2, 2.5→2); place in [-20, 100], default 0, negative rounds left of the decimal point. |
$concat | {"$concat": [e, ...]} | String operands; an empty operand list yields "". |
$replaceOne | {"$replaceOne": {"input": e, "find": e, "replacement": e}} | Replaces the first occurrence of find in input; no occurrence leaves input unchanged. All three required (object form only). An empty find matches at position 0 and prepends the replacement. Null/missing operands → null (as in Mongo); a non-string operand → null too (Mongo errors; regex find is not supported). |
$replaceAll | {"$replaceAll": {"input": e, "find": e, "replacement": e}} | Replaces every occurrence of find, left to right, non-overlapping; replaced regions are not rescanned. Same contract as $replaceOne otherwise; an empty find prepends the replacement once (pinned; Mongo docs leave the case unstated). |
$split | {"$split": [string, delimiter]} | Array of the substrings between delimiter occurrences: adjacent delimiters produce empty strings, no occurrence yields [input]. Exactly two operands; the delimiter must be a non-empty string — an empty literal delimiter is a parse error, a delimiter expression evaluating to "" → null (Mongo errors at runtime; regex delimiters are not supported). |
$size | {"$size": e} | Number of elements in the array operand. A null/missing or non-array operand → null (Mongo errors). {"$size": {"$split": ["$notes", "\n"]}} counts lines. |
$strLenBytes, $strLenCP | {"$strLenCP": e} | Length of the string operand in UTF-8 bytes ($strLenBytes) or code points ($strLenCP). A $strLenCP operand that is not valid UTF-8 → null (Mongo errors); $strLenBytes counts bytes verbatim, valid or not. |
$trim, $ltrim, $rtrim | {"$trim": {"input": e, "chars"?: e}} | Strips leading and trailing ($ltrim/$rtrim: one side) code points — UTF-8 aware, never mid-rune. Without chars: exactly Mongo's documented whitespace set (U+0000, U+0009–U+000D, U+0020, U+00A0, U+1680, U+2000–U+200A — not full Unicode White_Space). With chars: the set of code points in that string; chars: "" trims nothing (pinned; Mongo docs leave the case unstated). |
$cond | {"$cond": [if, then, else]} or {"$cond": {"if": e, "then": e, "else": e}} | All three parts required. Lazy: only the taken branch is evaluated. Truthiness is Mongo's: false, 0, null, missing → false; everything else — including "", [], {} — true. |
$switch | {"$switch": {"branches": [{"case": e, "then": e}, ...], "default": e?}} | At least one branch; cases evaluate lazily in order, first truthy case wins; no match falls to default. |
$ifNull | {"$ifNull": [e, e, ...]} | At least two operands (Mongo 4.4 variadic form): the first non-null, non-missing value, else the last operand's value. Lazy left-to-right. |
$eq, $ne, $gt, $gte, $lt, $lte | {"$eq": [a, b]} | Exactly two operands; true/false over any value types (see the comparison note below). |
$cmp | {"$cmp": [a, b]} | -1/0/1. |
$dateAdd | {"$dateAdd": {"startDate": e, "unit": u, "amount": e, "timezone"?: tz}} | Units: year, quarter, month, week, day, hour, minute, second, millisecond. year/quarter/month are calendar-aware in the operative timezone and clamp the day of month (Jan 31 + 1 month = Feb 28); week/day add calendar days, preserving the local clock across DST; hour and smaller are fixed millis spans. Non-integral amount → null. |
$dateDiff | {"$dateDiff": {"startDate": e, "endDate": e, "unit": u, "timezone"?: tz, "startOfWeek"?: w}} | Signed count of unit-boundary crossings, not elapsed time (day diff of 23:59 → 00:01 is 1). year…day cross local calendar boundaries; hour and smaller cross absolute-millis boundaries. startOfWeek (default sunday) applies to week only. |
$dateTrunc | {"$dateTrunc": {"date": e, "unit": u, "binSize"?: n, "timezone"?: tz, "startOfWeek"?: w}} | Truncates down to its binSize×unit bin (binSize: positive integer literal, default 1). Bins anchor at Mongo's reference point 2000-01-01T00:00:00 in the operative timezone; week bins anchor at the first startOfWeek on or after 2000-01-01 (a Saturday). |
$year, $week | {"$year": e} or {"$year": {"date": e, "timezone"?: tz}} | $week is the Sunday-based week of year 0–53 (days before the year's first Sunday are week 0) — not the ISO week. |
A single non-array operand is Mongo's shorthand for a one-element list
({"$abs": "$x"}); arity is checked at parse time with structured errors.
Comparison order is the engine's canonical anyenc value order — the same order
$sortand$min/$maxuse: values order by type tag first (null < number < string < false < true < array < object < ... < dateTime), then by value within a type (numbers numerically, strings bytewise, arrays elementwise, objects by their marshaled bytes). This differs from BSON's canonical cross-type order (where e.g. booleans sort after strings and object comparison ignores field order) — object equality here is field-order-sensitive, consistent with$groupkey equality. A missing operand compares asnull({"$eq": ["$nope", null]}istrue), and-0equals0. Vector values order by their encoded bytes too (aligned with$sort). Query filters differ: their ordering operators are type-bracketed (docs/query-filter-contract.mditem 13), so a$match{"a":{"$gt":5}}never matches a stringa, while the expression{"$gt":["$a",5]}istruefor one — the same split as Mongo's query operators versus$expr.
Null instead of runtime errors (divergence from Mongo): evaluation is streaming with no per-document error channel, so conditions Mongo reports as query errors yield
nullinstead — a non-numeric operand of an arithmetic operator, a non-string operand of a string operator ($concat,$replaceOne/$replaceAll,$split,$strLenBytes/$strLenCP,$trim/$ltrim/$rtrim— including a$trimcharsand a$splitdelimiter expression, where an empty-string delimiter counts too), a non-array$sizeoperand, a$strLenCPoperand that is not valid UTF-8, division by zero, a non-finite result (overflow, NaN), an out-of-range or non-integer$roundplace, and a$switchwith no matching case and nodefault(Mongo raises). Null and missing operands also yieldnull— as in Mongo, except for$sizeand$strLenBytes/$strLenCP, where Mongo errors for null/missing operands too.
$roundprecision is float64: values round by their binary double value ({"$round": [2.345, 2]}is2.35— the stored double sits above the midpoint;{"$round": [1.25, 1]}is1.2— an exact tie, half to even), and aplacebeyond float64 resolution returns the value unchanged.
Date operators work over the dateTime value type (
{"$date": ...}in JSON).unit,timezone,startOfWeekandbinSizeare parse-time literals — an expression there is a parse error (divergence: Mongo accepts dynamic values; a literal resolves the*time.Locationonce at parse time).timezoneis an Olson name ("Europe/Berlin") or a fixed offset ("+02:00","-0500","+02"); default UTC. A date operand (startDate/endDate/date, and the date side of$add/$subtract) that is null or missing →null, and any other non-dateTime type →nulltoo (divergence: Mongo errors — same no-error-channel rationale as above); an unrepresentable result (overflow past the int64-millis range) is alsonull. Around DST transitions: a computed wall time that a fall-back repeats resolves to its earlier occurrence, one that a spring-forward skips normalizes forward, and sub-day$dateTruncsubtracts the wall-clock residue on the absolute timeline, so it stays monotone and idempotent across the transition.$dateDiff'shour/minute/secondboundaries are absolute UTC millis, sotimezoneaffectsdayand larger units only.$dateDiff,$yearand$weekreturn float64 numbers;$dateAdd/$dateTruncreturn dateTimes. Millis in numeric form (date differences,$add/$subtractshifts) are float64: exact only up to ms, i.e. within roughly year ±285,000.
2.1 match predicates
{"$match": {"$expr": E}} evaluates the aggregation expression E per
document and keeps the row when the result is truthy ($cond truthiness:
false, 0, null, missing → drop; everything else — including "", [],
{} — keep). This enables field-to-field predicates the filter language
cannot express:
{"$match": {"$expr": {"$gt": ["$allocated", "$capacity"]}}}
$exprmay coexist with ordinary filter keys —{"$match": {"cat": "a", "$expr": E}}meanscat = "a" AND E— and may appear inside a top-level$andarray (a conjunction splits cleanly). Under$or/$norit is rejected with a dedicated parse error, and inside a field condition ({"a": {"$not": {"$expr": ...}}}) it is an unknown operator — no silent misparse.$expris always a residual per-document predicate (Mongo semantics): it never becomes index bounds. In a leading$match, the ordinary filter keys are still pushed into the access plan (indexes, CBO) with the expression applied as a residual on top; a pure-$expr$matchat the pipeline head is a full scan. A following$sortcan still push (filtering a sorted stream preserves order), but$skip/$limitstay in-pipeline — they must apply after the predicate.- The same contract holds inside one
$matchmixing$exprwith$textor$knn: the ordinary keys reach the planner with the ranked source as usual, while$exprfilters after the ranked scan — for$knnthat means after the$k-bounded page, so it can shrink the result below$k. - After
$group/$project/...,$exprsees that stage's output (e.g. accumulator fields). - Evaluation is alloc-free in steady state, like the other streaming stages.
Find() filters do not accept $expr — it is rejected as
unknown operator: $expr (query.ErrUnknownOperator). Field-to-field
predicates belong in an aggregation $match:
coll.Aggregate('[{"$match": {"$expr": ...}}]').
3. $group
{"$group": {
"_id": {"cat": "$cat", "year": "$meta.year"},
"n": {"$count": {}},
"total":{"$sum": "$amount"},
"tags": {"$addToSet": "$tag"}
}}
- The group key is spelled
_id(Mongo) orid; the output field is alwaysid— any-store documents carryid, so group results can be inserted back into a collection unchanged. A missing key value groups asnull. - Key equality is byte equality of the canonical anyenc encoding. For object keys this is field-order-sensitive (a deliberate divergence from Mongo's order-insensitive document comparison).
- Output order is first-seen (scan) order — unspecified; add
$sort.
Accumulators: $sum, $avg (numeric inputs only; empty: 0 / null),
$min, $max (anyenc value order; null/missing ignored; empty: null),
$count ({} argument), $first, $last (missing value omits the output
field; without a preceding $sort they reflect scan order), $push (skips
missing, keeps null), $addToSet (byte-equality dedup).
Numbers are IEEE 754 float64. anyenc stores every number as float64, so
$sum/$avgare float arithmetic — integer precision ends at . There is no int/long/decimal type tracking (divergence from Mongo, documented here rather than half-emulated).
4. $lookup: self-join point lookup
{"$lookup": {"localField": "refs", "foreignField": "id", "as": "linked"}}
$lookup is scoped to the case the data model makes cheap: relation values
are object ids and all objects live in one collection, so the join is a point
lookup per streamed row. For each row it reads localField (any field path),
resolves the value(s) as primary keys of the same collection, and sets
as (same naming rules as $project outputs, replacing any existing field)
to the array of matched full documents — always an array (Mongo semantics),
empty when nothing matches.
- Omitting
fromis the canonical form — the stage is self-join-only, so the source collection is implied. When present,frommust equal the aggregated collection's name; anything else failsIter/Count/Explainwith an error naming both collections. Callers that hold only a logical name for the collection (not the physical one) should omitfrom.foreignFieldis optional and must be"id"(the primary key); any other value is a parse error. The pipeline/letform is a parse error too. - A missing or null local value yields
[]. Divergence from Mongo: the primary key is never null, so$lookupnever does Mongo's null-matching join. - An array local value is set membership (Mongo semantics): elements are deduplicated by first occurrence, and the output keeps first-occurrence order (Mongo leaves the order unspecified). A null element is skipped; an element of a type no stored key has (or a dangling id) simply doesn't match — no error.
- A document may match itself (single hop, no recursion).
- Expression paths traverse into the
asarray (implicit array traversal, section 2):"$linked.name"collects the matched documents'namevalues into an array without an$unwind.$unwindingasfirst still works when one row per match is wanted. - Point lookups run inside the same snapshot the pipeline streams from,
at any pipeline position — after
$group,localFieldcan name a group key, resolving keys back to their documents:
[{"$group": {"_id": "$assignee", "n": {"$count": {}}}},
{"$lookup": {"localField": "id", "as": "assigneeDoc"}}]
The stage is streaming and alloc-free in steady state for single-id lookups (fetched documents reuse per-stage buffers); an id array only allocates while growing the stage's high-water match count.
5. $facet: sub-pipelines over one scan
[{"$match": {"space": "s1"}},
{"$facet": {
"total": [{"$count": "n"}],
"byType": [{"$group": {"_id": "$type", "n": {"$count": {}}}}],
"recent": [{"$sort": {"modified": -1}}, {"$limit": 5}]
}}]
$facet feeds every input row to each named sub-pipeline and emits exactly
one document {"total": [...], "byType": [...], "recent": [...]} — each
field the full result array of its sub-pipeline. This is the dashboard
pattern: N widgets over one shared scan instead of N independent scans.
- At least one facet; names follow output-field naming rules; each value is a
non-empty pipeline of any supported stage except
$facetitself (no nesting, as in Mongo).$lookupinside a facet works, at the same snapshot. - Empty input yields empty arrays (
$countstill emits its zero row, as it does standalone). - A
$matchbefore$facetparticipates in prefix pushdown as usual — that shared indexed scan is the point. A$matchat the head of a sub-pipeline filters the shared stream in-flight and never becomes index bounds;$text/$knnare therefore rejected inside facets (section 7). - Facet result arrays are inherently buffered: their bytes count against the
shared memory budget (section 8). Sub-pipeline
$sort/$groupstages keep their own bounds, including the$sort+$limittop-K fold. - Stages after
$facetsee the single result document ($unwinda facet array to keep processing it). - The fan-out itself does not allocate per row; once every facet has
satisfied a
$limit, the scan stops early.
6. out: materialize into a collection
[{"$group": {"_id": "$space", "total": {"$sum": "$bytes"}}},
{"$merge": {"into": "space_stats", "whenMatched": "replace"}}]
$out and $merge write the pipeline's results into a collection, making
derived values ($group totals, computed fields) filterable, sortable and
indexable like any stored documents — declare an index on the target and
query the materialized field through it, instead of recomputing per client.
Both must be the last pipeline stage (parse error otherwise, as in Mongo)
and are rejected inside $facet. Neither may target the aggregated
collection itself — ErrAggregateIntoSource (divergence: Mongo allows it
with caveats; unsafe under our streaming-read-plus-write model). Only a plain
collection name is accepted: Mongo's db-qualified form is a parse error. A
missing target is created inside the same write transaction.
Execution model — buffer, then write. The read pipeline runs to EOF in
its own read snapshot, buffering results as raw marshaled bytes; the buffered
bytes count against the MemoryLimit budget shared with the blocking stages
(exceeding it fails with ErrAggMemoryLimitExceeded and writes nothing).
Then one write transaction applies everything: target creation, the
$out delete-and-insert, every $merge upsert. Other readers — same process
or other processes — see the old contents or the new, never a mix, and any
error rolls the entire write back (nothing partial persists). The two phases
use different transactions: a write committed by someone else between them is
overwritten ($out) or merged against ($merge).
The write executes eagerly inside Iter/Count (Mongo's aggregate()
semantics): Iter returns an empty cursor after the write already happened —
Close without Next changes nothing — and Count returns the number of
documents written (inserted, replaced or merged; keepExisting/discard
skips and byte-identical replaces are not counted).
For read-only endpoints executing caller-supplied pipelines,
AggQuery.ReadOnly() makes Iter/Count fail fast with
ErrAggregateReadOnly (naming the stage) when the pipeline ends in
$merge/$out — before any read work, target creation or write
transaction; Explain stays available. A read transaction passed via
context also blocks the sinks (ErrTxIsReadOnly), but only at write time —
ReadOnly() is the cheap pre-flight.
$out replaces the target's contents: every existing document is deleted
and every result inserted through the regular write path, so declared range,
full-text and vector indexes survive and are rebuilt entry-by-entry within
the same transaction. A result lacking the target's primary key fails like
Insert (ErrDocWithoutId); duplicate result ids fail with ErrDocExists.
An empty result set still creates/empties the target (Mongo).
$merge upserts by primary key: on may only be "id" (parse error
otherwise — primary-key scope, like $lookup) and the target's primary key
must be id. Every result document must carry id (ErrMergeNoId).
Options, with Mongo's defaults:
| Option | Values (default first) |
|---|---|
whenMatched | "merge" — overlay the result's top-level fields onto the existing document (fields the result lacks keep their values); "replace"; "keepExisting"; "fail" → ErrMergeMatched naming the id, whole write aborted. |
whenNotMatched | "insert"; "discard"; "fail" → ErrMergeNotMatched naming the id, whole write aborted. |
The "merge" overlay preserves the existing document's field order (new
fields append), so a merged document and a fresh insert with identical fields
can differ in field order — observable to order-sensitive object comparison
($cmp, $group keys).
The pipeline/let form of whenMatched is not supported (parse error). An
empty result set is a pure no-op: nothing is written and a missing target is
not created (unlike $out).
7. Pushdown: what the planner executes
Aggregate splits the longest pushable prefix — $match chain (folded into
one $and), then at most one $sort, $skip, $limit in that order — and
hands it to the regular query planner. That means:
- an indexed
$match+$sortprefix runs as an index seek/scan with index-order sorting and cursor-level offset skips, exactly likeFind(); - a
$matchcontaining$textmakes the BM25 search drive the pipeline source (_scoreavailable downstream), a$knnclause makes the ANN index drive it (_distanceavailable downstream). With$kin the clause, the prefix denotes at most$kdocuments — downstream$group/$countstages aggregate exactly that page, never a silentlyef-truncated stream; {"$match": {"x": {"$in": []}}}short-circuits to an empty source with no I/O;$exprpredicates in the leading$matchchain never enter the plan: the ordinary keys push down, the expressions run as a residual streaming$match(section 2.1) — visible inExplainas aStages:entry.
Pushdown stops at the first $group/$project/$addFields/$unwind/
$count/$lookup/$facet or any out-of-canonical-order stage; the remainder runs in-pipeline. An
in-pipeline $sort directly followed by $skip/$limit keeps only the top
skip+limit rows (heap + packed arena, O(K) memory).
$text and $knn clauses are valid only inside the pushdown prefix —
they are executed by the index sources the planner builds, not by the
streaming $match operator. A $match containing them after the prefix ends
(e.g. after $unwind/$group, or preceded by $skip/$limit) fails
Iter/Count/Explain with a descriptive error instead of silently
matching everything (knn). The legacy bare-array
ANN spelling is likewise rejected in-pipeline (ErrLegacyVectorClause). This
is final: AggQuery has no Delete/Update, so the rejection costs
expressiveness, never data.
AggQuery.Explain shows the split:
... access plan of the pushed prefix ...
Pushdown: filter={"cat":{"$eq":"c1"}} sort limit=3
Stages:
1. $group {id:$cat,"top":{$push:$v}}
8. Limits and memory
Streaming stages retain nothing and are allocation-free in steady state.
Blocking stages ($group, in-pipeline $sort, $facet result buffers)
retain data and are bounded; exceeding a bound aborts the iteration with a
sentinel error:
| Bound | Default | Override | Error |
|---|---|---|---|
Unique $group keys | 50 000 | GroupLimit(n) | ErrGroupLimitExceeded |
$push/$addToSet length | 10 000 | AccumArrayLimit(n) | ErrAccumArrayLimitExceeded |
Retained bytes (blocking stages + the $merge/$out result buffer) | 256 MiB | MemoryLimit(n) | ErrAggMemoryLimitExceeded |
Negative values mean unlimited. There is no spill-to-disk: a pipeline that needs more than the budget should filter earlier or raise the limit explicitly.
iter, err := coll.Aggregate(pipeline).
GroupLimit(200_000).
MemoryLimit(1 << 30).
Iter(ctx)
9. Iterator semantics
Aggregate(...).Iter(ctx) returns the same Iterator interface as Find():
documents are valid only until the next Next() call (copy if you keep
them). Score()/Distance() return 0 on aggregation iterators —
for an FTS/vector prefix, read the _score/_distance fields off the
documents instead. Count(ctx) runs the pipeline and counts results;
$count-as-last-stage emits the count as a document instead. A $merge/
$out pipeline executes its write eagerly inside Iter/Count and yields
zero rows; Count returns the documents written (section 6). An unclosed
iterator pins its read transaction (the snapshot/WAL cannot advance past it) —
always Close it.