Chapter 7: scan/, one plan node to judge them all

When Postgres runs a query, it first makes a plan: a tree of nodes, each of which hands rows to the one above it. A sequential scan reads a table, a Sort sorts what it is handed, a Limit stops after so many. Normally a function in your WHERE is evaluated by the node that filters, one row at a time, and that is the one thing that must not happen here: one row at a time is one request at a time, waited on in turn.

So this folder adds a node of its own, a CustomScan called JevScan, and puts it between a table's scan and whatever was above it. Then it takes every call to the extension's functions out of the nodes they were in and answers them itself, many rows at once:

flowchart BT
  subgraph before["What Postgres would plan"]
    S1["Seq Scan on people<br/>Filter: country = 'PT' AND jev(people, q)"] --> L1["Sort, Limit"]
  end
  subgraph after["What it plans with postjevsql"]
    S2["Seq Scan on people<br/>Filter: country = 'PT'"] --> J2["Custom Scan (JevScan)<br/>judges up to jev.concurrency rows at once<br/>Filter: jev's answer"] --> L2["Sort, Limit"]
  end

The child keeps every ordinary condition, so a row the SQL filters out never reaches the node and is never judged. The node pulls rows ahead while fewer than its window are unanswered, spawns one judgement per row, and hands rows up in the order the child gave them, waiting only when the oldest is still out. A LIMIT above simply stops pulling, so nothing more than a window past the last row returned is ever sent.

Aside: being lazy on purpose. Postgres evaluates a OR b by skipping b when a is true. But this node answers every call before the condition runs, so it could pay for a b that Postgres was never going to read. reach.rs works out, for each call, the condition under which Postgres would reach it (earlier OR arms not true, earlier AND arms not false, the CASE tests before it), and the node evaluates that first. A call that would not be reached is left NULL and costs nothing.

Where the node goes

The calls can be anywhere in a query, and each place needs its own hook:

Where the call isWho puts the node there
A table's WHERE, or its select list when it is the whole queryset_rel_pathlist_hook wraps every path of the relation, index and parameterized ones included (plan.rs).
The select list above an ORDER BYcreate_upper_paths_hook makes the projection above the Sort a jev scan too.
An aggregate's argument, HAVING, DISTINCT, a window functionplanner_hook's post-pass, after the plan is built, puts a jev scan between that node and its child (lift.rs).
Each partition of a partitioned tableEach member is wrapped, sharing the statement's dedupe and budget.
CTEs and subqueriesNothing extra: each is planned on its own, so the hooks above reach it.

Whatever is left over (a call over two relations, one in RETURNING) would run row by row, so guard.rs checks the finished plan in ExecutorStart and refuses it before a single row is pulled. The same check prices the plan against jev.max_rows and jev.max_cost.

Try it. nix develop -c buck2 test //tests:batching runs the scan through all of the shapes in that table, each against a per-row reference, on Postgres 18; //tests:batching-pg17 runs it on 17.

For the people who maintain it

FileWhat
mod.rsThe safe surface: the Judge trait the extension implements, and the types it trades in (Function, Arg, Call, Value, Out, Field, Property, Estimate, Judgement).
plan.rsregister (the hooks, the custom scan methods, the syscache callback), support (the planner support function and its per-call cost), finding the calls, wrap_paths, wrap_projections, plan_custom_path, rows_before for EXPLAIN's candidate rows, and refuse_shipping_servers for sidecar mode.
lift.rsThe planner_hook post-pass: a jev scan under an Agg, WindowAgg, Group or Result whose own expressions call the functions.
reach.rsEach call's reach: the condition under which Postgres evaluates it.
guard.rsThe ExecutorStart check: refuse a call left outside a jev scan, then price the plan through Judge::afford.
exec.rsExecution: pull ahead while the window has room, judge each row in a scoped task, emit in the child's order, rescan, EXPLAIN.
statement.rsStatement: what one statement's scans share (the budget, the registry of judgments in flight), keyed by the EState, gone when its query context resets; an EvalPlanQual recheck finds its parent's.
backend.rson_backend: a closure a judgement queues for the backend, and the future of its result.
tuple.rsScanTuple, and ScanTuple::eval, the only way the node evaluates an expression.
ffi.rsPostgres helpers that are static inline or macros in the headers, so bindgen does not carry them, and iterators over List.

← Previous: Chapter 6, postjevsql-pg/src/ · Up: src · Next: Chapter 8, postjevsql-core/ →