droid
Cached Plans and When They Die
Prepared Statements · Stage 8 of 11

Cached Plans and When They Die

Keep the plan a prepared statement was given, notice when the schema moves under it, and plan again without anyone asking

Plan a prepared statement once and keep the plan; reuse it on every EXECUTE, counting hits; drop every kept plan over a table when an index is created or dropped, the table is altered, truncated or dropped, or it is analyzed, in any session; plan again on the next EXECUTE, silently, or fail with the planner's error, or with 0A000 when the result's columns changed; and show it all through plan cache and explain execute.

Run the tests on your machine

This part needs a network, which the platform's test sandbox does not have, so its tests run on your computer. Download the test kit (it needs only Python 3) and your project, build your program, and run:

python3 run.py --stage plan-cache --bin ./c-db
Download the test kit

Sign in to download the code you wrote here.

Concepts in this stage
  • A kept plan saves the planner's work on every EXECUTE, the most expensive step after the data itself
  • A plan depends on things that can change: the indexes it uses, the table's columns, the statistics it was costed with
  • Invalidation: when what a plan depends on changes, drop the plan; plan again only when it is needed
  • The new plan must answer the same question: a statement may not change the columns it returns
  • Generic plans: made without the values, so the planner cannot use them
Where this stage sits in the engine
Talking to the user
REPL
Sessions
PrinterServerWire protocol
Understanding SQL
LexerParserValidator
Answering a query
Planner
Executor
Keeping changes safe
TransactionsWrite-ahead logUndo logLock managerRead views
Storing rows
Row formatSlotted pageB+Tree
Reaching the disk
PagerDatabase file
this stagebuilt alreadystill to comeA statement enters at the top; rows come back up.

Why this stage exists

Your PREPARE parses and checks a statement once. The planner still runs on every EXECUTE: choosing a scan, maybe costing joins, maybe reordering them. For a statement run ten thousand times a minute, that is ten thousand identical plans. So the plan is kept too, the last piece of work that did not depend on the values.

A kept plan is a promise about a world that can change. It says "use index t_name". Then someone drops t_name. Or it was costed with statistics from when the table had a hundred rows, and it now has a million. A plan cache that never notices runs a plan that crashes or crawls. One that re-plans every time is no cache at all. The answer is invalidation: each plan remembers what it depends on, and a change to any of those drops the plan.

Drawing the diagram…

What a plan depends on

A plan depends on the tables it reads or changes, their indexes and their statistics. droid keeps it simple: each plan keeps the names of its tables. Any statement that changes the shape of one of them, or its statistics, drops every plan naming it, in every session. A session that dropped an index must not leave other sessions running plans that use it.

Dropping is cheap and immediate. Planning again is lazy: only a statement that is actually executed again pays for a new plan, and its client never notices. That is the "silently" in the contract. The EXECUTE that re-plans is not a hit, because it did the planner's work.

When planning again cannot work

Sometimes the new plan can't be made: the table was dropped. The EXECUTE fails with the same error a fresh statement would get (42P01), and the statement stays prepared. If someone creates the table again, the next EXECUTE plans against the new table and works. PostgreSQL behaves exactly this way.

