Skip to content

Commit 10f0528

Browse files
committed
Document implicit IO points in ORM
I purposely didn't spend much documentation writing about implicit IO when I first pushed out the asyncio extension because I wanted to get a sense on what kinds of issues people had. Now we know and the answer is predictably "all of them". List out all the known implicit IO points and how to avoid them. Also rename the "adapting lazy loads" section, so that the title is less suggestive that this is a necessary technique. References: sqlalchemy#5926 Change-Id: I3933b74bd37a5b06989531adbeade34347db679b
1 parent ac1228a commit 10f0528

File tree

2 files changed

+169
-28
lines changed

2 files changed

+169
-28
lines changed

doc/build/orm/extensions/asyncio.rst

+152-27
Original file line numberDiff line numberDiff line change
@@ -85,25 +85,69 @@ Synopsis - ORM
8585
---------------
8686

8787
Using :term:`2.0 style` querying, the :class:`_asyncio.AsyncSession` class
88-
provides full ORM functionality. Within the default mode of use, special care
89-
must be taken to avoid :term:`lazy loading` of ORM relationships and column
90-
attributes, as below where the :func:`_orm.selectinload` eager loading strategy
91-
is used to ensure the ``A.bs`` on each ``A`` object is loaded::
88+
provides full ORM functionality. Within the default mode of use, special care
89+
must be taken to avoid :term:`lazy loading` or other expired-attribute access
90+
involving ORM relationships and column attributes; the next
91+
section :ref:`asyncio_orm_avoid_lazyloads` details this. The example below
92+
illustrates a complete example including mapper and session configuration::
9293

9394
import asyncio
9495

95-
from sqlalchemy.ext.asyncio import create_async_engine
96+
from sqlalchemy import Column
97+
from sqlalchemy import DateTime
98+
from sqlalchemy import ForeignKey
99+
from sqlalchemy import func
100+
from sqlalchemy import Integer
101+
from sqlalchemy import String
96102
from sqlalchemy.ext.asyncio import AsyncSession
103+
from sqlalchemy.ext.asyncio import create_async_engine
104+
from sqlalchemy.ext.declarative import declarative_base
105+
from sqlalchemy.future import select
106+
from sqlalchemy.orm import relationship
107+
from sqlalchemy.orm import selectinload
108+
from sqlalchemy.orm import sessionmaker
109+
110+
Base = declarative_base()
111+
112+
113+
class A(Base):
114+
__tablename__ = "a"
115+
116+
id = Column(Integer, primary_key=True)
117+
data = Column(String)
118+
create_date = Column(DateTime, server_default=func.now())
119+
bs = relationship("B")
120+
121+
# required in order to access columns with server defaults
122+
# or SQL expression defaults, subsequent to a flush, without
123+
# triggering an expired load
124+
__mapper_args__ = {"eager_defaults": True}
125+
126+
127+
class B(Base):
128+
__tablename__ = "b"
129+
id = Column(Integer, primary_key=True)
130+
a_id = Column(ForeignKey("a.id"))
131+
data = Column(String)
132+
97133

98134
async def async_main():
99135
engine = create_async_engine(
100-
"postgresql+asyncpg://scott:tiger@localhost/test", echo=True,
136+
"postgresql+asyncpg://scott:tiger@localhost/test",
137+
echo=True,
101138
)
139+
102140
async with engine.begin() as conn:
103141
await conn.run_sync(Base.metadata.drop_all)
104142
await conn.run_sync(Base.metadata.create_all)
105143

