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 bby skippingbwhenais true. But this node answers every call before the condition runs, so it could pay for abthat Postgres was never going to read.reach.rsworks out, for each call, the condition under which Postgres would reach it (earlierORarms not true, earlierANDarms not false, theCASEtests 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 is | Who puts the node there |
|---|---|
A table's WHERE, or its select list when it is the whole query | set_rel_pathlist_hook wraps every path of the relation, index and parameterized ones included (plan.rs). |
The select list above an ORDER BY | create_upper_paths_hook makes the projection above the Sort a jev scan too. |
An aggregate's argument, HAVING, DISTINCT, a window function | planner_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 table | Each member is wrapped, sharing the statement's dedupe and budget. |
| CTEs and subqueries | Nothing 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:batchingruns the scan through all of the shapes in that table, each against a per-row reference, on Postgres 18;//tests:batching-pg17runs it on 17.
For the people who maintain it
| File | What |
|---|---|
| mod.rs | The safe surface: the Judge trait the extension implements, and the types it trades in (Function, Arg, Call, Value, Out, Field, Property, Estimate, Judgement). |
| plan.rs | register (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.rs | The planner_hook post-pass: a jev scan under an Agg, WindowAgg, Group or Result whose own expressions call the functions. |
| reach.rs | Each call's reach: the condition under which Postgres evaluates it. |
| guard.rs | The ExecutorStart check: refuse a call left outside a jev scan, then price the plan through Judge::afford. |
| exec.rs | Execution: pull ahead while the window has room, judge each row in a scoped task, emit in the child's order, rescan, EXPLAIN. |
| statement.rs | Statement: 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.rs | on_backend: a closure a judgement queues for the backend, and the future of its result. |
| tuple.rs | ScanTuple, and ScanTuple::eval, the only way the node evaluates an expression. |
| ffi.rs | Postgres 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/ →