0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

memory: SQLite は transactional() と相性最悪|別接続が別の空DBを掴む罠

0
Posted at

はじめに

インメモリ SQLite(:memory:)でテストを回していて、トランザクションのロールバック検証だけ書けない・落ちる方へ。

SQLite の :memory: は、テストを速く・後腐れなく回すための定番です。ディスクに触らず、テストが終われば消えます。多くの現場で「テスト DB といえばインメモリ SQLite」になっています。

ところが、この :memory: にはトランザクション周りの静かな罠があります。トランザクション・マネージャが内部で新しいコネクションを張る実装だと、そのコネクションは元のテストが用意した DB とは別の空 DB につながってしまいます。しかも症状が出ません。トランザクションを踏まないテストは全部グリーンのまま通り、ロールバックを検証しようとした瞬間に「なぜかデータが 1 件も見えない」と詰まります。

この記事は、自作フレームワーク NENE2 とその上のプロダクト(RAG コーパスサービス nene-corpus)で実際にこの罠を踏み、最終的に DatabaseTestKit という公式のテスト配線ヘルパーで構造的に塞いだ一部始終をコード付きでまとめます。「インメモリ SQLite でロールバックのテストが書けない」で検索して来た人向けに、なぜそうなるのかを PDO のレイヤーまで降りて説明します。

この記事で分かること:

  • :memory: が「接続を開くたび別の空 DB」になり、transactional() が別接続を張ると壊れる仕組み
  • トランザクションを踏まないテストだけ緑になる「症状が出ない」怖さ
  • フィクスチャを組む境界で :memory: を fail-fast に弾く(DatabaseTestKit)構造的対策

なぜ :memory: は「別接続 → 別の空 DB」になるのか

まず前提として、sqlite::memory: という DSN は 接続を開くたびに新しい独立したインメモリ DB を作ります。同じ文字列を渡しても、2 回 new PDO(...) すれば中身は別物になります(共有したいなら file::memory:?cache=shared のような shared-cache DSN が要ります)。ファイルベースの SQLite なら、同じパスを開いた別コネクションは同じ実体を見るので、この問題は起きません。

NENE2 のコネクションファクトリはまさに素の DSN を組み立てているだけです。

// NENE2 src/Database/PdoConnectionFactory.php
private function sqliteDsn(): string
{
    if ($this->config->name === ':memory:') {
        return 'sqlite::memory:';
    }

    return 'sqlite:' . $this->config->name;
}

public function create(): PDO
{
    $pdo = new PDO($this->dsn(), /* ... */);
    // ...
    return $pdo;
}

create() は呼ばれるたびに new PDO() します。つまり :memory: 構成のファクトリに対して create() を 2 回呼べば、中身の違う空 DB が 2 つできます。ここまでは SQLite の仕様どおりで、まだ罠ではありません。

罠になるのは、クエリ実行器とトランザクション・マネージャがそれぞれ別々のタイミングでコネクションを作る設計と噛み合ったときです。

NENE2 のクエリ実行器は、コネクションを遅延生成してキャッシュします。

// NENE2 src/Database/PdoDatabaseQueryExecutor.php
private function connection(): PDO
{
    return $this->connection ??= $this->connectionFactory->create();
}

一方、トランザクション・マネージャの transactional() は、呼ばれた時点でファクトリから新しいコネクションを取り、そこで beginTransaction() して、コールバックにはその新コネクションを握った別の実行器を渡します。

// NENE2 src/Database/PdoDatabaseTransactionManager.php
public function transactional(callable $callback): mixed
{
    try {
        $connection = $this->connectionFactory->create();  // ← ここで新規接続
        $connection->beginTransaction();

        try {
            $result = $callback(new PdoDatabaseQueryExecutor($this->connectionFactory, $connection));
            $connection->commit();

            return $result;
        } catch (Throwable $exception) {
            if ($connection->inTransaction()) {
                $connection->rollBack();
            }
            throw $exception;
        }
    } catch (PDOException $exception) {
        throw new DatabaseConnectionException('Database transaction could not be completed.', previous: $exception);
    }
}