106-
async with AsyncSession(engine) as session:
144+
# expire_on_commit=False will prevent attributes from being expired
145+
# after commit.
146+
async_session = sessionmaker(
147+
engine, expire_on_commit=False, class_=AsyncSession
148+
)
149+
150+
async with async_session() as session:
107151
async with session.begin():
108152
session.add_all(
109153
[
@@ -119,6 +163,7 @@ is used to ensure the ``A.bs`` on each ``A`` object is loaded::
119163

120164
for a1 in result.scalars():
121165
print(a1)
166+
print(f"created at: {a1.create_date}")
122167
for b1 in a1.bs:
123168
print(b1)
124169

@@ -130,31 +175,111 @@ is used to ensure the ``A.bs`` on each ``A`` object is loaded::
130175

131176
await session.commit()
132177

178+
# access attribute subsequent to commit; this is what
179+
# expire_on_commit=False allows
180+
print(a1.data)
181+
182+
133183
asyncio.run(async_main())
134184

135-
Above, the :func:`_orm.selectinload` eager loader is employed in order
136-
to eagerly load the ``A.bs`` collection within the scope of the
137-
``await session.execute()`` call. If the default loader strategy of
138-
"lazyload" were left in place, the access of the ``A.bs`` attribute would
139-
raise an asyncio exception. Using traditional asyncio, the application
140-
needs to avoid any points at which IO-on-attribute access may occur.
141-
This also includes that methods such as :meth:`_orm.Session.expire` should be
142-
avoided in favor of :meth:`_asyncio.AsyncSession.refresh`, and that
143-
appropriate loader options should be employed for :func:`_orm.deferred`
144-
columns as well as for :func:`_orm.relationship` constructs.
145-
The full list of available loaders is documented in the section
146-
:doc:`/orm/loading_relationships`.
147-
148-
In the example above the :class:`_asyncio.AsyncSession` is instantiated with an
149-
:class:`_asyncio.AsyncEngine` associated with a particular database URL.
150-
It is then used in a Python asynchronous context manager (i.e. ``async with:`` statement)
151-
so that it is automatically closed at the end of the block; this is equivalent
152-
to calling the :meth:`_asyncio.AsyncSession.close` method.
185+
In the example above, the :class:`_asyncio.AsyncSession` is instantiated using
186+
the optional :class:`_orm.sessionmaker` helper, and associated with an
187+
:class:`_asyncio.AsyncEngine` against particular database URL. It is
188+
then used in a Python asynchronous context manager (i.e. ``async with:``
189+
statement) so that it is automatically closed at the end of the block; this is
190+
equivalent to calling the :meth:`_asyncio.AsyncSession.close` method.
191+
192+
.. _asyncio_orm_avoid_lazyloads:
193+
194+
Preventing Implicit IO when Using AsyncSession
195+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
196+
197+
Using traditional asyncio, the application needs to avoid any points at which
198+
IO-on-attribute access may occur. Above, the following measures are taken to
199+
prevent this:
200+
201+
* The :func:`_orm.selectinload` eager loader is employed in order to eagerly
202+
load the ``A.bs`` collection within the scope of the
203+
``await session.execute()`` call::
204+
205+
stmt = select(A).options(selectinload(A.bs))
206+
207+
..
208+
209+
If the default loader strategy of "lazyload" were left in place, the access
210+
of the ``A.bs`` attribute would raise an asyncio exception.
211+
There are a variety of ORM loader options available, which may be configured
212+
at the default mapping level or used on a per-query basis, documented at
213+
:ref:`loading_toplevel`.
214+
215+
216+
* The :class:`_asyncio.AsyncSession` is configured using
217+
:paramref:`_orm.Session.expire_on_commit` set to False, so that we may access
218+
attributes on an object subsequent to a call to
219+
:meth:`_asyncio.AsyncSession.commit`, as in the line at the end where we
220+
access an attribute::
221+
222+
# create AsyncSession with expire_on_commit=False
223+
async_session = AsyncSession(engine, expire_on_commit=False)
224+
225+
# sessionmaker version
226+
async_session = sessionmaker(
227+
engine, expire_on_commit=False, class_=AsyncSession
228+
)
229+
230+
async with async_session() as session:
231+
232+
result = await session.execute(select(A).order_by(A.id))
233+
234+
a1 = result.scalars().first()
235+
236+
# commit would normally expire all attributes
237+
await session.commit()
238+
239+
# access attribute subsequent to commit; this is what
240+
# expire_on_commit=False allows
241+
print(a1.data)
242+
243+
* The :paramref:`_schema.Column.server_default` value on the ``created_at``
244+
column will not be refreshed by default after an INSERT; instead, it is
245+
normally
246+
:ref:`expired so that it can be loaded when needed <orm_server_defaults>`.
247+
Similar behavior applies to a column where the
248+
:paramref:`_schema.Column.default` parameter is assigned to a SQL expression
249+
object. To access this value with asyncio, it has to be refreshed within the
250+
flush process, which is achieved by setting the
251+
:paramref:`_orm.mapper.eager_defaults` parameter on the mapping::
252+
253+
254+
class A(Base):
255+
# ...
256+
257+
# column with a server_default, or SQL expression default
258+
create_date = Column(DateTime, server_default=func.now())
259+
260+
# add this so that it can be accessed
261+
__mapper_args__ = {"eager_defaults": True}
262+
263+
Other guidelines include:
264+
265+
* Methods like :meth:`_asyncio.AsyncSession.expire` should be avoided in favor of
266+
:meth:`_asyncio.AsyncSession.refresh`
267+
268+
* Appropriate loader options should be employed for :func:`_orm.deferred`
269+
columns, if used at all, in addition to that of :func:`_orm.relationship`
270+
constructs as noted above. See :ref:`deferred` for background on
271+
deferred column loading.
272+
273+
* The "dynamic" relationship loader strategy described at
274+
:ref:`dynamic_relationship` is not compatible with the asyncio approach and
275+
cannot be used, unless invoked within the
276+
:meth:`_asyncio.AsyncSession.run_sync` method described at
277+
:ref:`session_run_sync`.
153278

154279
.. _session_run_sync:
155280

156-
Adapting ORM Lazy loads to asyncio
157-
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
281+
Running Synchronous Methods and Functions under asyncio
282+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
158283

159284
.. deepalchemy:: This approach is essentially exposing publicly the
160285
mechanism by which SQLAlchemy is able to provide the asyncio interface

examples/asyncio/async_orm.py

+17-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
import asyncio
77

88
from sqlalchemy import Column
9+
from sqlalchemy import DateTime
910
from sqlalchemy import ForeignKey
11+
from sqlalchemy import func
1012
from sqlalchemy import Integer
1113
from sqlalchemy import String
1214
from sqlalchemy.ext.asyncio import AsyncSession
@@ -15,6 +17,7 @@
1517
from sqlalchemy.future import select
1618
from sqlalchemy.orm import relationship
1719
from sqlalchemy.orm import selectinload
20+
from sqlalchemy.orm import sessionmaker
1821

1922
Base = declarative_base()
2023

@@ -24,8 +27,14 @@ class A(Base):
2427

2528
id = Column(Integer, primary_key=True)
2629
data = Column(String)
30+
create_date = Column(DateTime, server_default=func.now())
2731
bs = relationship("B")
2832

33+
# required in order to access columns with server defaults
34+
# or SQL expression defaults, subsequent to a flush, without
35+
# triggering an expired load
36+
__mapper_args__ = {"eager_defaults": True}
37+
2938

3039
class B(Base):
3140
__tablename__ = "b"
@@ -46,7 +55,13 @@ async def async_main():
4655
await conn.run_sync(Base.metadata.drop_all)
4756
await conn.run_sync(Base.metadata.create_all)
4857

49-
async with AsyncSession(engine) as session:
58+
# expire_on_commit=False will prevent attributes from being expired
59+
# after commit.
60+
async_session = sessionmaker(
61+
engine, expire_on_commit=False, class_=AsyncSession
62+
)
63+
64+
async with async_session() as session:
5065
async with session.begin():
5166
session.add_all(
5267
[
@@ -66,6 +81,7 @@ async def async_main():
6681
# result is a buffered Result object.
6782
for a1 in result.scalars():
6883
print(a1)
84+
print(f"created at: {a1.create_date}")
6985
for b1 in a1.bs:
7086
print(b1)
7187

0 commit comments

Comments
 (0)