cockroachdb / cockroachdb/cockroach
sql: cursors are not closed by ROLLBACK TO SAVEPOINT and can return rolled-back rows
- Dominant language
- Go
- Stars
- 32.5k
- Forks
- 4.1k
- PR merge metrics
- PR metrics pending
Description
**Describe the problem**
SQL cursors don't participate in savepoint rollback. Two consequences, in order of severity:
1. **A cursor can return rolled-back data.** A cursor declared after a savepoint survives `ROLLBACK TO SAVEPOINT` and continues reading at its original read sequence number, so it returns rows written after the savepoint that the rollback discarded — the transaction observes its own rolled-back writes.
2. **Postgres divergence in both directions.** Postgres closes exactly the cursors opened inside the rolled-back savepoint scope ("Any cursor that is opened inside a savepoint will be closed when the savepoint is rolled back"). CockroachDB has no per-savepoint cursor tracking; behavior depends only on whether the savepoint is initial (taken before the transaction's first KV operation):
| Cursor opened | Savepoint | PG 16.13 | CRDB |
|---|---|---|---|
| after savepoint | non-initial | closed | **survives** (repro 1 below) |
| after savepoint | initial | closed | closed ✓ |
| before savepoint | non-initial | survives, position kept | survives, position kept ✓ |
| before savepoint | initial | survives | **closed** (repro 2 below) |
**To Reproduce**
Repro 1 — cursor survives rollback and returns a rolled-back row:
```sql
CREATE TABLE t (x INT);
INSERT INTO t VALUES (0);
BEGIN;
SELECT count(*) FROM t; -- make the txn active so the savepoint is not initial
SAVEPOINT s;
INSERT INTO t VALUES (1);
DECLARE c CURSOR FOR SELECT x FROM t ORDER BY x;
ROLLBACK TO SAVEPOINT s;
SELECT x FROM t ORDER BY x; -- returns: 0 (correct)
FETCH ALL c; -- returns: 0, 1 (returns the rolled-back row!)
COMMIT;
```
In Postgres the `FETCH` errors with `cursor "c" does not exist` because the rollback closed it.
Repro 2 — rollback to an initial savepoint closes a cursor Postgres keeps. This case only arises when the savepoint precedes all of the transaction's KV activity (here the cursor reads `generate_series`, which performs no KV operations even when fetched, so the savepoint remains initial):
```sql
BEGIN;
DECLARE c CURSOR FOR SELECT g FROM generate_series(1,10) g;
FETCH 2 c; -- returns: 1, 2
SAVEPOINT s; -- is_initial_savepoint = t
ROLLBACK TO SAVEPOINT s;
FETCH 2 c; -- ERROR: cursor "c" does not exist
COMMIT;
```
Postgres keeps the cursor open and the second `FETCH` resumes with rows 3, 4. Internally, rolling back to an initial savepoint is a transaction restart, and restart cleanup closes all non-holdable cursors.
**Expected behavior**
`ROLLBACK TO SAVEPOINT` closes exactly the cursors opened after the savepoint was established (Postgres semantics). At minimum, a cursor must never return rows the transaction rolled back.
**Additional data / screenshots**
Cursor cleanup only runs at transaction end (commit/rollback/restart) via the cursor set in `extraTxnState`; the `ROLLBACK TO SAVEPOINT` path (`pkg/sql/conn_executor_savepoints.go`) never touches cursors, and `pkg/sql/sql_cursor.go` has no savepoint awareness. PL/pgSQL exception handlers avoid the problem separately by explicitly closing cursors opened within the block before rolling back to the block savepoint (`closeCursors` in `pkg/sql/routine.go`).
**Environment:**
- Reproduced on master (v26.4.0-alpha dev build); present since cursor support was introduced in v22.1
- Client: `cockroach sql`, compared against PostgreSQL 16.13
Jira issue: CRDB-66767
Contributor guide
Assessment
This issue has not been assessed yet.