Skip to content

Warm one plan per prepared case - #4572

Open
sergei-pustovykh wants to merge 2 commits into
apple/sergei-pustovykh/stored-query/value-free-planningfrom
apple/sergei-pustovykh/stored-query/warm-up
Open

Warm one plan per prepared case#4572
sergei-pustovykh wants to merge 2 commits into
apple/sergei-pustovykh/stored-query/value-free-planningfrom
apple/sergei-pustovykh/stored-query/warm-up

Conversation

@sergei-pustovykh

@sergei-pustovykh sergei-pustovykh commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Now a stored query with a signature gets one plan per PREPARE FOR case.

Each PREPARE FOR case is planned the way a client's prepared statement would be: warm-up binds the value a case pins, or passes only the type when the case pins none. A client whose bindings fit that case then finds a plan already built for it.

This is the last PR of the chain. Until now a signature and its PREPARE FOR cases were only syntax and storage; this is what makes them do something.

What a PREPARE FOR case becomes

PreparedCaseParams turns one case into the PreparedParams to plan it with. A state either binds a value or leaves the parameter without one:

state warm-up passes so the plan
IS_NULL a real null is folded at plan time — the predicate is gone, not evaluated
IS_TRUE Boolean.TRUE is folded for that value
IS_FALSE Boolean.FALSE is folded for that value
IS_NOT_NULL no value, the declaration is value-free, and a null binding cannot match it

Decisions worth attention

Warm-up resolves no types. A declaration is passed on as the text the signature stored, and the planning path turns it into a type when it meets the parameter — that is where the schema template is in hand. So this PR adds no parsing and no type handling of its own; it decides only what each case binds.

Every declaration is passed on, not just the ones a case leaves value-free, because a declaration is only read for a parameter that carries no value: one that is also bound never consults it. Nothing has to be filtered per case, so PreparedCaseParams stays a pure function — no database, no schema template, no planner.

Temporary functions are planned inside the per-case loop. A signature parameter captured by a function's body changes that function's plan too, so each case needs its own compile, from the original template with a fresh factory. Compiling them once per query would have leaked one case's folded function into the next.

A failing case no longer skips the rest of the query. The plan for the null case failing is no reason to give up the plan for the non-null one, so the loop continues. queriesProcessed still counts queries — one increment either way, so the existing counter keeps its meaning — and the new plansWarmed says how much the cache was actually filled.

plansWarmed is an upper bound, stated as such in its javadoc: two cases differing only in a parameter the body never references produce the same plan under the same constraint, since such a parameter contributes no literal and no constant, and the second warm-up then replaces the first rather than adding to the cache. Rejecting an unreferenced signature parameter would remove that case, but it is a new validation rule and belongs with the others in the first PR, not here.

What is not warmed

A parameter declared with a schema template type — a struct, an enum, or an array of either — is not warmed when a case leaves it value-free. The built schema template drops its named types (auxiliaryTypes live in the builder, are used by resolveTypes() and are gone after build()), so there is nothing to resolve the name against. The failure comes from the planning path, where the resolution happens, and is contained like any per-case planning failure: logged, counted, that case skipped. A lookup by name on the built template is already on the team's task list; when it lands these queries start warming, with no change here.

Primitives, UUID, VECTOR(128, FLOAT) and arrays of primitives all resolve — vectorType is part of primitiveType, so no lookup is needed for any of them.

This is not a runtime limitation. A bound struct carries its own metadata, so the query plans and runs normally, only cold. Pinning the same parameter IS NULL is warmed too, since that case binds a real value and needs no type.

Behaviour changes

A stored query without a signature is planned exactly as before, with PreparedParams.empty() and one plan.

The behaviour change flagged in the first PR is resolved by this one: between them, a stored query that declared a signature failed warm-up and was skipped. It now warms.

Tests