Sometimes the new plan can be made but answers a different question. select * from t was prepared when t had two columns. After ADD COLUMN it has three. A driver that described the statement at Parse time still expects two. Rather than surprise it, the server refuses with 0A000 (PostgreSQL's message reads "cached plan must not change result type") on every EXECUTE until the client prepares the statement again.

An aside: generic and custom plans

A plan made at PREPARE has no values, so it is generic: where name = $1 is costed for "some value of name". If the table holds a million 'x' and one 'y', the best plan for 'y' (an index) is the worst for 'x' (a scan would be better). PostgreSQL therefore plans the first five EXECUTEs with their actual values, as custom plans, and keeps the generic one only if it costs about as much. droid always keeps the generic plan. It is predictable, and its weakness is the reason PostgreSQL has the option plan_cache_mode.

The algorithm in words

1

Keeping and reusing

PREPARE already checks the statement. Now it also plans it, and keeps the plan.

  1. 1.At PREPARE or Parse, plan the checked statement with its parameters unknown, and keep the plan, the tables it reads or changes, and the columns of its result. Count one plan.
  2. 2.At EXECUTE, if the plan is there, run it with the values in place and count a hit.
2

Invalidating

A schema change must reach plans in every session, not only the one that made the change.

  1. 1.When CREATE INDEX, DROP INDEX, ALTER TABLE, TRUNCATE, DROP TABLE or ANALYZE runs, find every statement, in every session, whose kept plan names the table, drop the plan and count an invalidation. A statement whose plan is already gone counts none. ANALYZE with no table does this for every plan.
  2. 2.The statement stays prepared, with no plan.
3

Planning again

Lazily: only a statement that is executed again pays for a new plan.

  1. 1.At EXECUTE, if the plan is gone, plan the statement again.
  2. 2.If planning fails, the EXECUTE fails with the planner's error, and the statement stays without a plan, to try again next time. It counts nothing.
  3. 3.If the new plan's result has other columns than the statement had at PREPARE, the EXECUTE is 01919, 0A000, every time, until the statement is prepared again. It counts nothing either.
  4. 4.Otherwise keep the new plan and count a plan, not a hit, then run it.
  5. 5.explain execute plans an invalidated statement in the same way, counting the plan, and keeps it; it runs nothing and is never a hit.

Output contract for this stage

These are the exact lines and codes the tests check. The full definition is on the output contract page, section 100.

InputOutput
plan cache[PLANCACHE] <name> plans=<p> hits=<h> invalidations=<i>, one line per statement of the session, ascending by name
explain execute <name> (<values>)[PLAN] execute <name> (<values>) and the plan's node lines, with $k where a parameter goes; an invalidated statement is planned first, counting in plans, and it is not a hit
EXECUTE of a statement over a dropped tablethe planner's error: 00401 (42P01)
EXECUTE whose new plan returns other columns01919 (0A000)
StatementInvalidates plans over
CREATE INDEX, DROP INDEXthe index's table
ALTER TABLE, TRUNCATE, DROP TABLEthat table
ANALYZEthat table, or every table

What you should see when done

shell
$droid --db s.db
Welcome to droid!
>prepare p as select id, name from t where name = $1;
Prepare statement executed successfully
>explain execute p ('bob');
[PLAN] execute p ('bob')
[PLAN] └── Project (cols=[id, name])
[PLAN] └── IndexOnlyScan (table=t, index=t_name, key=$1)
>drop index t_name;
Drop index statement executed successfully
>explain execute p ('bob');
[PLAN] execute p ('bob')
[PLAN] └── Project (cols=[id, name])
[PLAN] └── Filter (name = $1)
[PLAN] └── SeqScan (table=t)
>plan cache
[PLANCACHE] p plans=2 hits=0 invalidations=1

(The node lines are whatever your planner prints for these plans in earlier parts; only the $1 is new. The index holds name and the table key id, everything the query reads, so the scan is index-only.)

Common bugs in this stage

  • Invalidating only the session that ran the DDL. Other sessions keep plans that use the dropped index.
  • Re-planning eagerly at the DDL. It works, but it plans statements that may never run again, and it fails loudly for a dropped table at the DROP, not at the EXECUTE.
  • Counting a re-planning EXECUTE as a hit. It did the planner's work.
  • Deallocating a statement whose table was dropped. It stays prepared; the table may come back.
  • Letting a new plan return other columns. A driver described the statement once and holds the old shape.

Language notes

The page above is the same for every language. These are the only language-specific hints.

  • C: a list of every prepared statement of every session, with their table names, to walk at each DDL. A NULL plan pointer is "invalidated".
  • C++: std::optional<Plan> in the prepared statement; a std::unordered_multimap<std::string, Prepared*> from table name to statements makes invalidation a lookup.
  • Rust: Option<Plan> in the prepared statement; plan.take() to invalidate.
  • Zig: ?Plan; free the old plan's memory when you drop it.

Checklist

One line per thing the tests check. The tags name the test cases.

  • A prepared statement is planned once and its plan reused: plans=1 hits=5
    plan-reused
  • Dropping the index a plan uses makes the next EXECUTE plan again, without it
    drop-index-replans
  • A new index is used by the next EXECUTE
    create-index-replans
  • ANALYZE of a table invalidates the plans over it
    analyze-invalidates
  • After an index comes and goes, EXECUTE still gives the right rows
    ddl-keeps-results-right
  • A statement over a dropped table fails with 42P01, and works again once the table is back
    drop-table-then-execute
  • A cached plan may not change the columns it returns: 0A000
    alter-changes-result-shape
  • Each session caches its own statements, and DDL in any session invalidates them
    per-session-caches