きり丸の技術日記

技術検証したり、資格等をここに残していきます。

MissingGreenletが起きたときの対応

処理高速化のために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してインスタンスを検索するようにする。または、relationshiplazy属性を付与して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で解決もできませんしね。

参考情報

FastAPIのHTTPExcrptionはログに出しても空文字列の可能性がある

FastAPIHTTPExceptionをログに出力しようとした時のメモ。

環境

  • Python
    • 3.12.3
  • FastAPI
    • 0.103.1
    • 0.110.0

対応

少し古いFastAPIのライブラリなら、reprを使用してシリアライズしてください。もし、strで検証しても空文字列でなければ、そのままでも問題ありません。

from fastapi import HTTPException

exc = HTTPException(status_code=404, detail='NOT_FOUND')
print(exc)
# '' # 空文字列の可能性がある
# 404: NOT_FOUND # 新しいとこっちのメッセージ
print(repr(exc))
# HTTPException(status_code=404, detail='NOT_FOUND')

原因

FastAPIHTTPExceptionStarletteHTTPExceptionを継承しています。

しかし、StarletteHTTPException__str__が2023年6月21日まで定義されていませんでした。そのため、FastAPIが依存しているStarletteのバージョンが古いとstrで文字列化しても空文字列が返却されます。

さらに詳細

PythonのExceptionは位置引数をログに出力してくれますが、キーワード引数はログに出力してくれないようです。

HTTPExceptionはstatus_codeとdetailというキーワード引数でパラメータ指定をするので、位置引数に何も設定されず、文字列化されなかったようです。

except節は例外名の後に変数を指定できます。その変数は例外インスタンスに紐付けられ、一般的には引数を保持する args 属性を持ちます。利便性のため、組み込み例外型には str() が定義されており、明示的に .args を参照せずとも すべての引数を表示できます。

ソースコード

なし。

終わりに

ログ出力するには単純に文字列化すればよいと考えていましたが、ちょっとハマってしまいました。

参考情報

Pythonのjinja2でマルチパートメールのテンプレートを取得する

Pythonでマルチパートメールを送る方法をブログにしました。しかし、前回の記事はファイルからテンプレートを取得していないので、マルチパートメールを実質運用できません。今回はjinja2を使用してファイルからテンプレートを取得することで実運用できるようにします。

環境

  • Python
    • 3.12.3
  • jinja2
    • 3.1.3
  • MailHog
    • v1.0.1

事前条件

前回記事をベースに、jinja2でファイルのテンプレートを取得できるようにする。

対応

テンプレートを格納する

HTMLファイルとテキストファイルを配備します。

今回の例では次のディレクトリにファイルを配備します。

  • src/resources/templates/signin.html
  • src/resources/templates/signin.txt

また、今回のテンプレートにはnameという変数を用意しています。

# src/resources/templates/signin.txt
Welcome to our platform, {{ name }}!

テンプレート読み込む

jinja2を用いてテンプレートを読み込みます。今回の例ではsrc/resources/templatesにメールテンプレートを配備しているので、FileSystemLoaderにディレクトリを渡します。また、レンダリングする際にパラメータが不足した時にエラーとなるようにEnvironmentundefinedを定義します。発生したエラーは次のようなものが発生します。

jinja2.exceptions.UndefinedError: 'name' is undefined

次のコードを元に、HTMLメールとテキストメールのテンプレートを読み込むようにします。

from jinja2 import Template, Environment, FileSystemLoader, StrictUndefined


@classmethod
def get_templates(cls, path: str):
    file_loader = FileSystemLoader("src/resources/templates")
    env = Environment(loader=file_loader, undefined=StrictUndefined)
    template_text = env.get_template(f"{path}.txt")
    template_html = env.get_template(f"{path}.html")

    return template_text, template_html

ファイルレンダリング

読み込んだテンプレートに変数を埋め込んで、メール送信できる文字列に変換します。

@classmethod
def build_body(cls, path: str, params={}):
    text, html = cls.get_templates(path)
    return text.render(**params), html.render(**params)

メソッドの呼び出し

ファイル名、パラメータのキーと値を渡せばファイルからテンプレートを読めます。

body_text, body_html = Mailer.build_body("signin", params={"name": "NAME"})

前回の記事と合わせて次のようなメソッドになります。

@classmethod
def send(cls) -> None:
    sender = "no-reply@example.com"
    receiver = "1@example.com"
    subject = "Python SMTP Mail Subject"
    body_text, body_html = Mailer.build_body("signin", params={"name": "NAME"})

    msg = EmailMessage()
    msg.set_content(body_text)
    msg.add_alternative(body_html, subtype="html")

    msg["Subject"] = subject
    msg["From"] = sender
    msg["To"] = receiver
    try:
        with SMTP(host=cls.host, port=cls.port) as smtp:
            smtp.send_message(msg)
    except Exception as e:
        print(f"Failed to send email. Error {str(e)}")
        raise e

ソースコード

今回のテスト用に少々テストコードを変更しています。

終わりに

取り急ぎ本番でも運用できる状態になったと思います。他にもjinja2には良さそうなオプションがあるので色々と素振りしたいですね。

類似情報