PreparedCaseParamsTest, 7 cases, no database — the mapping from case to parameters is a pure function. What each state binds; that IS NOT NULL leaves no value; that declarations are passed on for every parameter whatever its state; a case mixing all three kinds; and that a value-free state with no declaration behind it is rejected here, so the failure names the missing declaration rather than surfacing later as a missing value.

StoredQueriesTest gains four cases, all against FDB. That one stored query with two cases warms two plans. That both are reachable from a client — proved by the cached-plan count not moving, since a miss would plan afresh and insert, which matters because the failure mode here is a silent miss and asserting on returned rows would not catch it. That a boolean pinned to each value gets a plan per value and each binding finds its own. And that a parameter type warm-up cannot resolve skips only that stored query, leaving the next one warmed — the same containment the temp-function failures already had.

Documentation

STORED_QUERY.rst gains a What is not warmed section — the general shape of a warm-up failure, the filtered-index case, and the schema template type — and a note in Signature that a template type in a signature needs the TYPE keyword, unlike a column definition which takes the bare name. That difference between columnType and functionColumnType was not derivable from the docs, and it is the mistake I made myself while writing the tests.

Stack

  1. Add a typed named parameter signature and PREPARE FOR to CREATE STORED QUERY — declares each parameter's name, type and nullability, enumerates the plans to warm for them, and persists both alongside the query text.
  2. Plan a named parameter from its declared type without a value — lets a query be planned when a parameter has a type but no value, and the declared type alone says which bindings the plan serves.
  3. {this PR} Warm one plan per prepared case — builds each plan at startup through the prepared-statement path, and reports how many plans the cache was filled with.

@sergei-pustovykh sergei-pustovykh added the enhancement New feature or request label Sep 7, 2026
@sergei-pustovykh
sergei-pustovykh force-pushed the apple/sergei-pustovykh/stored-query/warm-up branch from 46c4dd3 to d211368 Compare September 7, 2026 22:13
Warm-up built one plan per stored query. A stored query with a signature now
gets one plan per prepared case, planned the way a client's prepared statement
would be, so a client whose bindings fit a case finds a plan already built for
it.

PreparedCaseParams turns one case into the parameters to plan it with. IS_NULL,
IS_TRUE and IS_FALSE bind a real value, so the planner folds predicates exactly
as it would at run time; IS_NOT_NULL binds nothing and the parameter is planned
from its declaration instead. The value map is a HashMap rather than Map.of
because IS_NULL binds a real null, and the distinction is load-bearing:
hasNamedParamValue asks containsKey, so a parameter bound to null is value-bound
and reaches folding, while one merely absent would be planned value-free.

Warm-up resolves no types. Declarations are passed on as the text the signature
stored, and the planning path turns one into a type when it meets the parameter.
Every declaration is passed on, not just the ones a case leaves value-free,
because a declaration is only read for a parameter that carries no value — so
nothing has to be filtered per case and PreparedCaseParams stays a pure
function, with no database, schema template or planner behind it.

Temporary functions are planned inside the per-case loop rather than once per
query, because a signature parameter captured by a function's body changes that
function's plan too, so each case needs its own compile from the original
template with a fresh factory. Compiling them once would have leaked one case's
folded function into the next.

A case that fails no longer skips the rest: the plan for the null case failing
is no reason to give up the plan for the non-null one. queriesProcessed still
counts queries, one increment either way, and the new plansWarmed says how much
the cache was actually filled — an upper bound, since two cases differing only
in a parameter the body never references produce the same plan under the same
constraint.

A parameter declared with a schema template type is not warmed when a case
leaves it value-free: the built template keeps no named types, so the
declaration cannot be resolved. The failure comes from the planning path and is
contained like any per-case planning failure. A lookup by name on the built
template is planned separately; when it lands these queries start warming with
no change here.
@sergei-pustovykh
sergei-pustovykh force-pushed the apple/sergei-pustovykh/stored-query/warm-up branch from 5d6ed4b to 8484644 Compare September 8, 2026 09:39
@sergei-pustovykh
sergei-pustovykh marked this pull request as ready for review September 8, 2026 13:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant