3件を一括保存し、最後の1件を主キー重複にした。自分は保存処理を試していて、IntegrityError の後も2件残るのを確認した。例外を捕る位置を変えると、今度は0件になった。
「全部成功か、全部取り消しか」で扱うバッチなら、例外は with con: の外で捕りたい。内側でログだけ出すと、途中までのデータをコミットしてしまう。
sqlite3で2件と0件を再現する
LLMの処理結果を request_id ごとに保存する想定。3件目だけIDを重複させた。
CPython 3.14.8、SQLite 3.53.4で確認した。Python 3.12以降の標準ライブラリだけで動く。
import sqlite3
rows = [("r1", "回答A"), ("r2", "回答B"), ("r1", "回答C")]
def run(catch_inside):
con = sqlite3.connect(
":memory:",
autocommit=sqlite3.LEGACY_TRANSACTION_CONTROL,
isolation_level="DEFERRED",
)
con.execute("CREATE TABLE result (request_id TEXT PRIMARY KEY, body TEXT)")
sql = "INSERT INTO result VALUES (?, ?)"
label = "inside" if catch_inside else "outside"
try:
if catch_inside:
with con:
try:
con.executemany(sql, rows)
except sqlite3.IntegrityError as e:
print(label, "error:", e)
else:
try:
with con:
con.executemany(sql, rows)
except sqlite3.IntegrityError as e:
print(label, "error:", e)
count = con.execute("SELECT COUNT(*) FROM result").fetchone()[0]
print(label, "rows:", count)
finally:
con.close()
run(True)
run(False)
inside error: UNIQUE constraint failed: result.request_id
inside rows: 2
outside error: UNIQUE constraint failed: result.request_id
outside rows: 0
エラー文は同じ。保存件数だけが違う。
executemanyは、SQLを繰り返し実行する
executemany() は同じINSERTを各パラメータで繰り返す。今回の設定では、最初のINSERTでトランザクションが始まる。
1件目と2件目は成功する。3件目で主キー制約に違反し、既定の競合処理でそのINSERTが失敗する。先の2件はトランザクション内に残る。
ここで例外を捕る位置が効く。
inside は例外を処理してから with を抜ける。正常終了として2件がコミットされる。outside は例外が with の外へ抜けるため、終了処理でロールバックされて0件になる。
手元の sqlite3.Connection.__exit__.__doc__ にも、例外があればrollback、なければcommitとある。関連する一次情報はPython公式の説明とSQLiteの競合処理。
接続設定も確認する
同じ接続に先行する未コミットの更新があれば、それもロールバック対象になる。with con: より前の書き込みまで確認したい。
autocommit=True では、この with によるロールバックは効かない。コードでは LEGACY_TRANSACTION_CONTROL と DEFERRED を明示した。
with con: を抜けても接続は閉じない。サンプルの finally はその後始末用。
LLMのバッチを丸ごと再試行するなら、失敗時に保存が0件へ戻るところまで確認したい。内側でログを出す場合も、例外を再送出すればロールバックにつながる。再試行を足す前に、途中で失敗する入力を通すと、「失敗扱いなのに保存済み」という食い違いを拾える。
