処理高速化のためにSQLAlchemyを非同期処理で使用していると、気を付けないとMissingGreenlet
というエラーが発生します。その対策をブログにします。
環境
- Python
- 3.12.3
- sqlalchemy
- 2.0.30
ゴール
AsyncSession
を使用している最中に、次のエラーが出たときの対応を記載する。
sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here. Was IO attempted in an unexpected place? (Background on this error at: https://sqlalche.me/e/20/xd2s)
対応
検索時にoptionでjoinedload
等を付与して、eagerloadしてインスタンスを検索するようにする。または、relationship
のlazy
属性を付与してselect
以外を付与する。
# 処理のベースとなるクラス class User(Base): __tablename__ = "users" organization_id: Mapped[str] = mapped_column(INTEGER, ForeignKey("organizations.id")) organization: Mapped["Organization"] = relationship("Organization") class Organization(Base): __tablename__ = "organizations"
optionで検索する方法
query: Select = (select(User) .options(joinedload(User.organization)) # これが必要 .where(User.id == 1)) result = (await db.execute(query)).scalars().first()
lazy属性を付与する方法
organization_id: Mapped[str] = mapped_column(INTEGER, ForeignKey("organizations.id")) # lazyのデフォルトのselectだけが MissingGreenlet が発生する organization: Mapped["Organization"] = relationship("Organization", lazy="joined")
対応詳細
lazy属性は複数あります。細かいニュアンスは公式ヘルプを見に行ってください。
公式ヘルプを見に行くと分かりますが、デフォルトのselect
だけMissingGreenlet
が発生するようになっています。immediate
は発生しません。また、raise
を付与するとInvalidRequestError
が発生します。
- select
- items should be loaded lazily when the property is first accessed
- 最初にプロパティにアクセスしたタイミングでロードする
- immediate
- items should be loaded as the parents are loaded
- 親クラスがロードしたタイミングでロードする
# lazyにraiseを指定 # 絶対にlazyload(N+1)を発生させたくないときにはオススメ sqlalchemy.exc.InvalidRequestError: 'User.organization' is not available due to lazy='raise'
個人的には不要なタイミングでLEFT OUTER JOIN
が走るほうが気になるので、一律でimmediate
を付与しておくと幸せになれる気がします。親・子・孫の構造になった時にも有効かどうかは怪しいところですが…。
原因
relationship
で定義しているプロパティに対してアクセスしたときに発生します。lazy
属性のデフォルトは上のselect
属性で、プロパティアクセス時にインスタンス内部で保持しているsqlalchemyインスタンスを元に検索をします。同期的なSession
なら問題ないですが、使用しているのが非同期のAsyncSession
だとawait
を付与して検索していないので、MissingGreenlet
エラーが発生します。
微妙に分かりづらいのが、同一セッション内で既にID検索していた場合は問題なくインスタンスが取得できます。例えばA部署の組織を検索するときにjoinedload
を実行してインスタンスを取得できるようにしたが、B部署の組織を検索したときにjoinedload
を付与していないとインスタンスが取得できません。
同一プロパティなのに、条件によってはMissingGreenlet
が発生してしまうので難しいです。
async def test_missing_greenlet(self, db: AsyncSession): user1 = User(id=1, name="11", email="a@example.com", organization_id=1) user2 = User(id=2, name="22", email="b@example.com", organization_id=1) user_ng = User(id=100, name="100", email="100@example.com", organization_id=2) organization_1 = Organization(id=1) organization_2 = Organization(id=2) db.add(user1) db.add(user2) db.add(organization_1) db.add(user_ng) db.add(organization_2) await db.commit() # organization_1 を ここでLEFT OUTER JOINでloadする query: Select = (select(User) .options(joinedload(User.organization)) .where(User.id == 1)) result = (await db.execute(query)).scalars().first() assert result.organization is not None # organization_1 がload済みなのでエラーにならない query: Select = (select(User) .where(User.id == 2)) result = (await db.execute(query)).scalars().first() assert result.organization is not None # organization_2 がloadされていないので、MissingGreenletエラーになる query: Select = (select(User) .where(User.id == 100)) result = (await db.execute(query)).scalars().first() with pytest.raises(MissingGreenlet) as e: _ = result.organization
ソースコード
結構参考になると思います。
終わりに
職場で同じようにハマった人がいるので、一応ブログにしました。このエラーメッセージからはChatGPT
で解決もできませんしね。