roomを使っているアプリでCrashlyticsにSQLiteCantOpenDatabaseException
によるクラッシュが記録されていました。
原因はいろいろあるようで、ストレージがいっぱいになってしまっていたり、ファイルの破損のようなものだったり、ストレージそのものが破損しているのではと思われるものだったり。
いずれも繰り返し発生し、アプリの起動すらままならない状況になっていそうですが、モバイル端末で動いている以上、アプリとして完全に回避するのは難しそうな問題です。しかし、ファイルの破損であれば、DBファイルを作り直させれば復帰できる可能性があります。DBに重要なデータが格納されている場合は安易に削除できないですが、キャッシュとして使っているなど、データが消失しても大きな問題にならないなら、復帰を優先しても良さそうです。
前置きが長くなりましたが、データが消えてもいいからクラッシュを回避したい。ということで調査してみました。
スタックトレースは以下のようになっています。
android.database.sqlite.SQLiteConnection.open (SQLiteConnection.java:257)
android.database.sqlite.SQLiteConnection.open (SQLiteConnection.java:210)
android.database.sqlite.SQLiteConnectionPool.openConnectionLocked (SQLiteConnectionPool.java:516)
android.database.sqlite.SQLiteConnectionPool.open (SQLiteConnectionPool.java:206)
android.database.sqlite.SQLiteConnectionPool.open (SQLiteConnectionPool.java:198)
android.database.sqlite.SQLiteDatabase.openInner (SQLiteDatabase.java:922)
android.database.sqlite.SQLiteDatabase.open (SQLiteDatabase.java:902)
android.database.sqlite.SQLiteDatabase.openDatabase (SQLiteDatabase.java:766)
android.database.sqlite.SQLiteDatabase.openDatabase (SQLiteDatabase.java:755)
android.database.sqlite.SQLiteOpenHelper.getDatabaseLocked (SQLiteOpenHelper.java:378)
android.database.sqlite.SQLiteOpenHelper.getWritableDatabase (SQLiteOpenHelper.java:321)
androidx.sqlite.db.framework.FrameworkSQLiteOpenHelper$OpenHelper.getWritableOrReadableDatabase (FrameworkSQLiteOpenHelper.kt:232)
androidx.sqlite.db.framework.FrameworkSQLiteOpenHelper$OpenHelper.innerGetDatabase (FrameworkSQLiteOpenHelper.kt:190)
androidx.sqlite.db.framework.FrameworkSQLiteOpenHelper$OpenHelper.getSupportDatabase (FrameworkSQLiteOpenHelper.kt:151)
androidx.sqlite.db.framework.FrameworkSQLiteOpenHelper.getWritableDatabase (FrameworkSQLiteOpenHelper.kt:104)
androidx.room.RoomDatabase.inTransaction (RoomDatabase.kt:632)
androidx.room.RoomDatabase.assertNotSuspendingTransaction (RoomDatabase.kt:451)
androidx.room.RoomDatabase.query (RoomDatabase.kt:480)
対処方法:allowDataLossOnRecoveryを指定する
SupportSQLiteOpenHelper.Configuration
でallowDataLossOnRecovery
がtrueになっている場合、データの消失を許容して復帰させる動作になります。
これをRoomのどこで指定するかというと、BatabaseBuilder
に、openHelperFactory
というメソッドがあり、ここでSupportSQLiteOpenHelperの作成処理を変更することができます。
Room.databaseBuilder(context, HogeDatabase::class.java, DB_NAME)
.openHelperFactory {
// ここで実装する
}
.build()
引数にSupportSQLiteOpenHelper.Configuration
が渡されるため、以下のように実装すると概ねデフォルトの動作と同等の処理になります。
Room.databaseBuilder(context, HogeDatabase::class.java, DB_NAME)
.openHelperFactory {
FrameworkSQLiteOpenHelperFactory().create(it)
}
.build()
しかし、SupportSQLiteOpenHelper.Configuration
はImmutable
でdata classのcopyメソッドがある訳でもないので、builderを使ってパラメータをコピーする必要があります。
allowDataLossOnRecovery
をtrueに書き換えるだけなら、以下のような拡張関数を作成しても良いでしょう
private fun Configuration.allowDataLossOnRecovery(context: Context): Configuration =
Configuration.builder(context)
.name(name)
.callback(callback)
.noBackupDirectory(useNoBackupDirectory)
.allowDataLossOnRecovery(true)
.build()
これを使って以下のようにすることで、allowDataLossOnRecovery
がtrueになったSupportSQLiteOpenHelper
が使われるようになります。
Room.databaseBuilder(context, HogeDatabase::class.java, DB_NAME)
.openHelperFactory {
FrameworkSQLiteOpenHelperFactory()
.create(it.allowDataLossOnRecovery(context))
}
.build()
注意点として、 Configuration.Builder
のコンストラクタはinternal
であるため、通常は使えません。代わりに Configuration.builder(Context)
というメソッドを使います。builder
で、頭文字が小文字です。(これに気づかずしばらくはまりました)
--
DBデータが重要で再取得不可能なものの場合、安易に消去を許容できないでしょうが、データの性質によってはこういう対処方法もあるよという紹介でした。
allowDataLossOnRecoveryを設定した場合の挙動
蛇足ですが、allowDataLossOnRecoveryを設定したらどうなるかを調べて見ましょう
(今回の件はスタックトレースから追っていき、allowDataLossOnRecoveryを設定すればいけそうだ→設定の仕方はと調べました)
FrameworkSQLiteOpenHelperが実装になっています。
innerGetDatabase
を見ると以下のようになっていて、Exceptionのハンドリングも行われています。
private fun innerGetDatabase(writable: Boolean): SQLiteDatabase {
val name = databaseName
val isOpen = opened
if (name != null && !isOpen) {
val databaseFile = context.getDatabasePath(name)
val parentFile = databaseFile.parentFile
if (parentFile != null) {
parentFile.mkdirs()
if (!parentFile.isDirectory) {
Log.w(TAG, "Invalid database parent file, not a directory: $parentFile")
}
}
}
try {
return getWritableOrReadableDatabase(writable)
} catch (t: Throwable) {
// No good, just try again...
}
try {
// Wait before trying to open the DB, ideally enough to account for some slow I/O.
// Similar to android_database_SQLiteConnection's BUSY_TIMEOUT_MS but not as much.
Thread.sleep(500)
} catch (e: InterruptedException) {
// Ignore, and continue
}
val openRetryError: Throwable =
try {
return getWritableOrReadableDatabase(writable)
} catch (t: Throwable) {
t
}
if (openRetryError is CallbackException) {
// Callback error (onCreate, onUpgrade, onOpen, etc), possibly user error.
val cause = openRetryError.cause
when (openRetryError.callbackName) {
CallbackName.ON_CONFIGURE,
CallbackName.ON_CREATE,
CallbackName.ON_UPGRADE,
CallbackName.ON_DOWNGRADE -> throw cause
CallbackName.ON_OPEN -> {}
}
// If callback exception is not an SQLiteException, then more certainly it is not
// recoverable.
if (cause !is SQLiteException) {
throw cause
}
} else if (openRetryError is SQLiteException) {
// Ideally we are looking for SQLiteCantOpenDatabaseException and similar, but
// corruption can manifest in others forms.
if (name == null || !allowDataLossOnRecovery) {
throw openRetryError
}
} else {
throw openRetryError
}
// Delete the database and try one last time. (mAllowDataLossOnRecovery == true)
context.deleteDatabase(name)
try {
return getWritableOrReadableDatabase(writable)
} catch (ex: CallbackException) {
// Unwrap our exception to avoid disruption with other try-catch in the call stack.
throw ex.cause
}
}
最初のディレクトリ作成は置いておいて、getWritableOrReadableDatabase
を呼び出しているのは以下ですね。
try {
return getWritableOrReadableDatabase(writable)
} catch (t: Throwable) {
// No good, just try again...
}
try {
// Wait before trying to open the DB, ideally enough to account for some slow I/O.
// Similar to android_database_SQLiteConnection's BUSY_TIMEOUT_MS but not as much.
Thread.sleep(500)
} catch (e: InterruptedException) {
// Ignore, and continue
}
val openRetryError: Throwable =
try {
return getWritableOrReadableDatabase(writable)
} catch (t: Throwable) {
t
}
一回呼び出して、Exceptionが発生したら一回握りつぶして、500ms待って、リトライしてますね。
そして、2回目の呼び出しでもやはりExceptionが発生した場合、以下のような処理が行われています。
if (openRetryError is CallbackException) {
// Callback error (onCreate, onUpgrade, onOpen, etc), possibly user error.
val cause = openRetryError.cause
when (openRetryError.callbackName) {
CallbackName.ON_CONFIGURE,
CallbackName.ON_CREATE,
CallbackName.ON_UPGRADE,
CallbackName.ON_DOWNGRADE -> throw cause
CallbackName.ON_OPEN -> {}
}
// If callback exception is not an SQLiteException, then more certainly it is not
// recoverable.
if (cause !is SQLiteException) {
throw cause
}
} else if (openRetryError is SQLiteException) {
// Ideally we are looking for SQLiteCantOpenDatabaseException and similar, but
// corruption can manifest in others forms.
if (name == null || !allowDataLossOnRecovery) {
throw openRetryError
}
} else {
throw openRetryError
}
// Delete the database and try one last time. (mAllowDataLossOnRecovery == true)
context.deleteDatabase(name)
try {
return getWritableOrReadableDatabase(writable)
} catch (ex: CallbackException) {
// Unwrap our exception to avoid disruption with other try-catch in the call stack.
throw ex.cause
}
SQLiteCantOpenDatabaseException
は SQLiteException
のサブクラスなのでelse if節の中に入ります。allowDataLossOnRecovery
のフラグが立っている場合、throwには進まず、DBファイルを削除して、再度オープンを試みる、という動作になっていますね。
ということでまとめると
DBファイルを読み出し
失敗したら500msまって
再度度オープンを試みる
それでも失敗したら
allowDataLossOnRecoveryがfalseならクラッシュ
allowDataLossOnRecoveryがtrueなら
DBファイルを削除して再度オープンを試みる
という動作になっていました。