クラウドMacで検証するiOS SQLiteの読み取りスナップショット

クラウドMacで検証するiOS SQLiteの読み取りスナップショット

バックグラウンド同期で新しいレコードがコミットされたのに、一覧を更新しても古い値が表示される。キャッシュを作り直すと一時的に解消するものの、次の一括読み取りで再発する。このような場合は、読み取りが同じ SQLite トランザクション内にとどまっていないかを、まず確認します。クラウド Mac 上で独立した二つの接続を使って再現すれば、リモートデスクトップで操作を繰り返すよりも、データベースの動作仕様と UI キャッシュの問題を切り分けやすくなります。

まず、読み取りで守るべき契約を定義する

この記事で扱うのは、ローカルのファイルデータベースを WAL モードで使用する場合だけです。結論をインメモリデータベースや共有キャッシュ構成にそのまま当てはめないでください。Python 3 と SQLite モジュールを使える環境を用意し、最初に python3 -c 'import sqlite3; print(sqlite3.sqlite_version)' を実行して、出力をテスト記録とともに保存します。

合格条件は次のとおりです。読み取り側の接続で最初のクエリを実行した後も、書き込み側の接続はコミットできること。既存の読み取りトランザクションでは引き続き古い値が見えること。その読み取りトランザクションを終了してから再度クエリを実行すると、新しい値が見えること。ここでの「古い値」はトランザクションの一貫性によるものであり、データの消失ではありません。

一覧を「よりリアルタイムに」見せるために、一貫したビューが必要な複数テーブルの読み取りの途中で、安易にトランザクションをコミットしないでください。業務上必要なのが同じスナップショットなのか、それともクエリごとに最新のコミット済みデータを読むことなのかを、先に決めます。

テストには、実際の一時ファイルと二つの接続を使う必要があります。通常の :memory: 接続を二つ作っても、それぞれが別のデータベースを持つため、この相互作用は検証できません。

二つの接続で最小限の実験を組む

以下の内容を snapshot_check.py として保存し、python3 snapshot_check.py を実行します。このスクリプトは業務用データベースには接続せず、一時ディレクトリはコンテキストを抜けると自動的に削除されます。すべてのカーソルは結果をすぐに取得して閉じ、未完了のステートメントを明示的なトランザクションの問題と取り違えないようにしています。

import sqlite3
import tempfile
from pathlib import Path

def one(db, sql):
    cursor = db.execute(sql)
    try:
        return cursor.fetchone()
    finally:
        cursor.close()

with tempfile.TemporaryDirectory() as root:
    path = Path(root) / "snapshot.sqlite"
    writer = sqlite3.connect(path, isolation_level=None)
    reader = sqlite3.connect(path, isolation_level=None)
    try:
        assert one(writer, "PRAGMA journal_mode=WAL")[0] == "wal"
        writer.execute("PRAGMA wal_autocheckpoint=0")
        writer.execute("CREATE TABLE item(id INTEGER PRIMARY KEY, value TEXT)")
        writer.execute("INSERT INTO item VALUES(1, 'old')")
        one(writer, "PRAGMA wal_checkpoint(TRUNCATE)")

        reader.execute("BEGIN")
        assert one(reader, "SELECT value FROM item WHERE id=1")[0] == "old"

        writer.execute("BEGIN IMMEDIATE")
        writer.execute("UPDATE item SET value='new' WHERE id=1")
        writer.execute("COMMIT")

        assert one(reader, "SELECT value FROM item WHERE id=1")[0] == "old"
        held = one(writer, "PRAGMA wal_checkpoint(PASSIVE)")
        assert held[1] > held[2], held

        reader.execute("COMMIT")
        assert one(reader, "SELECT value FROM item WHERE id=1")[0] == "new"

        released = one(writer, "PRAGMA wal_checkpoint(PASSIVE)")
        assert released[1] == released[2], released
        print({"held": held, "released": released, "result": "passed"})
    finally:
        if reader.in_transaction:
            reader.rollback()
        if writer.in_transaction:
            writer.rollback()
        reader.close()
        writer.close()

コードで自動チェックポイントを明示的に無効にしているのは、実験中の挙動を観察しやすくするためであり、本番環境への設定推奨ではありません。初期化後の切り詰めも、この実験専用のフィクスチャだけを対象にしています。実際の処理を受け付けているデータベースに、そのまま適用しないでください。

BEGIN と最初の読み取りを区別する

通常の BEGIN が開始するのは遅延トランザクションです。この行を実行した時点を、そのままスナップショットの時点とみなすことはできません。上の例では、最初のクエリによって初めて読み取りビューが確定します。

