きり丸の技術日記

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

【障害メモ】PythonのSQLAlchemyを使用したテストコードで参照系処理を2回実行したら2回目で不具合発生

掲題の事象が発生しました。結論を出すと、ただの私の認識誤りです。ただ、よくある内容ですので、二度目が発生しないようにメモします。

Pythonと記載していますが、同様の条件が揃えば他のライブラリでも発生しうる内容です。JavaのMyBatisでも似た事象は発生しました。

環境

  • FastAPI
    • 0.79
  • SQLAlchemy
    • 1.4.36
  • Python
    • 3.10

事象

参照系処理を2回実行したら2回目で意図しないインスタンスに変更されている。条件は次の通り。

  • テストコードで発生
    • プロダクトコードでは実行ごとに別セッションで実行するので問題なし
  • DBアクセスのためのセッションをパラメータで渡す
    • トランザクション境界でセッションを生成して渡す
  • DBアクセスが発生する
  • DBアクセスしたインスタンスを加工して返却する

処理イメージ

実際に再現する環境は用意していないので、イメージです。

Productコード。

class APIUser:
    # 性別を文字列
    gender: str

class DBUser:
    __tablename__ = "users"
    # 性別は1行のコードで管理
    gender = Column(INTEGER, nullable=False)

class UserRepository:
    async def find_user(
        self,
        session
    ) -> APIUser:
        db_user: DBUser = await session.execute(select(DBUser.gender)).scalars().all()[0]
        api_user: APIUser = cast(APIUser, db_user) # castする
        api_user.gender = 'male' if api_user.gender == 1 else 'female' # 数値を文字に変換する

テストコード。

async test_multiple_execute -> None:
    # GIVEN
    session = await create_session() # いい感じにセッション作成する

    await create_user(gender=1) # いい感じに男ユーザを作成する
    expected = APIUser(gender="male")

    # WHEN, THEN
    assert expected == await UserRepository.find_user(session)
    # なぜか、genderがfemaleでエラーとなる
    assert expected == await UserRepository.find_user(session)

誤認識していた内容

  • 標準ライブラリのtyping.castは別インスタンスを作ってくれない
  • SQLAlchemyは常にDBのデータを元にインスタンスを生成してくれるわけではない

標準ライブラリのtyping.castは別インスタンスを作ってくれない

そもそも、castという言葉に別インスタンスを作ってくれる機能があると誤認していました。Javaでもそんな意味合いはないのですが…。普段castしないので、同名プロパティだけをマッピングしつつ、別インスタンスになっていると思いました…。

api_user: APIUser = cast(APIUser, db_user) 

上記コードだと、実際は型ヒントがAPIUserになっているだけで、実際のtypeDBUserのままです。あくまでPythonは動的型付言語で、castして明示している型によって実際の振る舞いが変わったりはしません。int型に対しても、str型を設定できます。

Pythonの公式のtyping.castを読んでみると、型ヒントだけ変更して、他は何の検査もしないことが記載されています。

値をある型にキャストします。

この関数は値を変更せずに返します。 型検査器に対して、返り値が指定された型を持っていることを通知しますが、実行時には意図的に何も検査しません。 (その理由は、処理をできる限り速くしたかったためです。)

SQLAlchemyは常にDBのデータを元にインスタンスを生成してくれるわけではない

公式の英語を誤読している可能性はあるので、誤っていたら指摘お願いします。

SQLAlchemyでは、キャッシュのような機能があります。クエリをキャッシュしているのではなく、処理結果をキャッシュします。もし、キャッシュした処理結果にクエリの検索条件に引っかかるインスタンスを保持している場合、該当のインスタンスを返却します。

常にDBのデータをもとにインスタンスを生成するのではなく、事前に所持してるインスタンスがあれば、そちらを返却するということです。

原文でもキャッシュのような機能と表現しておりますが、歯に物が挟まった表現が続くと読みづらいため、以後キャッシュと表現します。

Yeee…no. It’s somewhat used as a cache, in that it implements the identity map pattern, and stores objects keyed to their primary key. However, it doesn’t do any kind of query caching. This means, if you say session.query(Foo).filter_by(name='bar'), even if Foo(name='bar') is right there, in the identity map, the session has no idea about that. It has to issue SQL to the database, get the rows back, and then when it sees the primary key in the row, then it can look in the local identity map and see that the object is already there. It’s only when you say query.get({some primary key}) that the Session doesn’t have to issue a query.

結局何が起こっていたのか

1回目の処理実行時に、次のインスタンスが返却されています。

1. データ取得時
    1. DBUser(gender='1')
1. データ返却時
    1. DBUser(gender='male')

2回目の処理実行時に、キャッシュが働き、加工後のインスタンスが返却されます。

1. データ取得時
    1. DBUser(gender='male')
1. データ返却時
    1. DBUser(gender='female') # 1以外(male)が入ったので、femaleに。

対応策

同一セッションに保持されているキャッシュが残っていて、変更後の値を保持していたことが原因です。その変更後の値を破棄する必要があります。

  • キャッシュを使用しないようにする(常にDBの値を元にインスタンスを生成する)
    • 設定方法不明。どこかにはあるはず。
  • rollbackする
    • 私のテストコードの都合で、セットアップしたデータも消える等々面倒な点が多い

上記内容でも回避は可能です。今回はSQLAlchemyのSAVEPOINT機能を使用しました。

async test_multiple_execute -> None:
    # GIVEN
    session = await create_session() # いい感じにセッション作成する

    await create_user(gender=1) # いい感じに男ユーザを作成する
    expected = APIUser(gender="male")

    # WHEN, THEN
    # 1回目
    nested = await sesssion.begin_nested()
    assert expected == await UserRepository.find_user(nested)
    nested.rollback() # DBUser(gender='male')の変更を破棄する

    # 2回目
    # rollbackした時点でSAVEPOINTは破棄されるため、都度作成する必要があります
    nested = await sesssion.begin_nested()
    assert expected == await UserRepository.find_user(nested)
    nested.rokkback()

終わりに

キャッシュが悪いことに頭が回っていなくて、ドハマりしていました。

静的型付言語だと関係性がない型にキャストできず、実行時エラーが発生します。しかし、動的型付言語だとそもそも型自体があくまでヒント機能でしかないことを気付けませんでした。また静的型付言語だと、関係性がない型にキャストできず、別インスタンスにしてモデルを詰め直す必要があるため、内部的にそうなっているだろうと甘く見ていました。

というか、commitしないからいいものの、このままcommitしたらDBの値も書き換わっている可能性があるのは怖いですね。intからstrの書き換えだとDBの制約エラーに引っかかるのでいいですが、strからstrだとアウトですね。ちゃんと別インスタンスを生成したほうが安全に処理できて良さそうです。

なおJavaでも似た事象が発生しうると記載しているのは、MyBatisにはキャッシュ機能があります。DB更新をするためにインスタンスを書き換え、最低限の項目のみをUPDATEかけるためにDBとの差分を得ようと再検索すると、書き換え後のインスタンスを取得してしまい、差分ゼロと見なされて更新がかからない事象が発生しました。

高速化のためにライブラリがキャッシュを活用するのは当たり前になってきていると思いますので、同一セッションで複数回同条件で検索するパターンで変なアップデートはかけないようにしましょう。どうしてもインスタンスを修正したいのであれば、検索の都度インスタンスを再生成するのが安全です。

参考情報