Skip to content

Python: reuse implicit pybind prepared statements for repeated parameterized execute() calls#38

Merged
adsharma merged 8 commits into
LadybugDB:mainfrom
ericyuanhui:Prepare_Cached
Jul 9, 2026
Merged

Python: reuse implicit pybind prepared statements for repeated parameterized execute() calls#38
adsharma merged 8 commits into
LadybugDB:mainfrom
ericyuanhui:Prepare_Cached

Conversation

@ericyuanhui

Copy link
Copy Markdown
Contributor

Summary
This PR fixes a memory growth issue in the Python API when using the pybind backend for parameterized string queries.
Previously, repeated calls to:
conn.execute(query, params)
would implicitly prepare the same query text on every execution. Since the native prepared statement cache does not currently evict these entries, long-running workloads could accumulate prepared statements and show steadily increasing RSS.
This PR changes the pybind execution path to reuse implicitly prepared statements per connection.

Root cause
Before this change, _execute_with_pybind() effectively behaved like:
prepared = py_connection.prepare(query, parameters)
return py_connection.execute(prepared, parameters)
for every parameterized string query.
As a result:
identical query text was prepared repeatedly
each prepare created a new native cached prepared statement
the native cache grew without bound for this workload pattern

Solution
This PR adds a small connection-local cache for implicitly prepared pybind statements.
Behavior after this change:
only applies to pybind execution of string queries with non-empty parameters
the normalized query string is used as the cache key
the first execution prepares and stores the statement
subsequent executions of the same query reuse the cached prepared statement
the cache is cleared when the Connection is closed
This keeps the fix narrowly scoped to the buggy path and avoids changing explicit prepare() / execute(prepared_stmt, params) behavior.

Why this approach
This fix is intentionally implemented at the Python layer rather than by changing native prepared statement manager behavior.
Reasons:
it directly addresses the source of the repeated implicit prepares
it minimizes behavioral change outside the affected pybind path
it keeps explicit prepared statement semantics unchanged
it avoids introducing a broader eviction policy change in native code

Tests
This PR adds regression tests covering:
repeated execution of the same parameterized query only prepares once
different query strings do not share cached prepared statements
non-parameterized queries still use the direct query() path
closing the connection clears the implicit prepared statement cache

Result
With this change, the repro workload no longer allocates one new native prepared statement per batch in the implicit pybind path, so the monotonic RSS growth is substantially reduced.

fix issue :#37

@ericyuanhui

Copy link
Copy Markdown
Contributor Author

@adsharma The CI pipeline is stuck right now. How can I trigger it?

@ericyuanhui

Copy link
Copy Markdown
Contributor Author
image

@adsharma

adsharma commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Probably some github issue a few hours ago. Manually triggered.

Signed-off-by: ericyuanhui <285521263@qq.com>
adsharma added 2 commits July 9, 2026 09:26
Two bugs fixed in the test:

1. Add close() method to _FakeResult — QueryResult.close() calls
   self._query_result.close() internally; the missing method caused
   AttributeError during teardown on C-API CI.

2. Fix _get_pybind_connection monkeypatch to store fake_pybind in
   self._py_connection — the bare lambda returned fake_pybind but
   never stored it, so Connection.close() never called
   fake_pybind_connection.close(), causing
   'assert fake_pybind_connection.closed is True' to fail.

The test runs on both pybind and C-API CI jobs. These changes ensure
it passes on C-API without affecting the pybind behavior.
Two failure modes were present in the implicit prepared-statement cache
on Connection:

1. C-API path: every parameterized execute() called _prepare() which
   triggered a fresh lbug_connection_prepare, never reusing the
   previously prepared statement.  RSS would grow without bound on
   workloads that re-issued the same query.

2. pybind path: the cached PreparedStatement was reused, but when a
   caller's parameter *shape* changed (e.g. MAP -> STRUCT, or two
   STRUCTs with different field names), the upstream C++ plan
   (cachedPreparedStatementManager) stayed bound to the first shape's
   plan while the prepared statement's parameterMap was rebound in
   place.  canReuseCachedPlanWith then returned true on the third call
   and the executor ran the stale plan against the new shape,
   segfaulting in Value::copyFromRowLayout.  Reproduced by
   test_parameter.py::test_dict_conversion (MAP, STRUCT, STRUCT).

The C-API's "fix" of binding to a fresh temp per execute (commit
4afc0ad) traded correctness for cost: it always re-prepares, defeating
the cache entirely.

Fix:

* Cache key is (query, param_signature) instead of just query.  The
  signature is a structural hash of the parameter *type* (not values):
  MAP vs STRUCT, STRUCT field names, MAP key/value element types.  Two
  callers passing the same query with different shapes get independent
  prepared statements whose plans stay consistent with their bound
  values, avoiding the upstream mismatch.