この設計自体は真っ当です。本番(MySQL / PostgreSQL / ファイル SQLite)では、connectionFactory->create() が何度呼ばれても同じ実体の DBにつながるので、外側の実行器で用意したテーブルを、transactional() 内の新コネクションもちゃんと見られます。

ところが :memory: だけは違います。整理すると、テスト内でこうなります。

  1. 外側の queryExecutorCREATE TABLE などを走らせる → コネクション A(インメモリ DB-A)を生成・キャッシュします。テーブルは DB-A に作られます。
  2. その後 transactional(...) を呼ぶ → 内部で create() され コネクション B(インメモリ DB-B)が新規に生まれます。DB-B はまっさらな空 DB です。
  3. コールバックは DB-B に対して INSERT などを走らせようとする → DB-B にはテーブルが無いので、そもそもクエリが通らない/通っても DB-A には何も反映されません。

症状:「TX を触らないテストだけ通る」

この罠のいちばん厄介なところは、壊れているのに CI がグリーンになりやすい点です。

  • 単純な CRUD テスト(CREATE TABLEINSERTSELECT をすべて外側の queryExecutor=同じ接続 A で完結)は通ります。1 コネクションしか使わないので :memory: でも整合します。
  • transactional() を経由した書き込みのロールバックを検証しようとしたテストだけが、意味不明な形で書けない・落ちます。ロールバック後に外側の実行器で「消えているはず」を確認しようにも、そもそもコミットが別 DB(B) に行っているので、成功パスでも DB-A には何も現れません。

NENE2 の ADR 0012 では、これを DX トライアル(新人〜シニアの複数ペルソナで実施した 78 回の試用)で繰り返し踏んだ既知の落とし穴 (IMP-18) として、こう記録しています。

