Summary
When using the Python API with the pybind backend, repeated calls to:
conn.execute(query, params)
for the same parameterized query can cause process RSS to grow over time.
This happens because the current pybind execution path implicitly prepares the query on every parameterized execution, even when the SQL/Cypher text is identical.
Reproduction
A minimal repro script is available:
repro_pybind_implicit_prepare_growth.py
The repro compares two modes:
implicit: repeatedly calls conn.execute(query, params)
explicit: prepares once with conn.prepare(query) and then repeatedly calls conn.execute(prepared_stmt, params)
The script intentionally uses a deterministic failing workload so that:
the query text remains constant
the parameterized pybind execution path is exercised repeatedly
memory behavior can be observed across many batches
Example command:
python3 tools/python_api/scripts/repro_pybind_implicit_prepare_growth.py --mode both
Observed behavior
In implicit mode, RSS trends upward across repeated batches.
=== RSS Summary (implicit) === batches=2000 rss_before_first_batch=74164 KB (72.43 MB) rss_after_last_batch=2643224 KB (2581.27 MB) total_rss_growth=2569060 KB (2508.85 MB) positive_growth_batches=1753 negative_growth_batches=247 zero_growth_batches=0 max_batch_growth=49396 KB (48.24 MB) min_batch_growth=-10396 KB (-10.15 MB) avg_batch_growth=1284.52 KB (1.25 MB)
In explicit mode, growth is much flatter because the prepared statement is reused instead of recreated per batch.
=== RSS Summary (explicit) === batches=2000 rss_before_first_batch=75308 KB (73.54 MB) rss_after_last_batch=272332 KB (265.95 MB) total_rss_growth=197024 KB (192.41 MB) positive_growth_batches=981 negative_growth_batches=372 zero_growth_batches=647 max_batch_growth=49120 KB (47.97 MB) min_batch_growth=-11736 KB (-11.46 MB) avg_batch_growth=98.50 KB (0.10 MB)
Root cause
The Python pybind execution path currently does this for parameterized string queries:
prepared = py_connection.prepare(query, parameters)
return py_connection.execute(prepared, parameters)
This means that every call to execute(query, parameters) creates a new prepared statement.
At the native layer, each prepare inserts a new cached prepared statement into CachedPreparedStatementManager. That manager currently has no eviction or removal path for this use case, so repeated implicit prepares cause the cache to grow monotonically.
The issue is therefore not specific to query failure itself. Failures simply make it easier to reproduce repeatedly. The actual problem is repeated implicit prepare of the same query text.
Expected behavior
For repeated executions of the same parameterized query on the same connection, the implicit pybind path should reuse a previously prepared statement instead of preparing a new one each time.
Summary
When using the Python API with the pybind backend, repeated calls to:
conn.execute(query, params)
for the same parameterized query can cause process RSS to grow over time.
This happens because the current pybind execution path implicitly prepares the query on every parameterized execution, even when the SQL/Cypher text is identical.
Reproduction
A minimal repro script is available:
repro_pybind_implicit_prepare_growth.py
The repro compares two modes:
implicit: repeatedly calls conn.execute(query, params)
explicit: prepares once with conn.prepare(query) and then repeatedly calls conn.execute(prepared_stmt, params)
The script intentionally uses a deterministic failing workload so that:
the query text remains constant
the parameterized pybind execution path is exercised repeatedly
memory behavior can be observed across many batches
Example command:
python3 tools/python_api/scripts/repro_pybind_implicit_prepare_growth.py --mode both
Observed behavior
In implicit mode, RSS trends upward across repeated batches.
=== RSS Summary (implicit) === batches=2000 rss_before_first_batch=74164 KB (72.43 MB) rss_after_last_batch=2643224 KB (2581.27 MB) total_rss_growth=2569060 KB (2508.85 MB) positive_growth_batches=1753 negative_growth_batches=247 zero_growth_batches=0 max_batch_growth=49396 KB (48.24 MB) min_batch_growth=-10396 KB (-10.15 MB) avg_batch_growth=1284.52 KB (1.25 MB)In explicit mode, growth is much flatter because the prepared statement is reused instead of recreated per batch.
=== RSS Summary (explicit) === batches=2000 rss_before_first_batch=75308 KB (73.54 MB) rss_after_last_batch=272332 KB (265.95 MB) total_rss_growth=197024 KB (192.41 MB) positive_growth_batches=981 negative_growth_batches=372 zero_growth_batches=647 max_batch_growth=49120 KB (47.97 MB) min_batch_growth=-11736 KB (-11.46 MB) avg_batch_growth=98.50 KB (0.10 MB)Root cause
The Python pybind execution path currently does this for parameterized string queries:
prepared = py_connection.prepare(query, parameters)
return py_connection.execute(prepared, parameters)
This means that every call to execute(query, parameters) creates a new prepared statement.
At the native layer, each prepare inserts a new cached prepared statement into CachedPreparedStatementManager. That manager currently has no eviction or removal path for this use case, so repeated implicit prepares cause the cache to grow monotonically.
The issue is therefore not specific to query failure itself. Failures simply make it easier to reproduce repeatedly. The actual problem is repeated implicit prepare of the same query text.
Expected behavior
For repeated executions of the same parameterized query on the same connection, the implicit pybind path should reuse a previously prepared statement instead of preparing a new one each time.