|
| 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. |
0 commit comments