cockroachdb / cockroachdb/cockroach

sql: cursors are not closed by ROLLBACK TO SAVEPOINT and can return rolled-back rows

Open
#173,449 1 comment 0 reactions 1 assignee Claimed by @DrewKimball View on GitHub
A-sql-pgcompat branch-release-22.1 C-bug O-agent T-sql-queries
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.