私が歌川です

@utgwkk が書いている

Flask の abort() と psycopg2 のコネクションがロックを手放さないことについて: 明示的に commit() を呼ぶとよい

tl;dr

  • psycopg2を使うときははSELECT句だけを実行する場合も明示的に conn.commit() をする.
  • コンテキストマネージャを使うとすっきり書ける.

現象と調査結果

FlaskとPostgreSQL(psycopg2)を用いたwebアプリケーションのテストで,404や403を返すテストの次に実行されるテストが終了しないという現象に遭遇した.

def db():
    if hasattr(g, 'db'):
        g.db = psycopg2.connect(...)
    return g.db


@app.route('/@<strting:username>')
def userpage(username):
    c = db().cursor()
    c.execute('SELECT * FROM users WHERE username = %s', (username,))
    user = c.fetchone()

    if user is None:
        abort(404)

    # return user page

ユーザーページを表示する処理を行っている.username に対応するユーザーがいない場合は404を返し,いる場合はユーザーページを返す.

この abort(404) に至るパスのテストの次に実行されるテストがいつまで経っても終了しない. 厄介なことにSIGINTを送っても(Ctrl-Cを叩いても)停止しない. curlで各ページを表示しても,1秒と経たずにレスポンスが返ってくるし,test_client の挙動を確かめてみたが特におかしいところはなさそうだった.

ところで,各テストケースの前にDBを初期化するを入れていることを思い出した.

class UserTest(unittest.TestCase):
    def setUp(self):
        app.init_db()

これはつまり次のような処理である.何の変哲もない,全テーブルのデータを削除する処理だ.

def init_db():
    with db() as conn:
        c = conn.cursor()
        c.execute('TRUNCATE users, posts, ... RESTART IDENTITY CASCADE')

いろいろ試した結果,この初期化処理で詰まっていることが分かった. とりあえず postgresql truncate slow で検索して次のページに着いた.

TRUNCATE に必要なロックが獲得できていないかもしれないから確認してみるとよいですよ,と書いてあり,実際に見てみると,確かに TRUNCATE がロックを獲得できないままになっている! psycopg2のドキュメントを見直してみると次のようなことが書いてあった. autocommit は既定では False であると書いてあり,その次に,

Warning: By default, any query execution, including a simple SELECT will start a transaction: for long-running programs, if no further action is taken, the session will remain “idle in transaction”, an undesirable condition for several reasons (locks are held by the session, tables bloat…). For long lived scripts, either ensure to terminate a transaction as soon as possible or use an autocommit connection.

The connection class — Psycopg 2.8.4.dev0 documentation

つまり,

SELECT * FROM users WHERE username = 'hoge';

だと思っていたものは,実は,

BEGIN;
SELECT * FROM users WHERE username = 'hoge';
-- NO COMMIT; The lock has not been released yet!

だったのだ! SELECTがトランザクションを中断しないままロックを保持していたというわけか? しかし return でレスポンス返したときはトランザクション終了してたようだし,デストラクタでCOMMITして自動的に接続を閉じるみたいな処理があったのだろうか. abort(404) は例外を送出するのでそれが呼ばれない?

ということで,明示的に conn.commit() を呼ぶようにコードを書き換えると,確かにロックが保持されたままにならずテストが無事に終了するようになった. コンテキストマネージャを使ってやるとすっきりした形で書ける.

with db() as conn:
    c = conn.cursor()
    c.execute('SELECT * FROM users')

結論

ドキュメントちゃんと読もう. しかし abort() で適切なリソース解放が行われていなかったとすればそれはそれで問題なのでは…….