7. Volcano Execution
Planning chooses a physical tree; execution makes that tree produce rows. This
chapter follows MiniPostgres from build_executor() through the
open()/next()/close() protocol, then shows how expressions, blocking
operators, modifications, and EXPLAIN ANALYZE fit around that small
interface. The important idea is not merely that operators are classes. It is
that every node owns a precise pull-based lifecycle, so a parent can ask for
one row without knowing whether its child reads a heap page, probes an index,
builds a hash table, or computes an aggregate.
Learning objectives
By the end of this chapter, you will be able to:
- trace a physical plan into a tree of executor objects and follow one row upward through it;
- distinguish streaming operators from operators that must consume their input before returning a row;
- explain MiniPostgres integer-expression and SQL
NULLsemantics at the evaluation boundary; - show why executor cleanup remains balanced after both success and failure;
- interpret estimated and actual evidence in structured
EXPLAIN ANALYZE.
From an immutable plan to a live operator tree
src/minipostgres/engine.py, Database._execute_relational() is the public
orchestration point. It asks Planner.logical() for a logical tree, passes it
to Database._optimize(), and receives an immutable PhysicalPlan. It then
calls src/minipostgres/executor/factory.py, build_executor(), and drains the
result with src/minipostgres/executor/base.py, collect().
The factory is deliberately a boundary, not a convenience switch.
_build_executor() recursively maps each physical node to its matching runtime
node. A PhysicalFilter becomes a FilterExecutor whose child has already
been built; a PhysicalHashJoin becomes a HashJoinExecutor with two child
executors; a PhysicalModifyTable becomes an insert, update, or delete
executor. Planning data stays immutable while execution state—iterators,
hash buckets, sort buffers, counters—lives in executor instances.
The common protocol is defined by Executor.open(), Executor.next(), and
Executor.close() in src/minipostgres/executor/base.py:
with executor:
while (row := executor.next()) is not None:
rows.append(row)
That short loop is the heart of the Volcano model. open() initializes a
node, next() returns one ExecutionRow or None, and close() releases
child state. The public methods enforce lifecycle rules: opening twice is
idempotent, reopening a closed node is forbidden, and next() outside an open
lifecycle raises. collect() uses the context-manager protocol, so Python
calls close() even if a child raises while producing a row.
An ExecutionRow is more than a tuple of display values. Scan operators attach
catalog-stable column bindings and source TIDs; projection and aggregate nodes
attach computed output slots. This lets an update consume a plan-produced row
and still identify the physical source version without teaching every
intermediate operator about storage. The ownership boundary is summarized in
the architecture reference.
Streaming and blocking operators
Look at src/minipostgres/executor/operators.py,
SeqScanExecutor._open() and SeqScanExecutor._next(). Opening obtains the
table scan iterator. Each call to next() advances only far enough to return
one visible row. FilterExecutor._next() repeatedly pulls its child until
evaluate() returns exactly True; False and SQL unknown both reject the
row. ProjectExecutor._next() pulls one child row and computes its select
items. These are streaming operators: they can normally return output without
materializing all input.
Some operators need a phase boundary. HashJoinExecutor._open() drains the
chosen build side into an in-memory hash table before probing can yield rows.
AggregateExecutor._open() consumes its child to construct groups and
aggregate states. SortExecutor._open() drains and sorts all rows before
SortExecutor._next() returns the first. These remain Volcano nodes because
their parent still sees the same pull interface, but they are blocking
internally.
NestedLoopJoinExecutor._next() demonstrates stateful pulling without full
materialization. It holds the current left row, opens or rewinds work over the
right-side rows, and tests the complete join condition for each pair.
HashJoinExecutor._next() uses an extracted equality key for candidates but
still evaluates the complete ON predicate as a residual. Thus changing join
algorithm changes work, not result semantics.
The bounded design is intentional. Joins, aggregates, and sorts live in Python
memory; there is no spill-to-disk, parallel executor, vectorized batch, or
memory-accounting hierarchy. src/minipostgres/executor/memory.py,
TableAccess, nevertheless keeps execution independent from whether rows come
from MemoryTable or persistent IndexedTableAccess.
Typed expression evaluation and int64 behavior
src/minipostgres/executor/expressions.py, evaluate(), evaluates only
already-bound expressions. Name resolution and type inference happened
earlier, so the executor dispatches on BoundLiteral, BoundColumn,
BoundCast, BoundUnary, BoundBinary, and BoundIsNull.
Two boundaries matter. First, arithmetic propagates NULL: after handling
AND, OR, and comparisons with their dedicated SQL helpers, _binary()
returns None when either arithmetic operand is None. Filters retain only
the Python value True, preserving SQL three-valued predicate behavior.
Second, integer arithmetic is signed 64-bit arithmetic, not unbounded Python
integer arithmetic. Addition, subtraction, multiplication, and unary negation
pass through src/minipostgres/types.py, validate_int64(). Integer division
uses absolute magnitudes and restores the sign, so it truncates toward zero;
division by zero raises TypeMismatch.
That validation is placed after the operation because Python can compute the mathematical result safely and MiniPostgres can then reject values outside the database type. It also means the evaluator, rather than a storage codec, defines overflow behavior for query expressions.
Modifications are executor nodes too
In src/minipostgres/executor/operators.py,
InsertExecutor._open(), UpdateExecutor._open(), and
DeleteExecutor._open() consume child rows and emit one affected-row count
through ModificationExecutor._next(). They do not reach into a Python list.
They call the TableAccess boundary, and normal database execution routes that
call into persistent MVCC heap and index maintenance.
Updates first evaluate and schema-validate every candidate produced by the child. They preserve each source TID, acquire the required concurrency protection in the storage access layer, and install a new version. Deletes likewise use source TIDs. This is why projection pruning cannot casually throw away row identity for a modifying plan.
The implementation is still smaller than PostgreSQL. It has no trigger
execution, RETURNING, partition routing, speculative insertion, or
statement-level transition tables. The positive contract—modification through
an executor and access-method boundary—is real; the surrounding production
features are not.
EXPLAIN ANALYZE without changing the pull contract
src/minipostgres/engine.py, Database._explain(), separates two operations.
Plain EXPLAIN calls explain_plan() and never builds or runs an executor.
EXPLAIN ANALYZE creates an InstrumentationSession, passes it to
build_executor(), executes the tree, and combines the collected metrics with
the physical plan.
src/minipostgres/executor/instrumentation.py,
InstrumentationSession.wrap(), decorates every node with an
InstrumentedExecutor. Its _open(), _next(), and _close() time the
delegate and count emitted rows. The wrapper does not add a second traversal or
change demand direction. Metrics are keyed by id(plan), which connects each
runtime wrapper back to the exact immutable plan node.
Elapsed milliseconds are local Python measurements. They are not PostgreSQL
cost units, benchmark-quality latency, or a prediction. Estimated rows and
costs come from planning; actual rows and elapsed time come from this one
execution. InstrumentationTracker.record_open() and record_close() also
provide a repository-wide lifecycle check. If expression evaluation fails,
the nested context-manager cleanup still closes every opened delegate.
Compared with PostgreSQL 18
PostgreSQL also executes a tree of plan-state nodes. The closest source anchors
are src/backend/executor/execProcnode.c (ExecInitNode,
ExecProcNode, ExecEndNode), src/backend/executor/execExpr.c for
expression-step interpretation, and node-specific files such as
nodeSeqscan.c, nodeHashjoin.c, nodeAgg.c, and nodeSort.c.
EXPLAIN (ANALYZE) is coordinated in src/backend/commands/explain.c, with
executor instrumentation in src/backend/executor/instrument.c.
The shared shape is demand-driven node execution and per-node runtime
evidence. MiniPostgres deliberately replaces PostgreSQL's generated expression
steps, memory contexts, tuple slots, parallelism, JIT hooks, work-memory spill,
and rich instrumentation with Python objects and in-memory state. It also
returns a structured PlanExplanation, not PostgreSQL-compatible text or
JSON. See the query-engine limits in
Differences from PostgreSQL, the
query_path evidence row in the
behavior matrix, and the relationship vocabulary in
the PostgreSQL mapping.
Hands-on experiment: pull a measured tree
Run this command from the repository root. It was executed against the current repository:
UV_CACHE_DIR=/tmp/minipostgres-uv-cache uv run --offline python - <<'PY'
from tempfile import TemporaryDirectory
from minipostgres import Database
def walk(node, depth=0):
print(" " * depth + f"{node.node_type}: actual_rows={node.actual_rows}")
for child in node.children:
walk(child, depth + 1)
with TemporaryDirectory() as root, Database.open(root) as db:
db.execute("CREATE TABLE sales (dept TEXT, amount INT)")
db.execute("INSERT INTO sales VALUES ('eng', 7), ('eng', 5), ('ops', 3)")
result = db.execute(
"EXPLAIN ANALYZE SELECT dept, SUM(amount) FROM sales "
"GROUP BY dept ORDER BY dept"
)
print(result.rows)
walk(result.plan)
tracker = db.instrumentation_tracker
print("balanced lifecycle:", tracker.open_count == tracker.close_count)
PY
Measured output:
(('eng', 12), ('ops', 3))
Sort: actual_rows=2
Project: actual_rows=2
Aggregate: actual_rows=2
SeqScan: actual_rows=3
balanced lifecycle: True
The scan emits three rows, aggregation reduces them to two groups, and the upper nodes each emit two. The lifecycle counter proves all instrumented opens had matching closes. A focused regression check was also run:
UV_CACHE_DIR=/tmp/minipostgres-uv-cache uv run --offline pytest -q \
tests/contract/test_explain_analyze.py::test_explain_analyze_reports_each_node_without_changing_rows
. [100%]
1 passed in 0.48s
Exercises
-
Understanding. Why can a
SortExecutorobey Volcano even though it consumes its entire child duringopen()?Acceptance: your answer must separate the parent-visible pull contract from the node's internal blocking algorithm.
Reference answer
Volcano specifies how a parent interacts with a node, not that every node must stream internally. Sort may materialize and order all input in
_open(), then expose one row per_next()call. -
Understanding. Where are signed-int64 overflow and SQL unknown filtered, and why are those two decisions not planner responsibilities?
Acceptance: name the relative source paths and functions and explain the runtime input involved.
Reference answer
src/minipostgres/executor/expressions.py,_binary(), callsvalidate_int64()for integer results.FilterExecutor._next()insrc/minipostgres/executor/operators.pyretains only results for whichevaluate(predicate, row) is True. Both depend on runtime row values; the planner can type the expression but cannot know those values. -
Hands-on. In a disposable worktree, add a
PhysicalOffsetandOffsetExecutorthat discards the first n child rows, then yields the remainder. Do not edit the tutorial author's workingsrc/tree.Acceptance: add unit tests showing offset zero, offset smaller than input, and offset larger than input; show that child
close()is called when downstream evaluation raises; run the new tests plustests/unit/executor/.Reference answer
A minimal patch adds an immutable physical node with
childandoffset, maps it inexecutor/factory.py, and implements a unary executor. Its_open()opens the child and callsnext()up to n times, stopping atNone;_next()delegates thereafter. The normalUnaryExecutor._close()supplies lifecycle cleanup. The parser and planner should only be extended if SQLOFFSETis also part of the exercise; otherwise construct the physical node directly in unit tests.
Summary
MiniPostgres turns immutable physical plans into stateful operators behind one
small demand-pull contract. Streaming and blocking algorithms compose because
their parents see the same lifecycle; typed expression evaluation preserves
SQL NULL and int64 rules; modification operators carry TIDs into storage;
instrumentation wraps rather than rewrites execution. The next chapter adds
the moving target that makes execution harder: concurrent transactions can
change which tuple version a pull is allowed to see or modify.