bug: a failed run() rolls back a transaction it did not open (Android)
まだ誰も着手していません。
評価
- 難易度
- 3/5
- 見積もり時間
- 1〜2日
- 初心者へのやさしさ
- 72/100
調査の方向性
Database.java の run() にある finally ブロックから始め、続いて execute() と executeSet() にある対応するクリーンアップを比較します。Issue に記載された Android のシーケンスを再現し、トランザクションの開始に失敗しても既存のトランザクションがアクティブなままになり、tx-write-1 が保持され、後続の commit が成功できることを確認します。
索引モデルが issue の本文から書いたものです。
説明
Plugin version:
7.0.3
Platform(s):
Android (reproduced on a device). Not about web — on web the same collision fails earlier and louder.
Current behavior:
When run() is called with transaction: true while an explicit transaction (beginTransaction()) is already open, it correctly fails with Already in transaction — but on the way out it rolls back the transaction it collided with, which it did not open. The rows that transaction had already written are silently discarded, statements issued afterwards execute outside any transaction and auto-commit, and the eventual commitTransaction() fails.
Measured on a device:
intruder: "Run: Failed in beginTransactionAlready in transaction"
transactionStillActiveAfterIntruder: false <- nobody asked for this
secondWriteInsideTransaction: "ok" <- now running outside any transaction
commit: "CommitTransaction: Cannot perform this operation
because there is no current transaction."
finalRows: ["tx-write-2"] <- tx-write-1 is gone
tx-write-1 was written inside the explicit transaction before the intruding call arrived. It is gone, and nothing in the log says a rollback happened.
Expected behavior:
A call that fails to begin a transaction should leave transaction state untouched. Cleanup should roll back only a transaction that the same call opened — not whatever transaction happens to be open on the connection.
The Already in transaction error itself is correct and useful; the collateral rollback is the bug.
Steps to reproduce:
Run against a throwaway database on Android. No app framework needed — this is the raw plugin API.
Related code:
const sqlite = window.Capacitor.Plugins.CapacitorSQLite;
const database = "reprobug";
await sqlite.createConnection({ database, encrypted: false, mode: "no-encryption", version: 1, readonly: false });
await sqlite.open({ database, readonly: false });
await sqlite.execute({ database, statements: "CREATE TABLE IF NOT EXISTS t (label TEXT);", transaction: false, readonly: false, isSQL92: true });
await sqlite.run({ database, statement: "DELETE FROM t;", values: [], transaction: true, readonly: false, returnMode: "no" });
// An explicit transaction writes one row.
await sqlite.beginTransaction({ database });
await sqlite.run({ database, statement: "INSERT INTO t (label) VALUES ('tx-write-1');", values: [], transaction: false, readonly: false, returnMode: "no" });
// An unrelated write arrives, asking for its own transaction. It fails, as documented.
try {
await sqlite.run({ database, statement: "INSERT INTO t (label) VALUES ('intruder');", values: [], transaction: true, readonly: false, returnMode: "no" });
} catch (error) {
console.log("intruder:", error.message); // Already in transaction
}
// The explicit transaction is no longer active, and nobody ended it.
console.log("still active:", (await sqlite.isTransactionActive({ database })).result); // false
await sqlite.run({ database, statement: "INSERT INTO t (label) VALUES ('tx-write-2');", values: [], transaction: false, readonly: false, returnMode: "no" });
try {
await sqlite.commitTransaction({ database });
} catch (error) {
console.log("commit:", error.message); // no current transaction
}
const rows = await sqlite.query({ database, statement: "SELECT label FROM t;", values: [], readonly: false });
console.log("rows:", rows.values.map((r) => r.label)); // ["tx-write-2"] — tx-write-1 lost
The cleanup lives in Database.java, in the finally of run() (and the same shape in execute() / executeSet()):
} finally {
if (_db != null && transaction && _db.inTransaction()) rollbackTransaction();
}
_db.inTransaction() reports engine state, not ownership, so a call whose own beginTransaction() threw still satisfies the condition and rolls back the transaction belonging to somebody else. Tracking whether this invocation actually opened a transaction — and only rolling back in that case — would fix it.
Other information:
This is not the queueing discussion from #215 and #258, which are about whether the plugin should serialize concurrent writes. Even accepting that the caller must serialize, a failed call should not destroy another transaction's committed work. The failure mode is what makes it worth separating: Already in transaction is visible, but the lost rows are not — no error, no log, and the data is simply missing.
How it shows up in practice: any second writer that isn't aware a transaction is open — a sync engine applying server records, a log writer, an analytics flush — lands mid-transaction and takes the transaction down with it. docs/SQLiteTransaction.md tells callers to pass transaction: false inside a transaction, which is the right advice for code that knows about the transaction; the problem is code that doesn't.
Note that with transaction: false the collision is quieter but still lossy: the write silently joins the open transaction and is rolled back with it.
Workaround, for anyone else who lands here: serialize units of work in the caller, so a statement or a whole transaction is exclusive. A promise chain around every write and every transaction is enough. For Kysely users specifically, locking in the dialect's acquireConnection() and releasing in releaseConnection() does it, since Kysely holds one connection for the duration of a transaction.
Device: Xiaomi 24117RN76G, Android 16 (SDK 36).
Capacitor doctor:
Latest Dependencies:
@capacitor/cli: 8.5.0
@capacitor/core: 8.5.0
@capacitor/android: 8.5.0
@capacitor/ios: 8.5.0
Installed Dependencies:
@capacitor/cli: 8.5.0
@capacitor/ios: 8.5.0
@capacitor/android: 8.5.0
@capacitor/core: 8.5.0
[success] Android looking great! 👌
- 主要言語
- Swift
- スター
- 663
- フォーク
- 159
- PR マージ指標
- 30日以内にマージされた PR はありません
環境構築
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートあり
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
capacitor-community/sqlite のほかの issue
-
難易度 5/5 1週間以上 初心者へのやさしさ 25/100
capacitor-community/sqlite#693 · リアクション 7 件 ·
-
bug/fix needs: triage
難易度 3/5 1〜2日 初心者へのやさしさ 68/100
capacitor-community/sqlite#690 ·
-
feature needs: triage
難易度 3/5 1〜2日 初心者へのやさしさ 55/100
capacitor-community/sqlite#689 ·
-
bug/fix needs: triage
難易度 3/5 1〜2日 初心者へのやさしさ 45/100
capacitor-community/sqlite#685 ·
-
bug/fix needs: triage
難易度 3/5 1〜2日 初心者へのやさしさ 48/100
capacitor-community/sqlite#671 ·
capacitor-community/sqlite の issue をすべて見る
似ている issue
-
難易度 2/5 1〜3時間 初心者へのやさしさ 87/100
メンテナーはふだん 1 日以内に返信
-
area: build-and-ci bug difficulty:1 good first issue help wanted S3: minor
難易度 2/5 1〜3時間 初心者へのやさしさ 86/100
manaflow-ai/cmux#16193 ·
メンテナーはふだん 1 日以内に返信
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 84/100
-
難易度 2/5 1〜3時間 初心者へのやさしさ 85/100
permissionlesstech/bitchat#1743 ·
メンテナーはふだん 1 日以内に返信
-
難易度 1/5 1時間未満 初心者へのやさしさ 70/100
521xueweihan/HelloGitHub#3816 ·