別の実験として、書き込みとコミットを、読み取り側の接続で最初のクエリを実行する前に移してみます。この場合、最初のクエリも、同じトランザクション内の後続のクエリも、すべて new を返すはずです。アサーションもそれに合わせて変更する必要があります。

この実験で、よくある思い込みを見抜けます。ログに「トランザクション開始」と記録されていると、その後の読み取りはすべてその時点に対応していると考えてしまいがちです。診断時にはメソッドの開始位置だけでなく、少なくとも接続 ID、トランザクション ID、最初のクエリの実行箇所、トランザクションの終了箇所を記録してください。

複数のクエリで一貫性が必要か

たとえば、注文の総数を取得してから明細を取得する場合、二つのクエリが業務上同じスナップショットに属するなら、短い読み取りトランザクションにまとめます。一方、スクロールによる追加読み込みのたびに最新データが必要なら、ページのライフサイクル全体を一つのトランザクションで囲むべきではありません。合格条件を決めるのは業務処理の境界であり、どちらがより早く更新されて見えるかではありません。

ファイルサイズだけでなく、チェックポイントの進捗を見る

wal_checkpoint(PASSIVE) は三つの整数を返します。順に、ビジー状態、ログ内のフレーム数、チェックポイント済みのフレーム数を表します。このフィクスチャでは、既存の読み取りトランザクションが以前のビューをまだ必要としているため、新しいフレームの一部はチェックポイントを完了できません。読み取りトランザクションを終了してから再実行すると、処理を進められます。

特定の実行で生成されるフレーム数を固定値としてアサートしないでください。ページサイズ、データベースのレイアウト、実行時ライブラリのバージョンによって数は変わるため、テストでは相対的な関係を確認します。

また、チェックポイント済みフレーム数がログ内のフレーム数に追いついても、WAL ファイルがすぐに小さくなるとは限りません。後で再利用するために、ファイル内の領域が保持されることがあります。本番環境では、読み取りトランザクションの継続時間、チェックポイントの結果、書き込みの傾向を併せて記録してください。ディレクトリに大きなファイルがあるという理由だけでログを削除してはいけません。

2 番目のアサーションが失敗した場合は、まず開いたままの読み取りトランザクション、カーソル、ほかの接続を探し、その後でチェックポイント戦略を検討します。最初から強制的な切り詰めを繰り返すループを追加して、本当の原因である長時間の読み取りトランザクションを隠さないでください。

フィクスチャを iOS のデータ層テストへ移す

ホスト側の実験で検証できるのは、現在の Python にリンクされている SQLite です。iOS に移す際は、対象のシミュレータまたはデバイス上で、実際のデータアクセス層を通して再実行し、その環境の sqlite3_libversion() を記録してください。ホスト側の結果を、すべての実行環境での動作の証明として扱うことはできません。

基盤となる接続は、実際に独立した二つの接続を維持します。「最初の読み取り完了」「書き込みのコミット」「再読み取り」の三段階は、明示的なテスト用同期バリアで制御し、スリープによる待機には頼らないでください。接続プールが二つの操作を同じ接続に割り当てると、実験の意味がなくなります。

最後に、次の四点を確認します。既存のトランザクションでは古い値を読み、新しいトランザクションでは新しい値を読むこと。例外が発生した経路でもトランザクションを終了すること。長時間の読み取りトランザクションを解放すると、チェックポイントが進むこと。非同期クエリでは、キャンセル後にカーソルが閉じられ、トランザクションも終了していることを確認します。これらのアサーションを回帰テストに残しておけば、次に一覧で古いデータが表示されたとき、それが正しいスナップショットなのか、それともトランザクションの生存期間が想定範囲を超えているのかを、まず判断できます。

よくある質問

BEGINだけで読み取りスナップショットは確定しますか?

通常の遅延トランザクションでは確定しません。このWAL実験では、最初の実際の読み取りでスナップショットが成立します。

書き込み後、既存の読み取りで新しい値が見えるべきですか?

いいえ。既存の読み取りトランザクションは元のスナップショットを参照します。終了して読み直すと、コミット済みの新しい値を確認できます。

チェックポイント後はWALファイルが必ず小さくなりますか?

必ずしも小さくなりません。データベースへの書き戻しとファイルの切り詰めは別の処理です。

専用のクラウドMacに作業を任せよう

専用の物理 Mac mini をレンタルして、用途に合わせてロケーションと利用期間を選べます。利用開始後は管理画面で接続情報を確認できます。ご利用中に問題が発生した場合は、管理画面からサポートチケットをお送りください。

クラウドMacを選ぶ

用途に合わせてロケーションを選ぶ

構成と利用期間を確認して、専用の物理 Mac mini をお申し込みください。