#37000: cursor_iter relies on GC for server-side cursor cleanup, causing
transaction abort after savepoint rollback
-------------------------------------+-------------------------------------
     Reporter:  Ratskó László        |                     Type:  Bug
       Status:  new                  |                Component:  Database
                                     |  layer (models, ORM)
      Version:  4.2                  |                 Severity:  Normal
     Keywords:  iterator, server-    |             Triage Stage:
  side-cursor, savepoint, psycopg3   |  Unreviewed
    Has patch:  0                    |      Needs documentation:  0
  Needs tests:  0                    |  Patch needs improvement:  0
Easy pickings:  0                    |                    UI/UX:  0
-------------------------------------+-------------------------------------
 == Description

 When `QuerySet.iterator()` is used inside `transaction.atomic()` and an
 exception interrupts the iteration, the server-side cursor opened by
 `cursor_iter` is not closed eagerly. The generator stays alive (referenced
 from the exception's traceback), and the cursor is only closed when GC
 collects the generator — by which time the savepoint has already been
 rolled back, destroying the cursor on the PostgreSQL side.

 The delayed `cursor.close()` sends a `CLOSE` command for a non-existent
 cursor, which raises `InvalidCursorName` and aborts the entire
 transaction. Any subsequent DB operations fail with
 `InFailedSqlTransaction`.

 This was discussed with the psycopg maintainer in
 https://github.com/psycopg/psycopg/discussions/1282, who suggested that we
 open a discussion about this here is well.

 == Reproduction

 {{{#!python
 from django.db import transaction
 from myapp.models import Item

 def export_items():
     try:
         with transaction.atomic():  # creates a savepoint
             for item in Item.objects.iterator():  # opens server-side
 cursor
                 if item.value == "bad":
                     raise ValueError("Export failed")
                 process(item)
     except ValueError:
         # Savepoint was rolled back, cursor destroyed on PostgreSQL side.
         # But cursor_iter generator is still alive in the traceback.
         # GC will eventually close() the cursor → CLOSE fails →
 transaction aborted.
         Item.objects.create(value="error logged")  # ← fails with
 InFailedSqlTransaction
 }}}

   == What happens step by step

   1. `Item.objects.iterator()` opens a server-side cursor via
 `cursor_iter`
   2. Exception is raised mid-iteration
   3. `transaction.atomic().__exit__` rolls back the savepoint → PostgreSQL
 destroys the cursor
   4. The `cursor_iter` generator is **not** closed — still referenced from
 the exception traceback
   5. GC collects the generator → `finally: cursor.close()` runs
   6. psycopg sends `CLOSE "cursor_name"` → `InvalidCursorName`
   7. The failed SQL aborts the transaction
   8. Subsequent queries fail with `InFailedSqlTransaction`

 == The problem in cursor_iter

 {{{#!python
 # django/db/models/sql/compiler.py
 def cursor_iter(cursor, sentinel, col_count, itersize):
     try:
         for rows in iter((lambda: cursor.fetchmany(itersize)), sentinel):
             yield rows if col_count is None else [r[:col_count] for r in
 rows]
     finally:
         cursor.close()  # only runs when generator is closed or GC'd
 }}}

 The `finally` block is correct for normal completion, but when an
 exception interrupts the generator mid-yield, the close is deferred to GC.
 By that time, a savepoint rollback may have already destroyed the cursor.

 == Related

 - psycopg discussion: https://github.com/psycopg/psycopg/discussions/1282
-- 
Ticket URL: <https://code.djangoproject.com/ticket/37000>
Django <https://code.djangoproject.com/>
The Web framework for perfectionists with deadlines.

-- 
You received this message because you are subscribed to the Google Groups 
"Django updates" group.
To unsubscribe from this group and stop receiving emails from it, send an email 
to [email protected].
To view this discussion visit 
https://groups.google.com/d/msgid/django-updates/0107019d19a93444-9aa1a298-5b29-4463-a390-61b59cf69efb-000000%40eu-central-1.amazonses.com.

Reply via email to