Trial 17-B re-confirmed IMP-18: :memory: SQLite is incompatible with transactional() because the transaction manager calls $connectionFactory->create(), which opens a fresh connection to a different empty in-memory database. The trap is silent (the test passes locally if it doesn't touch transactions).

(出典: NENE2 docs/adr/0012-sanctioned-test-database-wiring.md

「silent(症状が出ない)」がキーワードです。ローカルで CRUD テストが通ってしまうので、トランザクションの原子性を守りたい肝心のテストを書こうとした人だけが、この壁に突き当たります。

実際にロールバック検証が書けなかった例(nene-corpus)

抽象論ではありません。NENE2 の上に乗る RAG コーパスサービス nene-corpus で、PDF 取り込みユースケースの原子性バグを直そうとしたときにこれを踏みました。

もともと CreatePdfSourceUseCase::execute() は、sources->savedocuments->savechunks->save(複数回)→ sources->update(Ready) を transactional()で走らせていました。途中で例外が飛ぶと documents / chunks が中途半端に残る非原子な状態になり得ます。修正自体は「成功パスを transactional(fn($executor) => ...) 一本に束ね、失敗時はロールバック後に別(非トランザクション)の実行器で Failed の補償レコードを書く」というものでした。

問題はその修正を守るテストです。「途中で書き込みを失敗させて、documents テーブルが空のまま・sources に Failed が 1 件だけ残る」ことを検証したいわけです。ところがテストのハーネスが :memory: SQLite だったため、transactional() が別コネクション=別の空 DB を掴んでしまい、ロールバックの検証そのものが成立しませんでした。PR #325(merged 2026-07-05)の記述がそのまま状況を説明しています。

テストは :memory: SQLite ではなく Nene2\Testing\DatabaseTestKit::sqlite()(file-backed)に切替(transactional() が別コネクションを開くため :memory: では別 DB になり検証できない)。ロールバックを確認する新規テストを追加。

(出典: nene-corpus PR #325)

切り替え後のテストの setUp() は、罠の理由をコメントに明記したうえでファイルベースの SQLite を使っています。

// nene-corpus tests/Ingestion/CreatePdfSourceUseCaseTest.php
protected function setUp(): void
{
    // `:memory:` cannot be used here: `transactional()` opens a *separate*
    // connection via the connection factory, which for `:memory:` would see
    // an empty database. A file-backed SQLite DB lets both the transactional
    // connection and this test's assertion connection see the same data.
    $this->dbPath = sys_get_temp_dir() . '/nene-corpus-pdf-tx-' . uniqid('', true) . '.sqlite';
    $this->kit = DatabaseTestKit::sqlite($this->dbPath);
    // ...
}

これで初めて test_execute_rolls_back_documents_and_chunks_when_a_write_fails_midway(チャンク書き込みを意図的に失敗させ、documents が空・sources に Failed 補償レコード 1 件だけを確認)が成立しました。

DatabaseTestKit で接続共有を公式化する

対症療法として各テストで「ファイルパスを使う」と覚えておくこともできますが、それは次に来る人がまた :memory: と書くという意味でしかありません。NENE2 では ADR 0012 でこれを構造的に塞ぎました。

背景として、PDO の具象アダプタ(PdoConnectionFactory / PdoDatabaseQueryExecutor / PdoDatabaseTransactionManager)は ADR 0009 で @internal 扱い、つまり公開 API の安定保証の外に置かれています。本番コードはインターフェースにだけ依存すべきで、これは正しい方針です。ですがテストは「本物の DB を背負った具象の実行器」を組み立てる必要があり、@internal のクラスを名指しで new せざるを得ませんでした。ここに :memory: の罠も重なります。

そこで公開名前空間 Nene2\Testing\ を新設し、その第一号として DatabaseTestKit を追加しました。3 つのインターフェースを public readonly プロパティで束ねて配り、内部で具象アダプタを配線します。

// NENE2 src/Testing/DatabaseTestKit.php
final readonly class DatabaseTestKit
{
    public function __construct(
        public DatabaseConnectionFactoryInterface $connectionFactory,
        public DatabaseQueryExecutorInterface $queryExecutor,
        public DatabaseTransactionManagerInterface $transactionManager,
    ) {}

    public static function sqlite(string $path): self
    {
        if ($path === ':memory:') {
            throw new InvalidArgumentException(
                'DatabaseTestKit::sqlite() does not support ":memory:". '
                . 'transactional() opens a separate connection that would see an empty in-memory database. '
                . 'Use a file path (e.g. sys_get_temp_dir() . "/" . uniqid("kit-", true) . ".sqlite") instead.',
            );
        }

        return self::fromConfig(DatabaseConfig::sqlite($path));
    }

    public static function fromConfig(DatabaseConfig $config): self
    {
        $factory = new PdoConnectionFactory($config);

        return new self(
            connectionFactory: $factory,
            queryExecutor: new PdoDatabaseQueryExecutor($factory),
            transactionManager: new PdoDatabaseTransactionManager($factory),
        );
    }
}

肝は sqlite(':memory:')InvalidArgumentException を投げることです。ADR の言葉を借りれば、**「silent な IMP-18 の罠を、テストのフィクスチャを組み立てる境界で fail-fast なエラーに変換する」**わけです。うっかり :memory: と書いた瞬間、例外メッセージが「別コネクションが空の DB を見るから、ファイルパスを使え」と理由ごと教えてくれます。同じファイルパスなら実行器もトランザクション・マネージャも同一実体の DB を共有するので、接続をまたいでも整合します。

テスト側は具象クラスを一切名指しせず、一行でフィクスチャが立ちます。

$kit = DatabaseTestKit::sqlite(sys_get_temp_dir() . '/' . uniqid('kit-', true) . '.sqlite');
$kit->queryExecutor->execute('CREATE TABLE counters (id INTEGER PRIMARY KEY, value INTEGER NOT NULL)');

// ロールバックがちゃんと効くことを検証できる
try {
    $kit->transactionManager->transactional(function (DatabaseQueryExecutorInterface $tx) {
        $tx->insert('INSERT INTO counters (value) VALUES (?)', [99]);
        throw new RuntimeException('abort');
    });
} catch (RuntimeException) {}

self::assertNull($kit->queryExecutor->fetchOne('SELECT * FROM counters WHERE value = ?', [99]));

DatabaseTestKit 自身のテスト tests/Testing/DatabaseTestKitTest.php に、コミット・ロールバック・:memory: 拒否の各ケースが入っています。)

別プロダクト(nene-serve)での採用と、あえて :memory: を残した判断

広告配信サービス nene-serve でも、テストの DB 配線を @internal アダプタ直 new から DatabaseTestKit 経由に統一しました(PR #141 / commit 0c26f85、merged 2026-07-05)。ここで面白いのは、serve は SQLite ハーネスを :memory: のまま残したことです。矛盾しているようで、実は罠の本質を正しく理解した判断になっています。

serve のリポジトリテスト用ハーネスは、transactional() を使わず共有の query executor 一本しか駆動しません。1 コネクションで完結するなら、:memory: でも整合するからです。ただし DatabaseTestKit::sqlite():memory: を拒否するので、ガードを迂回して fromConfig() に直接インメモリ構成を渡しています。その意図をハーネスの docblock に明記しています。

// nene-serve tests/Support/TestDatabase.php
// Note: DatabaseTestKit::sqlite() rejects `:memory:` because its
// transactional() helper opens a second connection; this harness only drives
// the shared query executor and never spans connections, so it builds the kit
// via DatabaseTestKit::fromConfig() with an in-memory config directly.
public static function withSchema(string ...$tables): DatabaseQueryExecutorInterface
{
    $executor = DatabaseTestKit::fromConfig(DatabaseConfig::sqlite(':memory:'))->queryExecutor;
    // ...
}

つまり DatabaseTestKit は「:memory: を全面禁止する道具」ではなく、「接続をまたぐ transactional() を触るテストで :memory: を踏ませない」道具です。接続をまたがないと分かっている単一実行器のテストなら、fromConfig():memory: を選ぶ余地は残しつつ、その判断を docblock で言語化して次の人に引き継ぎます。ガードの意味を理解したうえでの明示的なオプトアウトであって、うっかりの :memory: とは別物です。

学び

  • :memory: SQLite は「同一コネクションで完結する」前提の道具です。接続を 2 本以上開く可能性がある処理(典型的にはトランザクション・マネージャが独自に接続を張る設計)と組み合わせると、静かに別 DB を掴みます。
  • 症状が出ないのが最悪です。トランザクションを踏まない CRUD テストは通るので、CI はグリーンです。ロールバックの原子性という、いちばんテストで守りたいものだけが検証不能になります。
  • 対策は「テスト基盤側でコネクションを固定して配る」ことです。その場の作法(ファイルパスを使え)を人間の記憶に頼らず、フィクスチャを組む境界で fail-fast にします:memory: を渡したら理由付きで例外)。NENE2 では DatabaseTestKit がその境界になりました。
  • ただし一律禁止ではありません。1 コネクションで閉じると分かっているテストなら :memory: は速くて有効です。要は「接続をまたぐか否か」で判断し、またぐなら共有実体(ファイル or shared-cache)、またがないなら理由を明記してインメモリ、と使い分けます。

自作フレームワークに限った話ではありません。トランザクション境界で新しいコネクションを取りに行く実装(コネクションプール、transactional() ヘルパー、DI 経由のリポジトリファクトリなど)を採用しているなら、:memory: SQLite でのテストは一度「ロールバックがちゃんと検証できているか」を疑ってみてください。


一次資料

  • NENE2 ADR 0012 Sanctioned Test Database Wiring via Nene2\Testingdocs/adr/0012-sanctioned-test-database-wiring.md・IMP-18 / Issue #1307)
  • NENE2 実装: src/Database/PdoConnectionFactory.php / PdoDatabaseTransactionManager.php / PdoDatabaseQueryExecutor.php / src/Testing/DatabaseTestKit.php / tests/Testing/DatabaseTestKitTest.php
  • nene-corpus PR #325 fix(ingestion): composer.lock の NENE2 pin 是正と PDF 取り込みの TX 化tests/Ingestion/CreatePdfSourceUseCaseTest.php
  • nene-serve PR #141 chore(test,db): adopt NENE2 DatabaseTestKittests/Support/TestDatabase.php

シリーズ/相互リンク(いずれも執筆中・公開前)

  • テスト系: (Clock 注入とフレークテスト)、(マイグレーション境界 / nene-serve #141 の phinx 配線)

── 森 秀之(彩音インターナショナル) 🔗 ayane.co.jp

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?