Skip to content

Warm one plan per prepared case - #4572

Open
sergei-pustovykh wants to merge 4 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 4 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.
he cached-plan count not moving, which is the only way to catch a silent miss; PreparedCaseParamsTest covers the mapping without a database.

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

Where to look

  1. OfflineStoredQueriesProcessor.planStoredQuery — the per-case loop, and all five counter increments in one place.
  2. planOneCase in the same class — why the temporary functions are compiled inside the loop rather than once.
  3. PreparedCaseParams.of — the whole of what a case decides: which parameters get a value and which are left without one.
  4. Tests: StoredQueriesTest proves a hit by t

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 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.

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, and asserts the counters alongside the cached-plan count: two plans warmed and none failed for one query with two cases, and — where a declaration cannot be resolved — one warmed, one failed, one query processed and one failed. That last set is what catches a counter incremented per query where it should be per case, since a wrong increment would make the four numbers agree. 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
@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
@sergei-pustovykh
sergei-pustovykh force-pushed the apple/sergei-pustovykh/stored-query/warm-up branch from e68191a to 41bbcc9 Compare September 8, 2026 16:21
@sergei-pustovykh
sergei-pustovykh force-pushed the apple/sergei-pustovykh/stored-query/warm-up branch 2 times, most recently from f513766 to ffa198d Compare September 11, 2026 11:44
@sergei-pustovykh
sergei-pustovykh force-pushed the apple/sergei-pustovykh/stored-query/warm-up branch from ffa198d to e40e619 Compare September 11, 2026 13:36
@sergei-pustovykh
sergei-pustovykh force-pushed the apple/sergei-pustovykh/stored-query/warm-up branch from e40e619 to 47d3f8c Compare September 11, 2026 14:15
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.
The cases this PR warms are written in the same shape as everywhere else.
Follows the rename in the layers below; comments and one message
fragment a test matches on.
@sergei-pustovykh
sergei-pustovykh force-pushed the apple/sergei-pustovykh/stored-query/warm-up branch from 47d3f8c to 3a16f71 Compare September 11, 2026 14:59
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