* Per-connection RLock (_prepared_cache_lock) serializes prepare + bind
  + execute so concurrent threads/async tasks cannot tear the cached
  entry's mutable bound state.  The C++ side already takes its own mtx
  inside executeWithParams; the Python lock only needs to cover the
  C-API _bound_values map and the cache itself.

* close() walks the cache and calls close() on each entry to destroy
  the C++ lbug_prepared_statement; the C-API wrapper has no __del__ so
  the entries would otherwise leak their _prepared_statement and
  _bound_values C++ allocations.

* _get_or_prepare_pybind_statement and _execute_with_pybind hold the
  lock around prepare+bind+execute.

* Tests:
  - test_pybind_implicit_prepare_cache.py: updated for the new tuple
    cache key and added two tests that pin the new
    shape-segregation behavior (3 distinct shapes -> 3 prepares; same
    shape, different values -> 1 prepare).
  - test_pybind_implicit_prepare_cache_threading.py: NEW.  Reproduces
    the corruption in the pybind backend with 2/8/32 threads on a
    single shared connection.  Currently FAILS on the pybind backend
    (to be debugged separately) and PASSES on the C-API backend.
adsharma added 5 commits July 9, 2026 10:56
test_fsm_reclaim_node_table_delete and test_fsm_reclaim_rel_table_delete
were failing because get_used_page_ranges() pulled in PK index pages
(start_page_idx 31..51 in the vPerson fixture) and asserted they were
a subset of the post-checkpoint free pages. match ... delete only
reclaims data pages; the table and its persistent hash index stay
around for future inserts, so those pages legitimately remain in use.

Add get_used_node_data_page_ranges() that filters storage_info by
table_type = 'NODE' and switch the two _delete tests to use it. The
drop-table tests keep the original get_used_page_ranges() since drop
removes the index along with the table.

Likely broken by ladybug 9cd338e3f ('Fix hash index storage accounting
and show built-in PK indexes'), which gave the hash index a
getStorageEntries() implementation -- combined with 8a8c0f13f ('Report
ART index storage in info UDFs') that wired appendStorageInfoForIndex
into storage_info, this caused PK index pages to start showing up in
storage_info output for the first time. The test was originally added
in 8a55bc6 / C++ eaf97b755 ('Reclaim pages for fully-deleted node
groups') when storage_info returned only NODE/REL data pages.
…ndex

 In numpy_scan.cpp, the OBJECT/STRING scan path uses pos = i +
 offset (the absolute row index in the source array) as the output
 vector index for setNull() when handling None or NaN values:

 ```cpp
   // BEFORE (buggy):
   outputVector->setNull(pos, true /* isNull */);  // pos = i + offset
 ```

 The output vector is sized for DEFAULT_VECTOR_CAPACITY (= 2048)
 entries and expects 0-based indices within each batch. But pos is
 the absolute dataframe row index. When the COPY spans multiple
 batches (i.e., when offset >= DEFAULT_VECTOR_CAPACITY), pos exceeds
 the null mask buffer, causing a heap buffer overrun → the segfault
 / free(): invalid next size / munmap_chunk(): invalid pointer
 crashes seen on Linux.
…s_equal NaN-aware

The test generated float32 values by taking random 32-bit patterns and
reinterpreting them as IEEE 754 floats. About 0.4% of those patterns are
NaN (all-ones exponent, nonzero mantissa), so the floatcol contained
dozens of NaN values at the larger scale factors.

pyarrow.Array == Array follows the Arrow spec (NaN != NaN), so
tables_equal()'s element-wise comparison would report two tables that
differ only by NaN positions as not-equal. Locally, pa.Table.from_pandas
converts NaN to None when building the expected patable, masking the
issue. In CI (different pyarrow/pandas versions) the NaN survives in
both the expected and scanned tables and the comparison fails with
'tables are not equal'.

Two-part fix:

- generate_primitive() for float32 now retries until it gets a non-NaN
  bit pattern. Subnormals, +/-Inf, signed zero, etc. are all still
  exercised, just not NaN.

- tables_equal() routes floating columns through
  numpy.array_equal(equal_nan=True), which is the correct semantic for
  a round-trip scan test. Non-floating columns keep the cheap
  pyarrow.Array != Array check.

Related: ladybug #647 (data corruption in the DataFrame scan path on
Linux, fixed in 530c5ec for numpy_scan.cpp). This is a different bug
in the test itself, not in the scan path.
@adsharma

adsharma commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

@ericyuanhui - please review the rest of the commits and open an issue if you see anything still broken.

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.

2 participants