Skip to content

feat: cache query plans for queries using query-module functions - #4461

Merged
DavIvek merged 6 commits into
masterfrom
cache-query-module-function-plans
Jul 23, 2026
Merged

feat: cache query plans for queries using query-module functions#4461
DavIvek merged 6 commits into
masterfrom
cache-query-module-function-plans

Conversation

@DavIvek

@DavIvek DavIvek commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Problem

Any query that calls a query-module (user-defined) function — e.g. RETURN mymodule.myfunc(x) — was marked non-cacheable at parse time (is_cacheable &= !IsUserDefined()). The whole query was re-parsed and re-planned on every execution, which is expensive for large queries (#1611: ~200ms re-plan per run for a query containing a magic function).

The gate existed for a real reason: a cached plan baked the function's callable and a shared_ptr to its module into the Function AST node. Caching that would (a) run stale code after a module reload and (b) pin the module — mg.load refuses to reload a module while use_count != 1.

Fix

Resolve module functions per-execution instead of baking them into the plan:

  • Function stores only is_user_defined_ + a slot index (user_function_id_); no module reference.
  • AstStorage::user_functions_ records the distinct module-function names (same shape as labels_/properties_).
  • PullPlan resolves them once per execution into EvaluationContext::resolved_user_functions, which keeps the backing modules alive for the query's duration only.
  • Evaluators read the callable from that table (O(1) slot), with an on-demand fallback for cold paths (e.g. PrimitiveLiteralExpressionEvaluator).
  • The parse-time cacheability gate is removed.

Net effect: module-function queries are cached (no re-parse/re-plan), while reload stays safe — idle cached plans hold no module reference (never block a reload), and the next execution re-resolves and picks up new code automatically.

The CALL procedure gate is intentionally left in place; its YIELD result-name binding is a separate concern.

Closes #1611

Queries calling user-defined (query-module) functions were marked
non-cacheable at parse time, so the whole query was re-parsed and
re-planned on every execution. The gate existed because a cached plan
baked in the function's callable plus a shared_ptr to its module: that
would run stale code after a reload and pin the module, which reload
refuses to unload while use_count != 1.

Resolve module functions per-execution instead. The Function AST node
now stores only is_user_defined_ and a slot index into a per-execution
table built in the PullPlan constructor from AstStorage::user_functions_.
The table holds the module references for the query's duration only, so
idle cached plans never block a reload and the next execution re-resolves
and picks up new code automatically. A cold-path fallback resolves on
demand for evaluators without the table.

The CALL procedure gate is left as-is; its YIELD result-name binding is
a separate concern.

Tests: unit coverage that a magic-function query is now cacheable and
survives the AST cache/clone path, plus an e2e reload test asserting the
cached query is not blocked on reload and re-resolves to the new impl.
Copilot AI review requested due to automatic review settings July 21, 2026 21:11
@DavIvek DavIvek added this to the mg-v3.13.0 milestone Jul 21, 2026
@DavIvek DavIvek added Docs - changelog only Docs - changelog only CI -build=release -test=e2e Run release build and e2e tests on push labels Jul 21, 2026
@DavIvek DavIvek self-assigned this Jul 21, 2026
@DavIvek
DavIvek marked this pull request as draft July 21, 2026 21:12
@DavIvek DavIvek added CI -build=coverage -test=core Run coverage build and core tests on push CI -build=debug -test=core Run debug build and core tests on push CI -build=release -test=core Run release build and core tests on push labels Jul 21, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR enables query plan caching for Cypher queries that call query-module (user-defined) functions by removing module/shared_ptr callables from the cached AST and instead resolving UDF callables per execution (keeping modules alive only for the execution lifetime). This addresses repeated re-planning overhead while preserving safe module reload behavior.

Changes:

  • Refactors Function AST nodes to store only a UDF flag + slot id (no module references), and records distinct UDF names in AstStorage::user_functions_.
  • Adds per-execution resolution of user-defined functions into EvaluationContext::resolved_user_functions, and updates evaluators to dispatch via the resolved table (with on-demand fallback).
  • Adds unit + e2e coverage to validate cacheability and safe module reload with cached plans.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/unit/cypher_main_visitor.cpp Updates UDF expectations and adds a cacheability/unit-storage test for magic functions.
tests/e2e/python_query_modules_reloading/test_reload_query_module.py Adds an e2e test ensuring cached UDF queries don’t block mg.load and pick up reloaded code.
tests/e2e/python_query_modules_reloading/procedures/reload_func_module.py New query module used by the e2e reload test.
tests/e2e/python_query_modules_reloading/procedures/CMakeLists.txt Ensures the new module file is copied into the e2e environment.
src/query/interpreter.cpp Resolves user-defined functions once per execution into the evaluation context.
src/query/interpret/eval.hpp Adds per-call selection logic to use per-execution resolved UDFs or resolve on demand.
src/query/interpret/awesome_memgraph_functions.hpp Introduces ResolvedUserFunctions and APIs for resolving UDFs.
src/query/interpret/awesome_memgraph_functions.cpp Implements per-execution and on-demand UDF resolution via the module registry.
src/query/frontend/ast/cypher_main_visitor.cpp Removes the parse-time “UDF => non-cacheable” gate; assigns UDF slot ids into AST storage.
src/query/frontend/ast/ast.hpp Refactors Function to avoid embedding module refs/callables for UDFs; stores UDF metadata instead.
src/query/frontend/ast/ast_storage.hpp Adds user_functions_ intern table and AddUserFunction helper.
src/query/cypher_query_interpreter.cpp Copies user_functions_ through the AST cache clone path.
src/query/context.hpp Adds resolved_user_functions to EvaluationContext.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/query/interpreter.cpp
Comment thread src/query/frontend/ast/ast.hpp Outdated
DavIvek added 2 commits July 22, 2026 11:14
Trigger::GetPlan copied the AST name tables into a fresh AstStorage but
dropped user_functions_, and Trigger::Execute built its own
ExecutionContext without calling ResolveUserFunctions. A trigger whose
statement calls a query-module function therefore fell onto the per-call
on-demand resolution path, and a concurrent module reload could swap the
implementation between rows.

Copy user_functions_ in GetPlan and resolve it into the trigger's
EvaluationContext in Execute, matching PullPlan. Also correct the
Function::user_function_id_ comment to reference EvaluationContext.
- collapse ResolvedUserFunctions to a single vector<user_func> (drop the
  parallel callables/modules invariant; modules were never indexed)
- SelectedFunction wraps a variant<const func_impl*, user_func> instead
  of an out-param whose returned pointer aliased a caller local
- MG_ASSERT the resolved-slot invariant (this branch is user-defined-only)
- assign user_function_id_ in the planner-v2 AST builder too, so v2 uses
  the resolved table instead of the per-call fallback
- drop the now-dead module_fwd.hpp include and a stale module-lock comment

Tests: function removed by reload raises, multiple user functions in one
query, and a trigger calling a module function across a reload.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.

Comment thread tests/e2e/python_query_modules_reloading/test_reload_query_module.py Outdated
@DavIvek
DavIvek requested review from Ignition and imilinovic July 22, 2026 12:03
@DavIvek
DavIvek marked this pull request as ready for review July 22, 2026 12:03
@sonarqubecloud

Copy link
Copy Markdown

Comment thread src/query/frontend/ast/ast_storage.hpp Outdated
@DavIvek DavIvek added CI -build=coverage -test=clang_tidy and removed CI -build=coverage -test=core Run coverage build and core tests on push CI -build=debug -test=core Run debug build and core tests on push CI -build=release -test=e2e Run release build and e2e tests on push labels Jul 23, 2026
@DavIvek
DavIvek enabled auto-merge July 23, 2026 12:08
@DavIvek

DavIvek commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Tracking

  • [Link to Epic/Issue]

Standard development

CI Testing Labels

  • Select the appropriate CI test labels (CI -build=build-name -test=test-suite)

Documentation checklist

  • Add the documentation label
  • Add the bug / feature label
  • Add the milestone for which this feature is intended
    • If not known, set for a later milestone
  • Write a release note, including added/changed clauses
    • Improves query performance by allowing queries that use user-defined module functions to benefit from query caching instead of being re-parsed and re-planned on every execution. Module reloads remain safe, and cached queries automatically pick up updated function implementations on the next execution. #4461
  • [ Documentation PR link memgraph/documentation#XXXX ]
    • Is back linked to this development PR

@DavIvek
DavIvek added this pull request to the merge queue Jul 23, 2026
Merged via the queue into master with commit f0d13d3 Jul 23, 2026
36 checks passed
@DavIvek
DavIvek deleted the cache-query-module-function-plans branch July 23, 2026 13:59
seuros pushed a commit to seuros/memgraph that referenced this pull request Jul 28, 2026
…queries (memgraph#4482)

## Problem

`CALL mod.proc() YIELD ...` queries were non-cacheable, so every
execution re-parsed and re-planned. Parsing a `CALL` bakes the
procedure's result fields (including `YIELD *`), its `is_write`
classification and its `required_privilege` into the AST. The last two
are consumed before execution starts, to pick the storage accessor and
to authorize, so a reload needs a re-parse, not just a re-plan.

Procedure counterpart to memgraph#4461, which made query-module functions
cacheable via per-execution binding.

## Changes

- **Cache procedure queries.** `ModuleRegistry` gains an atomic
generation counter, bumped on any change to the loaded module set.
Cached ASTs and plans are stamped at build and rebuilt when stale, so a
reload re-parses and re-plans on next execution. The plan inherits the
AST's generation, so a reload between parse and prepare cannot stamp a
stale plan as fresh.

- **Scope invalidation to dependent queries.** A global stamp
invalidated every cached AST and plan on any module load, including
queries naming no module. `AstStorage` now records `call_procedures_`
next to `user_functions_`, and `DependsOnModules()` gates the check.
Queries touching no module survive a reload untouched, and procedures
and module functions end up under one rule.

- **Two crash paths closed.** `CallProcedureCursor` dereferenced a
past-the-end iterator when a yielded field was missing. The
user-function slot bounds check was an unconditional assert, aborting
the process in release. Both now throw.

- **Derive a plan's required indices once**, at construction, instead of
re-walking the plan on every cache hit. Benefits all cached queries.

## Not addressed

Triggers have no generation check and never re-parse, so a signature or
privilege change is picked up only on recreate. Pre-existing; needs
trigger re-parse, which touches durability.

---------

Co-authored-by: Gareth Andrew Lloyd <gareth.lloyd@memgraph.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CI -build=coverage -test=clang_tidy CI -build=release -test=core Run release build and core tests on push Docs - changelog only Docs - changelog only

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Adding a query module function call triggers query plan cost recalculation on same query.

3 participants