You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
SQLiteWorkflowStore leaks cursors: the store depends on CPython refcounting to commit #516
SQLiteWorkflowStore calls conn.execute(...) and never closes the cursor it
returns. On CPython the cursor is refcounted away the moment the expression ends,
which finalises the statement; on any runtime without refcounting it survives
until the next GC, holds the statement open, and the following commit() fails:
sqlite3.OperationalError: cannot commit transaction - SQL statements in progress
This is not a PyPy quirk to work around. It is the store depending on when
CPython happens to free an object for correctness, which is a portability
assumption nothing states and nothing checks.
Reproduction
Verified against main at 5.0.4 + this cycle's merges, PyPy 7.3.23 (Python
3.11.15), Nodus installed with no modifications — the only dependency is tzdata.
All nine failures have this one root cause. They surface as three different
exception types — sqlite3.OperationalError, LangRuntimeError, and two AssertionErrors on a result dict — but every one carries the same message:
'message': 'cannot commit transaction - SQL statements in progress'
Nothing else in that subset fails. Coroutines, channels, task graph and the new
state policies all pass unmodified.
Where
src/nodus_lang_workflow/store.py — 12 conn.execute(...) call sites, none of
which close the returned cursor. Two consume it (.fetchone(), .fetchall());
the rest are DDL/DML where the cursor is discarded immediately. Example:
row=conn.execute(
"SELECT ... FROM workflow_runs WHERE run_id = ?",
(run_id,),
).fetchone()
return_record_from_row(row)
The cursor is unreferenced after the expression. CPython frees it there. PyPy
does not, and the statement is still live when the enclosing block commits.
Why it is worth fixing regardless of PyPy
It is a latent bug on CPython too. The guarantee is an implementation
detail, not a language one. It holds today by accident.
The same shape will appear elsewhere. Anywhere a resource's release depends
on refcounting rather than an explicit close, the same class of failure is
waiting. Worth a sweep rather than a point fix.
Fix direction
Close cursors explicitly. Either with closing(conn.execute(...)) as cur:, or
assign and .close(), or route reads through a small helper that does it once so
the twelve sites cannot drift:
The helper is the better shape here for the reason this codebase keeps
rediscovering: twelve call sites each remembering to close is twelve chances to
forget, and the thirteenth will.
Add PyPy to CI once it passes. The value is not that anyone ships on PyPy
today — it is that a non-refcounting runtime catches exactly this class of defect
and CPython never will.
v5.0.4 and current main. Not a regression — the pattern predates this cycle.
Provenance
Surfaced while measuring PyPy throughput for #173, which is a task from the
bootstrapping thread rather than a bug hunt. The benchmark was the point; this was
found because a speedup is worthless if the runtime does not work, so the suite
was run before the number was reported.
Summary
SQLiteWorkflowStorecallsconn.execute(...)and never closes the cursor itreturns. On CPython the cursor is refcounted away the moment the expression ends,
which finalises the statement; on any runtime without refcounting it survives
until the next GC, holds the statement open, and the following
commit()fails:This is not a PyPy quirk to work around. It is the store depending on when
CPython happens to free an object for correctness, which is a portability
assumption nothing states and nothing checks.
Reproduction
Verified against
mainat 5.0.4 + this cycle's merges, PyPy 7.3.23 (Python3.11.15), Nodus installed with no modifications — the only dependency is
tzdata.All nine failures have this one root cause. They surface as three different
exception types —
sqlite3.OperationalError,LangRuntimeError, and twoAssertionErrors on a result dict — but every one carries the same message:Nothing else in that subset fails. Coroutines, channels, task graph and the new
state policies all pass unmodified.
Where
src/nodus_lang_workflow/store.py— 12conn.execute(...)call sites, none ofwhich close the returned cursor. Two consume it (
.fetchone(),.fetchall());the rest are DDL/DML where the cursor is discarded immediately. Example:
The cursor is unreferenced after the expression. CPython frees it there. PyPy
does not, and the statement is still live when the enclosing block commits.
Why it is worth fixing regardless of PyPy
detail, not a language one. It holds today by accident.
23× faster (see the measurement on that issue), and this is the only thing
standing between that number and a usable runtime. Nine tests, one cause.
on refcounting rather than an explicit close, the same class of failure is
waiting. Worth a sweep rather than a point fix.
Fix direction
Close cursors explicitly. Either
with closing(conn.execute(...)) as cur:, orassign and
.close(), or route reads through a small helper that does it once sothe twelve sites cannot drift:
The helper is the better shape here for the reason this codebase keeps
rediscovering: twelve call sites each remembering to close is twelve chances to
forget, and the thirteenth will.
Add PyPy to CI once it passes. The value is not that anyone ships on PyPy
today — it is that a non-refcounting runtime catches exactly this class of defect
and CPython never will.
Related
path to a 20×+ improvement and this is what blocks evaluating it properly.
Affected versions
v5.0.4 and current
main. Not a regression — the pattern predates this cycle.Provenance
Surfaced while measuring PyPy throughput for #173, which is a task from the
bootstrapping thread rather than a bug hunt. The benchmark was the point; this was
found because a speedup is worthless if the runtime does not work, so the suite
was run before the number was reported.