Skip to content

Commit 9e58916

Browse files
authored
Merge pull request #121 from bluedynamics/fix/release-conn-rollback
Pool: rollback before putconn to release virtualxid (#118)
2 parents 4ca2989 + 04c930c commit 9e58916

5 files changed

Lines changed: 416 additions & 0 deletions

File tree

CHANGES.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@
44

55
### Fixed
66

7+
- ``release_request_connection`` now issues an explicit
8+
``conn.rollback()`` before returning the connection to the pool.
9+
Otherwise an implicit transaction opened by a prior ``SELECT`` on
10+
the pool fallback path stays alive, holding a ``virtualxid`` that
11+
blocks ``CREATE INDEX CONCURRENTLY``. Companion fix to
12+
bluedynamics/zodb-pgjsonb#58 (the storage-conn path). Closes #118.
13+
714
- Suggested Indexes UI: detect already-applied suggestions with
815
mixed-case field names (e.g. `Language`) by matching index names
916
case-insensitively — PostgreSQL folds unquoted identifiers to
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
# `release_request_connection` Rollback — Implementation Plan (PR2)
2+
3+
**Goal:** Add an explicit `conn.rollback()` before `pool.putconn(conn)` in `release_request_connection()` to close any open implicit transaction on the pool fallback path.
4+
5+
**Architecture:** 5-line change in `pool.py` plus 3 unit tests.
6+
7+
**Tech Stack:** Python 3.12+, psycopg 3 (mock only), pytest
8+
9+
**Spec:** `docs/superpowers/specs/2026-04-13-release-conn-rollback-design.md`
10+
11+
---
12+
13+
## File Map
14+
15+
- Modify: `src/plone/pgcatalog/pool.py``release_request_connection()`
16+
- Modify: `tests/test_config.py``TestRequestConnection` class
17+
- Modify: `CHANGES.md` — Unreleased entry
18+
19+
---
20+
21+
### Task 1: Failing tests for rollback behavior
22+
23+
**Files:**
24+
- Modify: `tests/test_config.py` — append to `TestRequestConnection`
25+
26+
- [ ] **Step 1: Add three new tests at the end of `TestRequestConnection`**
27+
28+
Locate the last method in `TestRequestConnection` (`test_release_swallows_putconn_exception` at ~line 215). Insert these methods *after* it but *before* the `_clean_pending` helper at line 230:
29+
30+
```python
31+
def test_release_rolls_back_before_putconn(self):
32+
"""release_request_connection rolls back any open txn before pooling."""
33+
pool = mock.Mock()
34+
conn = mock.Mock()
35+
conn.closed = False
36+
pool.getconn.return_value = conn
37+
38+
# Track call order
39+
call_order = []
40+
conn.rollback.side_effect = lambda: call_order.append("rollback")
41+
pool.putconn.side_effect = lambda c: call_order.append("putconn")
42+
43+
get_request_connection(pool)
44+
release_request_connection()
45+
46+
conn.rollback.assert_called_once()
47+
pool.putconn.assert_called_once_with(conn)
48+
# rollback must precede putconn
49+
assert call_order == ["rollback", "putconn"]
50+
51+
def test_release_swallows_rollback_exception(self):
52+
"""If rollback fails (e.g. dead conn), putconn still runs and no exception escapes."""
53+
pool = mock.Mock()
54+
conn = mock.Mock()
55+
conn.closed = False
56+
conn.rollback.side_effect = RuntimeError("connection dead")
57+
pool.getconn.return_value = conn
58+
59+
get_request_connection(pool)
60+
# Must not raise
61+
release_request_connection()
62+
63+
# putconn still called despite rollback failure
64+
pool.putconn.assert_called_once_with(conn)
65+
# Thread-local cleared
66+
assert getattr(pending_mod._local, "pgcat_conn", None) is None
67+
68+
def test_release_skips_rollback_for_closed_conn(self):
69+
"""A closed conn is not rolled back nor returned to pool."""
70+
pool = mock.Mock()
71+
conn = mock.Mock()
72+
conn.closed = True
73+
pool.getconn.return_value = conn
74+
75+
# First obtain it (the closed-conn check happens later in this scenario);
76+
# set thread-local manually to avoid get_request_connection's closed check
77+
# opening a different conn.
78+
from plone.pgcatalog import pending as pending_mod_local
79+
80+
pending_mod_local._local.pgcat_conn = conn
81+
pending_mod_local._local.pgcat_pool = pool
82+
83+
release_request_connection()
84+
85+
conn.rollback.assert_not_called()
86+
pool.putconn.assert_not_called()
87+
# Thread-local still cleared
88+
assert getattr(pending_mod._local, "pgcat_conn", None) is None
89+
```
90+
91+
- [ ] **Step 2: Verify the first two fail (third should already pass)**
92+
93+
Run: `PYTHONPATH=src .venv/bin/pytest tests/test_config.py::TestRequestConnection -v -k "rolls_back_before_putconn or swallows_rollback_exception or skips_rollback_for_closed"`
94+
95+
Expected: 2 FAIL (`rollback.assert_called_once` — current code never calls rollback), 1 PASS (closed-conn skip already works).
96+
97+
- [ ] **Step 3: Commit the failing tests**
98+
99+
```bash
100+
git add tests/test_config.py
101+
git commit -m "test: failing regression for explicit rollback before pool return (#118)"
102+
```
103+
104+
---
105+
106+
### Task 2: Implement rollback
107+
108+
**Files:**
109+
- Modify: `src/plone/pgcatalog/pool.py``release_request_connection()` (~line 74)
110+
111+
- [ ] **Step 1: Add `contextlib` import at the top of `pool.py`**
112+
113+
Find the imports block (search for `import logging`). Add `import contextlib` in the existing import block, alphabetically positioned (likely above `import logging` and `import os`):
114+
115+
```python
116+
import contextlib
117+
import logging
118+
import os
119+
```
120+
121+
- [ ] **Step 2: Update `release_request_connection`**
122+
123+
Replace the function body. Locate:
124+
125+
```python
126+
def release_request_connection(event=None):
127+
"""Return the request-scoped connection to the pool.
128+
129+
Called by the IPubEnd subscriber at the end of each Zope request.
130+
Safe to call when no request-scoped connection is active (no-op).
131+
"""
132+
conn = getattr(_local, "pgcat_conn", None)
133+
pool = getattr(_local, "pgcat_pool", None)
134+
if conn is not None and pool is not None:
135+
try:
136+
if not conn.closed:
137+
pool.putconn(conn)
138+
except Exception:
139+
log.warning("Failed to return connection to pool", exc_info=True)
140+
_local.pgcat_conn = None
141+
_local.pgcat_pool = None
142+
```
143+
144+
Replace with:
145+
146+
```python
147+
def release_request_connection(event=None):
148+
"""Return the request-scoped connection to the pool.
149+
150+
Called by the IPubEnd subscriber at the end of each Zope request.
151+
Safe to call when no request-scoped connection is active (no-op).
152+
153+
Issues an explicit ``conn.rollback()`` before pooling so the
154+
connection comes back idle (not 'idle in transaction'). This
155+
prevents the pool fallback path from leaving virtualxid locks
156+
that block ``CREATE INDEX CONCURRENTLY`` (#118). ``rollback()``
157+
on a conn with no open transaction is a cheap no-op.
158+
"""
159+
conn = getattr(_local, "pgcat_conn", None)
160+
pool = getattr(_local, "pgcat_pool", None)
161+
if conn is not None and pool is not None:
162+
try:
163+
if not conn.closed:
164+
# Close any implicit txn opened by prior SELECTs. Suppress
165+
# exceptions — a dead conn shouldn't break pool return.
166+
with contextlib.suppress(Exception):
167+
conn.rollback()
168+
pool.putconn(conn)
169+
except Exception:
170+
log.warning("Failed to return connection to pool", exc_info=True)
171+
_local.pgcat_conn = None
172+
_local.pgcat_pool = None
173+
```
174+
175+
- [ ] **Step 3: Run the new tests**
176+
177+
Run: `PYTHONPATH=src .venv/bin/pytest tests/test_config.py::TestRequestConnection -v`
178+
179+
Expected: all (~10) tests pass.
180+
181+
- [ ] **Step 4: Run the full suite**
182+
183+
Run: `PYTHONPATH=src .venv/bin/pytest tests/test_config.py tests/test_suggestions.py tests/test_tika_enqueue.py -q`
184+
185+
Expected: all pass; no regressions in nearby modules.
186+
187+
- [ ] **Step 5: Commit**
188+
189+
```bash
190+
git add src/plone/pgcatalog/pool.py
191+
git commit -m "fix(pool): rollback before pool return to release virtualxid (#118)"
192+
```
193+
194+
---
195+
196+
### Task 3: Changelog + spec/plan + push + PR
197+
198+
**Files:**
199+
- Modify: `CHANGES.md` (append to Unreleased section)
200+
201+
- [ ] **Step 1: Update CHANGES.md**
202+
203+
Read `CHANGES.md` to find the `## Unreleased` section. Add an entry under `### Fixed`:
204+
205+
```markdown
206+
- ``release_request_connection`` now issues an explicit
207+
``conn.rollback()`` before returning the connection to the pool.
208+
Otherwise an implicit transaction opened by a prior ``SELECT`` on
209+
the pool fallback path stays alive, holding a ``virtualxid`` that
210+
blocks ``CREATE INDEX CONCURRENTLY``. Companion fix to
211+
bluedynamics/zodb-pgjsonb#58 (the storage-conn path). Closes #118.
212+
```
213+
214+
- [ ] **Step 2: Commit changelog + spec + plan**
215+
216+
```bash
217+
git add CHANGES.md docs/superpowers/
218+
git commit -m "docs: changelog + spec + plan for #118 pool rollback fix"
219+
```
220+
221+
- [ ] **Step 3: Push**
222+
223+
```bash
224+
git push -u origin fix/release-conn-rollback
225+
```
226+
227+
- [ ] **Step 4: Create PR**
228+
229+
```bash
230+
gh pr create --repo bluedynamics/plone-pgcatalog --base main \
231+
--head fix/release-conn-rollback \
232+
--title "Pool: rollback before putconn to release virtualxid (#118)" \
233+
--body "$(cat <<'EOF'
234+
## Summary
235+
236+
Companion fix to bluedynamics/zodb-pgjsonb#58 — closes the secondary leak in the **pool fallback path**.
237+
238+
\`release_request_connection()\` previously returned conns to the pool without an explicit \`commit/rollback\`. Pool conns aren't autocommit by default, so any prior \`SELECT\` left an implicit transaction open, holding a \`virtualxid\` that blocks \`CREATE INDEX CONCURRENTLY\`.
239+
240+
Fix: \`with contextlib.suppress(Exception): conn.rollback()\` before \`pool.putconn(conn)\`. Cheap (no-op when no open txn), safe (suppresses errors from dead conns), idempotent.
241+
242+
Closes #118.
243+
244+
## Tests
245+
246+
Three new unit tests in \`TestRequestConnection\`:
247+
- rollback called before putconn (call-order assertion)
248+
- rollback exception swallowed; putconn still runs; thread-local cleared
249+
- closed conn: neither rollback nor putconn called
250+
251+
🤖 Generated with [Claude Code](https://claude.com/claude-code)
252+
EOF
253+
)"
254+
```
255+
256+
Report PR URL.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# `release_request_connection`: rollback before pool return — Design Spec
2+
3+
**Issue:** https://github.com/bluedynamics/plone-pgcatalog/issues/118
4+
**Date:** 2026-04-13
5+
**Scope:** plone-pgcatalog (PR2 of 2 — companion to bluedynamics/zodb-pgjsonb PR #58)
6+
7+
## Problem
8+
9+
`release_request_connection()` calls `pool.putconn(conn)` without an
10+
explicit `commit/rollback`. For the pool fallback path (no ZODB
11+
storage, e.g. tests, scripts, future non-zodb-pgjsonb consumers), pool
12+
conns are not autocommit by default, and any prior `SELECT` leaves an
13+
implicit transaction open until the next user reuses the conn. This
14+
keeps a `virtualxid` alive — the same root cause as #118 on the ZODB
15+
side.
16+
17+
The leak is small in practice:
18+
- pool conns rotate frequently — usually under load they get reused
19+
quickly and the next user opens a fresh implicit txn anyway,
20+
- `psycopg_pool` reclaims idle conns after `max_idle` (default 10 min),
21+
22+
…but it's a real leak and trivial to close.
23+
24+
## Goal
25+
26+
Every connection returned to the pool by
27+
`release_request_connection()` is in a clean state — no open
28+
transaction.
29+
30+
## Design
31+
32+
Wrap an explicit `conn.rollback()` call before `pool.putconn(conn)`.
33+
Use `contextlib.suppress(Exception)` so a connection in an unrecoverable
34+
state (already closed, killed externally) doesn't break pool return.
35+
36+
```python
37+
def release_request_connection(event=None):
38+
conn = getattr(_local, "pgcat_conn", None)
39+
pool = getattr(_local, "pgcat_pool", None)
40+
if conn is not None and pool is not None:
41+
try:
42+
if not conn.closed:
43+
with contextlib.suppress(Exception):
44+
conn.rollback()
45+
pool.putconn(conn)
46+
except Exception:
47+
log.warning("Failed to return connection to pool", exc_info=True)
48+
_local.pgcat_conn = None
49+
_local.pgcat_pool = None
50+
```
51+
52+
**Properties:**
53+
- Cheap: `rollback()` on a conn with no open txn is a no-op
54+
round-trip (psycopg sends a `ROLLBACK` only if there's a txn).
55+
- Safe on autocommit conns: psycopg's `rollback()` is a no-op when
56+
`autocommit=True`.
57+
- Defense in depth: even if zodb-pgjsonb's PR #58 isn't yet
58+
installed, this prevents pgcatalog's own pool-fallback path from
59+
leaking.
60+
61+
## Tests
62+
63+
Three new unit tests in the existing `TestRequestConnection` class
64+
(`tests/test_config.py`):
65+
66+
1. **`test_release_rolls_back_before_putconn`**: mock conn, call
67+
`release_request_connection()`, verify `conn.rollback()` was called
68+
exactly once **before** `pool.putconn(conn)`.
69+
2. **`test_release_swallows_rollback_exception`**: `conn.rollback()`
70+
raises → `pool.putconn(conn)` still called, no exception
71+
propagates, thread-local cleared.
72+
3. **`test_release_skips_rollback_for_closed_conn`**: `conn.closed =
73+
True` → neither `rollback()` nor `putconn()` called.
74+
75+
## Non-goals
76+
77+
- Changing `get_request_connection()` — connection acquisition is
78+
fine; it's the return path that needs the cleanup.
79+
- Touching the storage-conn path — that's PR #58 in zodb-pgjsonb.
80+
- Changing `_install_orjson_loader` or other module init.
81+
82+
## Rollout
83+
84+
- Single file change: `src/plone/pgcatalog/pool.py`.
85+
- Test additions: `tests/test_config.py`.
86+
- No schema, no API change.
87+
- Independent of PR #58 — can land in any order.

src/plone/pgcatalog/pool.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from plone.pgcatalog.pending import _local
1212

13+
import contextlib
1314
import logging
1415
import os
1516

@@ -76,12 +77,22 @@ def release_request_connection(event=None):
7677
7778
Called by the IPubEnd subscriber at the end of each Zope request.
7879
Safe to call when no request-scoped connection is active (no-op).
80+
81+
Issues an explicit ``conn.rollback()`` before pooling so the
82+
connection comes back idle (not 'idle in transaction'). This
83+
prevents the pool fallback path from leaving virtualxid locks
84+
that block ``CREATE INDEX CONCURRENTLY`` (#118). ``rollback()``
85+
on a conn with no open transaction is a cheap no-op.
7986
"""
8087
conn = getattr(_local, "pgcat_conn", None)
8188
pool = getattr(_local, "pgcat_pool", None)
8289
if conn is not None and pool is not None:
8390
try:
8491
if not conn.closed:
92+
# Close any implicit txn opened by prior SELECTs.
93+
# Suppress exceptions — a dead conn shouldn't break pool return.
94+
with contextlib.suppress(Exception):
95+
conn.rollback()
8596
pool.putconn(conn)
8697
except Exception:
8798
log.warning("Failed to return connection to pool", exc_info=True)

0 commit comments

Comments
 (0)