掲題の事象が発生しました。結論を出すと、ただの私の認識誤りです。ただ、よくある内容ですので、二度目が発生しないようにメモします。
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
になっているだけで、実際のtype
はDBUser
のままです。あくまで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との差分を得ようと再検索すると、書き換え後のインスタンスを取得してしまい、差分ゼロと見なされて更新がかからない事象が発生しました。
高速化のためにライブラリがキャッシュを活用するのは当たり前になってきていると思いますので、同一セッションで複数回同条件で検索するパターンで変なアップデートはかけないようにしましょう。どうしてもインスタンスを修正したいのであれば、検索の都度インスタンスを再生成するのが安全です。