Skip to content

Concurrent requests can detach each other's ORM objects: per-request session_factory.remove() closes a Session shared by all gevent greenlets #1150

Description

@new-usemame

Found while reviewing #1149 (which fixes a different session-sharing defect). This one is on main today and is reachable by any two concurrent web requests.

Symptom

A request fails partway through with one of:

InvalidRequestError: Instance '<Books at 0x...>' is not persistent within this Session
InvalidRequestError: Object <_Row> cannot be converted to 'persistent' state,
as this identity map is no longer valid. Has the owning Session been closed?

Intermittent, load-dependent, and the traceback points at whatever the request happened to be doing — so it looks like a different bug each time. Same family as the reports on #1048 and #1121.

Root cause

Two facts combine:

  1. cps/server.py runs gevent.pywsgi.WSGIServer with a Pool, and there is no monkey.patch_all(). So every request greenlet runs on the same OS thread and therefore shares one calibre_db.session.
  2. shutdown_session (cps/__init__.py:481) runs calibre_db.session_factory.remove() on every request teardown. scoped_session.remove() calls Session.close(), which expunges every loaded object.

So when greenlet A finishes, its teardown closes the Session that greenlet B is still using. B's already-loaded ORM objects become detached, and its next attribute access, expire(), or streaming fetch raises.

Reproduced against main:

obj = db.session.query(T).first()      # greenlet B, mid-request
print(obj.name)                        # -> "book"

CalibreDB.session_factory.remove()     # greenlet A's Flask teardown fires

db.session.expire(obj)
# InvalidRequestError: Instance '<T at 0x...>' is not persistent within this Session

The busier the instance, the likelier the overlap — which matches reports that these errors show up on large libraries and during ingest, when requests take longer and overlap more.

Why it is not the same as #1121

#1121 is about a background WorkerThread (a real OS thread) sharing a Session with request handlers. This is about request greenlets sharing a Session with each other and tearing it down per-request. #1149 fixes the first and, as written, makes this one sharper — the property returns a brand-new Session after remove(), so calibre_db.session stops being stable within a single request. Details in the review on #1149.

Direction

Needs a real answer rather than a patch, and the two issues should be solved together since they both come down to "what is the lifetime of a Session here":

  • Scope the session per greenlet (scoped_session(scopefunc=gevent.getcurrent)) instead of per OS thread, so each request genuinely gets its own and per-request teardown is correct.
  • Or stop calling remove() per request and tie teardown to something that is actually request-scoped.

Option 1 looks closer to correct, but it interacts with the StaticPool constraint recorded in notes/fix-udf-gil-deadlock-DESIGN.md (one shared DBAPI connection, transaction boundaries process-global), so it needs designing rather than a one-liner.

No fix in this issue — filing so the finding isn't lost and so #1149's design pass covers both.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions