Database Session Management#
Dify uses Flask-SQLAlchemy with a context-local db.session proxy. Historically, service and core modules accessed this global session directly, which led to three classes of bugs:
DetachedInstanceError— ORM objects loaded in one session context become detached when accessed in another (e.g., across thread or task boundaries).PendingRollbackError— Callingdb.session.refresh()on a session with a pending rollback causes a cascade failure.- Transaction isolation violations — An explicit
db.session.commit()inside a helper or decorator flushes all pending dirty state in the request-scoped session, not just the intended writes, making any upstream rollback impossible.
A large-scale refactoring effort (issue #37403) is resolving these by making session access explicit across ~153+ files: api/controllers/ (62), api/services/ (51), api/core/ (68), and api/tasks/ (42).
Pattern 1 — Explicit Session Injection#
Service methods that read or mutate ORM state now accept an explicit session parameter instead of importing db and using the global proxy. Controllers own the session lifecycle and pass it down.
Signature convention — see load_user in api/services/account_service.py:
# Service layer
def load_user(user_id: str, session: scoped_session | Session) -> None | Account:
account = session.get(Account, user_id)
...
The module-level docstring captures the contract: "methods that read or mutate ORM state accept an explicit session so controllers, tasks, and tests can control transaction lifetime and avoid hidden Flask-scoped session usage inside service logic."
This pattern has been applied across ApiKeyAuthService, FeedbackService and MetadataService, AppService, DatasetService, TagService, and many others under issue #37403.
Type annotation: Methods use Session | scoped_session to support both concrete and proxy sessions without requiring a specific SQLAlchemy import at the call site.
Pattern 2 — Independent Session for Side-Effect Writes#
For audit logging, hit-count updates, or rate-limit recording that must commit immediately but must not affect the caller's transaction, use an independent session via sessionmaker:
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session:
session.add(record)
# auto-commits on context exit; isolated from db.session
This context manager auto-commits on exit and is completely decoupled from the request-scoped db.session.
Canonical examples in the codebase:
- Dataset query audit logging —
_on_queryinapi/core/rag/retrieval/dataset_retrieval.py: writesDatasetQueryrecords in an independent session so a downstream workflow failure cannot roll back the audit trail. - Rate limit logging —
cloud_edition_billing_rate_limit_checkinapi/controllers/service_api/wraps.py: the decorator records aRateLimitLogin its own session before raisingForbidden, preventing uncommitted request-scoped state from leaking into the log write. - End-user and built-in tool services —
api/services/end_user_service.pylines 27, 57, 136 use this pattern for all reads/writes.
The problems this pattern fixes are documented in issues #37886 (premature commit in _on_query) and #37885 (premature commit in the rate-limit decorator).
Common Failure Modes and Fixes#
| Symptom | Root cause | Fix |
|---|---|---|
DetachedInstanceError | ORM object loaded in one implicit session, accessed after session closes | Refactor method to accept session param; keep object and session in same scope |
PendingRollbackError in load_user | db.session.refresh() called while session has a pending rollback | Replaced with session.expunge() or switched to explicit passed-in session |
| Dirty data committed mid-request | db.session.commit() inside helper/decorator flushes unrelated pending objects | Move the write into an independent sessionmaker(...).begin() context |
| Rollback ignored after partial workflow failure | Intermediate commit prevents top-level rollback from reverting state | Use independent session for side effects; keep main workflow writes in the caller-scoped session |
AccountService.get_account_by_email_with_case_fallback#
Previously created its own session_factory, detaching ORM instances from the caller's transaction and triggering DetachedInstanceError in auth flows. PR #37847 changed the signature to accept session: Session | scoped_session and removed the internal factory; 9+ call sites across controllers and CLI were updated.
Key Source Files#
| File | Role |
|---|---|
api/services/account_service.py | Reference implementation; module docstring captures the caller-scoped session contract |
api/core/rag/retrieval/dataset_retrieval.py | Shows both patterns: injected session in _retriever; independent session in _on_query and _on_retrieval_end |
api/controllers/service_api/wraps.py | cloud_edition_billing_rate_limit_check — example of independent session in a decorator |
api/services/end_user_service.py | Consistent use of sessionmaker(...).begin() for all DB access |
Tracking issue: #37403 — make db.session pass from parameter is the umbrella for the ongoing refactoring across 150+ files.