Skip to content

Fix state reuse on the cached-physical-plan fast path (0-row scans, live-result clobbering) - #870

Merged
adsharma merged 1 commit into
mainfrom
fix/cached-plan-reuse
Aug 31, 2026
Merged

Fix state reuse on the cached-physical-plan fast path (0-row scans, live-result clobbering)#870
adsharma merged 1 commit into
mainfrom
fix/cached-plan-reuse

Conversation

@adsharma

Copy link
Copy Markdown
Contributor

Summary

Two bugs in the cached-physical-plan fast path (Phases 1-3) cause wrong results on the second execution of the same query string:

1. Table-function scans return 0 rows on re-execution

TableFunctionCall::copy() shares the TableFuncSharedState, and prepareForReuse() calls sharedState->resetState() to rewind the scan position. FTableScanSharedState overrides resetState(), but SimpleTableFuncSharedState (pandas/polars/arrow scans and most simple table functions) inherited the no-op base, so curRowIdx stayed at numRows and every getMorsel() returned an invalid morsel.

User-visible effect: LOAD FROM df followed by LOAD FROM $df RETURN * returned 0 rows / "No more tuples in QueryResult" (the Python layer rewrites both to the same string, so the second execution takes the fast path). Reproduced down to getMorsel() logging: fresh initSharedState was never called on the fast path and the stale cursor served invalid morsels to all worker threads.

Fix: override resetState() to reset curRowIdx = 0, mirroring FTableScanSharedState.

2. ResultCollector::prepareForReuse() clobbered live QueryResults

The plan template shares the ResultCollectorSharedState (and its FactorizedTable) with every executed clone, and prepareForReuse() unconditionally clear()ed that table. A QueryResult from a previous execution holds a shared_ptr to the same table, so overlapping executions (e.g. AsyncConnection's pool running RETURN $1 concurrently across 4 connections) corrupted live results: queries returned other queries' rows or empty tables — test_async_prepare_and_execute_concurrent asserted [96] == [1].

Fix: clear-and-reuse the table only when no external QueryResult still references it (use_count() == 1, which keeps the Phase 2 block-reuse fast path for sequential loops); otherwise hand the execution a fresh table with the same schema and let the old table live until its QueryResult is destroyed.

Test plan

Unblocks CI on LadybugDB/ladybug-python#53 (regression test for #866).

The cached-physical-plan fast path (Phase 1-3, #78e1ccfd4/e103a49f2/
80fa473) clones the template operator tree per execution and calls
prepareForReuse(). Two pieces of mutable state were not handled:

1. Table-function scans silently returned 0 rows on re-execution.

TableFunctionCall::copy() shares the TableFuncSharedState, and
prepareForReuse() calls sharedState->resetState() to reset the scan
position. FTableScanSharedState overrides resetState(), but
SimpleTableFuncSharedState - used by pandas/polars/arrow scans and most
simple table functions - inherited the no-op base implementation, so
curRowIdx stayed at numRows and every getMorsel() returned an invalid
morsel. Concretely: executing "LOAD FROM df" (which the Python layer
rewrites to "LOAD FROM $df") followed by "LOAD FROM $df RETURN *"
returned 0 rows / "No more tuples in QueryResult".

Fix: override resetState() to reset curRowIdx, mirroring
FTableScanSharedState.

2. ResultCollector::prepareForReuse() clobbered live QueryResults.

The plan template shares the ResultCollectorSharedState (and its
FactorizedTable) with every executed clone, and prepareForReuse()
unconditionally clear()ed that table. A QueryResult from a previous
execution holds a shared_ptr to the same table, so overlapping
executions (e.g. AsyncConnection's pool) corrupted live results:
queries returned other queries' rows or empty tables
(test_async_prepare_and_execute_concurrent asserted [96] == [1]).

Fix: clear-and-reuse the table only when no QueryResult still
references it (use_count() == 1); otherwise give the execution a fresh
table with the same schema and let the old one live until its
QueryResult is destroyed. The sequential loop case keeps the Phase 2
block-reuse fast path.

Verified: full Python suite (280 tests) passes including
test_scan_pandas and test_async_connection; api_test, c_api_test, and
734 e2e tests pass (remaining failures pre-exist on main).

Unblocks CI on LadybugDB/ladybug-python#53.
@adsharma
adsharma merged commit 47443cd into main Aug 31, 2026
4 checks passed
@adsharma
adsharma deleted the fix/cached-plan-reuse branch August 31, 2026 03:43
adsharma added a commit that referenced this pull request Aug 31, 2026
… switch

Add a global (client-context) configuration option to gate the cached-
physical-plan fast path for parameterized prepared statements:

    CALL enable_cached_prepared_statement='reads'   -- cache read plans only
    CALL enable_cached_prepared_statement='writes'  -- cache write plans only
    CALL enable_cached_prepared_statement='both'    -- cache all (default)
    CALL enable_cached_prepared_statement='none'    -- disable plan caching

Values are case-insensitive; anything else fails with an error listing the
supported inputs. The setting is registered like other client settings, so
it round-trips through current_setting('enable_cached_prepared_statement')
and is settable from every API (SQL CALL, Python/Java/Node set via SQL).

This is a safety valve for latent state-reuse bugs in the plan cache (the
class of bugs fixed by #841, #870 and #877): users who hit a misbehaving
query shape can disable or narrow the optimization without a code change,
instead of having to choose between wrong results and giving up on
parameterized queries. The default, BOTH, preserves the current behaviour
(parameterized reads and writes both take the fast path).

Enforcement lives in ClientContext::executeNoLock(), gating both cache
reuse and cache population, so a disabled scope never serves or fills the
plan cache regardless of which execute path is taken. A statement rejected
by the scope simply maps its physical plan fresh on every execution.

Regression coverage in prepare_test.cpp:
- setting round-trip via current_setting, invalid value rejected
- per-scope cache-population checks (read vs write statements, via
  CachedPreparedStatement::physicalPlanCache):
  BOTH caches both, READS caches reads only, WRITES caches writes only,
  NONE caches nothing
- repeated executions return correct results in every scope, including
  NONE where every execution re-maps the plan
adsharma added a commit that referenced this pull request Aug 31, 2026
…hed-plan fast path (#877)

Re-executing the same parameterized query string (the recommended
execute(query, params) form, which reuses the cached physical plan) returned
the first execution's rows whenever the plan contained a sort, top-k, join,
cartesian product, OPTIONAL MATCH, UNION, EXISTS subquery, LIMIT/SKIP or
recursive extend. Clean output, no error — callers could not detect it.

Two root causes, both from per-execution state surviving across executions
of the cached operator tree (same family as #841 / #870):

1. Whole sub-pipelines vanished from the cloned plan template. Several
   operator copy() implementations only cloned children[0] and dropped the
   children attached later by the plan mapper (build sides, sort sinks,
   union collectors). ProcessorTask::run() and the fast path both clone
   through copy(), so the corresponding sink pipelines never ran again on
   later executions and operators kept serving execution 1's data:

   - OrderByScan / OrderByMerge / TopKScan (ORDER BY, top-k)
   - HashJoinProbe (build side — traversals, OPTIONAL MATCH, EXISTS, SIP)
   - Intersect, CrossProduct, PathPropertyProbe
   - UnionAllScan (UNION / UNION ALL)
   - RecursiveExtend, TableFunctionCall (FTable scans, recursive extend)
   - Profile, DummySimpleSink

2. Shared states accumulated per-execution state that was never reset:

   - SortSharedState kept payload tables / sorted key blocks / string key
     col info; KeyBlockMergeTaskDispatcher kept active merge tasks.
   - Limit / Skip counters stayed exhausted after the first execution.
   - HashJoinSharedState kept the previous execution's rows and hash slots.
   - UnionAllScanSharedState kept the previous scan cursors.
   - SemiMaskerSharedState re-merged previous local masks into the global
     node-offset masks (recursive extend).
   - RecursiveExtendSharedState kept its limit counter and factorized-table
     pool contents.
   - ResultCollector: internal collectors (union branches, cross-product /
     accumulate / SIP builds) are only read by other operators of the same
     plan, so they are now always cleared in place instead of being
     replaced with a fresh table the readers cannot see. Only the plan
     root's table (handed to the client via getQueryResult()) keeps the
     use_count-based fresh-table behavior for overlapping executions.

Fixes:
- copy() implementations preserve all children.
- Per-execution state is reset on the hooks that run once per execution:
  initGlobalStateInternal() (Limit, Skip, HashJoinBuild, UnionAllScan,
  BaseSemiMasker, SortSharedState::init, KeyBlockMergeTaskDispatcher::init)
  and prepareForReuse() (RecursiveExtend).
- ResultCollector distinguishes internal vs client-facing result tables.

The Python AsyncConnection.execute(PreparedStatement, ...) UnboundLocalError
mentioned in the issue does not reproduce on current main (fixed earlier by
the explicit conn_index handling in async_connection.py).

Regression test: ApiTest.RepeatedParameterizedCachedPlanExecution877
fails on unpatched main and passes with this change.

Validation: full Python suite (153 passed), e2e suite (1961 passed; the
single dictionary_bug~orb383 failure pre-exists on clean main), repeated
overlap/concurrency tests on AsyncConnection pools.
adsharma added a commit that referenced this pull request Aug 31, 2026
… switch

Add a global (client-context) configuration option to gate the cached-
physical-plan fast path for parameterized prepared statements:

    CALL enable_cached_prepared_statement='reads'   -- cache read plans only
    CALL enable_cached_prepared_statement='writes'  -- cache write plans only
    CALL enable_cached_prepared_statement='both'    -- cache all (default)
    CALL enable_cached_prepared_statement='none'    -- disable plan caching

Values are case-insensitive; anything else fails with an error listing the
supported inputs. The setting is registered like other client settings, so
it round-trips through current_setting('enable_cached_prepared_statement')
and is settable from every API (SQL CALL, Python/Java/Node set via SQL).

This is a safety valve for latent state-reuse bugs in the plan cache (the
class of bugs fixed by #841, #870 and #877): users who hit a misbehaving
query shape can disable or narrow the optimization without a code change,
instead of having to choose between wrong results and giving up on
parameterized queries. The default, BOTH, preserves the current behaviour
(parameterized reads and writes both take the fast path).

Enforcement lives in ClientContext::executeNoLock(), gating both cache
reuse and cache population, so a disabled scope never serves or fills the
plan cache regardless of which execute path is taken. A statement rejected
by the scope simply maps its physical plan fresh on every execution.

Regression coverage in prepare_test.cpp:
- setting round-trip via current_setting, invalid value rejected
- per-scope cache-population checks (read vs write statements, via
  CachedPreparedStatement::physicalPlanCache):
  BOTH caches both, READS caches reads only, WRITES caches writes only,
  NONE caches nothing
- repeated executions return correct results in every scope, including
  NONE where every execution re-maps the plan
adsharma added a commit that referenced this pull request Aug 31, 2026
… switch

Add a global (client-context) configuration option to gate the cached-
physical-plan fast path for parameterized prepared statements:

    CALL enable_cached_prepared_statement='reads'   -- cache read plans only
    CALL enable_cached_prepared_statement='writes'  -- cache write plans only
    CALL enable_cached_prepared_statement='both'    -- cache all (default)
    CALL enable_cached_prepared_statement='none'    -- disable plan caching

Values are case-insensitive; anything else fails with an error listing the
supported inputs. The setting is registered like other client settings, so
it round-trips through current_setting('enable_cached_prepared_statement')
and is settable from every API (SQL CALL, Python/Java/Node set via SQL).

This is a safety valve for latent state-reuse bugs in the plan cache (the
class of bugs fixed by #841, #870 and #877): users who hit a misbehaving
query shape can disable or narrow the optimization without a code change,
instead of having to choose between wrong results and giving up on
parameterized queries. The default, BOTH, preserves the current behaviour
(parameterized reads and writes both take the fast path).

Enforcement lives in ClientContext::executeNoLock(), gating both cache
reuse and cache population, so a disabled scope never serves or fills the
plan cache regardless of which execute path is taken. A statement rejected
by the scope simply maps its physical plan fresh on every execution.

Regression coverage in prepare_test.cpp:
- setting round-trip via current_setting, invalid value rejected
- per-scope cache-population checks (read vs write statements, via
  CachedPreparedStatement::physicalPlanCache):
  BOTH caches both, READS caches reads only, WRITES caches writes only,
  NONE caches nothing
- repeated executions return correct results in every scope, including
  NONE where every execution re-maps the plan
adsharma added a commit that referenced this pull request Aug 31, 2026
… switch

Add a global (client-context) configuration option to gate the cached-
physical-plan fast path for parameterized prepared statements:

    CALL enable_cached_prepared_statement='reads'   -- cache read plans only
    CALL enable_cached_prepared_statement='writes'  -- cache write plans only
    CALL enable_cached_prepared_statement='both'    -- cache all (default)
    CALL enable_cached_prepared_statement='none'    -- disable plan caching

Values are case-insensitive; anything else fails with an error listing the
supported inputs. The setting is registered like other client settings, so
it round-trips through current_setting('enable_cached_prepared_statement')
and is settable from every API (SQL CALL, Python/Java/Node set via SQL).

This is a safety valve for latent state-reuse bugs in the plan cache (the
class of bugs fixed by #841, #870 and #877): users who hit a misbehaving
query shape can disable or narrow the optimization without a code change,
instead of having to choose between wrong results and giving up on
parameterized queries. The default, BOTH, preserves the current behaviour
(parameterized reads and writes both take the fast path).

Enforcement lives in ClientContext::executeNoLock(), gating both cache
reuse and cache population, so a disabled scope never serves or fills the
plan cache regardless of which execute path is taken. A statement rejected
by the scope simply maps its physical plan fresh on every execution.

Regression coverage in prepare_test.cpp:
- setting round-trip via current_setting, invalid value rejected
- per-scope cache-population checks (read vs write statements, via
  CachedPreparedStatement::physicalPlanCache):
  BOTH caches both, READS caches reads only, WRITES caches writes only,
  NONE caches nothing
- repeated executions return correct results in every scope, including
  NONE where every execution re-maps the plan
adsharma added a commit that referenced this pull request Aug 31, 2026
…hed-plan fast path (#877)

Re-executing the same parameterized query string (the recommended
execute(query, params) form, which reuses the cached physical plan) returned
the first execution's rows whenever the plan contained a sort, top-k, join,
cartesian product, OPTIONAL MATCH, UNION, EXISTS subquery, LIMIT/SKIP or
recursive extend. Clean output, no error — callers could not detect it.

Two root causes, both from per-execution state surviving across executions
of the cached operator tree (same family as #841 / #870):

1. Whole sub-pipelines vanished from the cloned plan template. Several
   operator copy() implementations only cloned children[0] and dropped the
   children attached later by the plan mapper (build sides, sort sinks,
   union collectors). ProcessorTask::run() and the fast path both clone
   through copy(), so the corresponding sink pipelines never ran again on
   later executions and operators kept serving execution 1's data:

   - OrderByScan / OrderByMerge / TopKScan (ORDER BY, top-k)
   - HashJoinProbe (build side — traversals, OPTIONAL MATCH, EXISTS, SIP)
   - Intersect, CrossProduct, PathPropertyProbe
   - UnionAllScan (UNION / UNION ALL)
   - RecursiveExtend, TableFunctionCall (FTable scans, recursive extend)
   - Profile, DummySimpleSink

2. Shared states accumulated per-execution state that was never reset:

   - SortSharedState kept payload tables / sorted key blocks / string key
     col info; KeyBlockMergeTaskDispatcher kept active merge tasks.
   - Limit / Skip counters stayed exhausted after the first execution.
   - HashJoinSharedState kept the previous execution's rows and hash slots.
   - UnionAllScanSharedState kept the previous scan cursors.
   - SemiMaskerSharedState re-merged previous local masks into the global
     node-offset masks (recursive extend).
   - RecursiveExtendSharedState kept its limit counter and factorized-table
     pool contents.
   - ResultCollector: internal collectors (union branches, cross-product /
     accumulate / SIP builds) are only read by other operators of the same
     plan, so they are now always cleared in place instead of being
     replaced with a fresh table the readers cannot see. Only the plan
     root's table (handed to the client via getQueryResult()) keeps the
     use_count-based fresh-table behavior for overlapping executions.

Fixes:
- copy() implementations preserve all children.
- Per-execution state is reset on the hooks that run once per execution:
  initGlobalStateInternal() (Limit, Skip, HashJoinBuild, UnionAllScan,
  BaseSemiMasker, SortSharedState::init, KeyBlockMergeTaskDispatcher::init)
  and prepareForReuse() (RecursiveExtend).
- ResultCollector distinguishes internal vs client-facing result tables.

The Python AsyncConnection.execute(PreparedStatement, ...) UnboundLocalError
mentioned in the issue does not reproduce on current main (fixed earlier by
the explicit conn_index handling in async_connection.py).

Regression test: ApiTest.RepeatedParameterizedCachedPlanExecution877
fails on unpatched main and passes with this change.

Validation: full Python suite (153 passed), e2e suite (1961 passed; the
single dictionary_bug~orb383 failure pre-exists on clean main), repeated
overlap/concurrency tests on AsyncConnection pools.
adsharma added a commit that referenced this pull request Aug 31, 2026
… switch

Add a global (client-context) configuration option to gate the cached-
physical-plan fast path for parameterized prepared statements:

    CALL enable_cached_prepared_statement='reads'   -- cache read plans only
    CALL enable_cached_prepared_statement='writes'  -- cache write plans only
    CALL enable_cached_prepared_statement='both'    -- cache all (default)
    CALL enable_cached_prepared_statement='none'    -- disable plan caching

Values are case-insensitive; anything else fails with an error listing the
supported inputs. The setting is registered like other client settings, so
it round-trips through current_setting('enable_cached_prepared_statement')
and is settable from every API (SQL CALL, Python/Java/Node set via SQL).

This is a safety valve for latent state-reuse bugs in the plan cache (the
class of bugs fixed by #841, #870 and #877): users who hit a misbehaving
query shape can disable or narrow the optimization without a code change,
instead of having to choose between wrong results and giving up on
parameterized queries. The default, BOTH, preserves the current behaviour
(parameterized reads and writes both take the fast path).

Enforcement lives in ClientContext::executeNoLock(), gating both cache
reuse and cache population, so a disabled scope never serves or fills the
plan cache regardless of which execute path is taken. A statement rejected
by the scope simply maps its physical plan fresh on every execution.

Regression coverage in prepare_test.cpp:
- setting round-trip via current_setting, invalid value rejected
- per-scope cache-population checks (read vs write statements, via
  CachedPreparedStatement::physicalPlanCache):
  BOTH caches both, READS caches reads only, WRITES caches writes only,
  NONE caches nothing
- repeated executions return correct results in every scope, including
  NONE where every execution re-maps the plan
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant