Migrate to multiplatform SQLite library#6202
Conversation
app/src/main/java/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt
Outdated
Show resolved
Hide resolved
|
Wow, awesome! You did it!
Unfortunately I won't be able to review this until sometime next week probably.
One comment from scrolling through it vertically: The API of the old wrapper interface was basically a copy of the Android one, because that's what was exposed on Android.
The new interface doesn't *need* to copy that as long as it remains well readable.
Don't know regarding transactions. Did you check the syntax for it in the sqlite docs?
El 8 de abril de 2025 22:28:50 CEST, Flo Edelmann ***@***.***> escribió:
…
@FloEdelmann commented on this pull request.
> + override fun <T> transaction(block: () -> T): T {
+ databaseConnection.execSQL("BEGIN IMMEDIATE TRANSACTION")
+ try {
+ val result = block()
+ databaseConnection.execSQL("END TRANSACTION")
+ return result
+ } catch (t: Throwable) {
+ databaseConnection.execSQL("ROLLBACK TRANSACTION")
+ throw t
+ }
+ }
This doesn't work correctly yet: I consistently get an error when downloading data:
```
Unable to download
android.database.SQLException: Error code: 5, message: cannot commit transaction - SQL statements in progress
at androidx.sqlite.driver.bundled.BundledSQLiteStatementKt.nativeStep(Native Method)
at androidx.sqlite.driver.bundled.BundledSQLiteStatementKt.access$nativeStep(BundledSQLiteStatement.jvmAndroid.kt:1)
at androidx.sqlite.driver.bundled.BundledSQLiteStatement.step(BundledSQLiteStatement.jvmAndroid.kt:100)
at androidx.sqlite.SQLite.execSQL(SQLite.kt:56)
at de.westnordost.streetcomplete.data.StreetCompleteDatabase.transaction(StreetCompleteDatabase.kt:130)
at de.westnordost.streetcomplete.data.osm.mapdata.WayDao.getAll(WayDao.kt:67)
at de.westnordost.streetcomplete.data.osm.mapdata.MapDataController$getWays$1.invoke(MapDataController.kt:193)
at de.westnordost.streetcomplete.data.osm.mapdata.MapDataController$getWays$1.invoke(MapDataController.kt:193)
at de.westnordost.streetcomplete.data.osm.mapdata.MapDataCache.getWays$lambda$26(MapDataCache.kt:265)
at de.westnordost.streetcomplete.data.osm.mapdata.MapDataCache.$r8$lambda$ZWQirpDjfvRnK1EiAAaEtzZT8dc(Unknown Source:0)
at de.westnordost.streetcomplete.data.osm.mapdata.MapDataCache$$ExternalSyntheticLambda8.invoke(D8$$SyntheticClass:0)
at de.westnordost.streetcomplete.data.osm.mapdata.MapDataCache.getElements(MapDataCache.kt:235)
at de.westnordost.streetcomplete.data.osm.mapdata.MapDataCache.getWays(MapDataCache.kt:265)
at de.westnordost.streetcomplete.data.osm.mapdata.MapDataController.getWays(MapDataController.kt:193)
at de.westnordost.streetcomplete.data.osm.mapdata.MapDataController.completeMapData(MapDataController.kt:151)
at de.westnordost.streetcomplete.data.osm.mapdata.MapDataController.putAllForBBox(MapDataController.kt:65)
at de.westnordost.streetcomplete.data.osm.mapdata.MapDataDownloader$download$2.invokeSuspend(MapDataDownloader.kt:29)
```
The [Android SQLite library](https://developer.android.com/training/data-storage/sqlite?hl=en) seems to have done much more than this function currently does: https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/java/android/database/sqlite/SQLiteSession.java
But I have no idea how much of that is needed. If it's everything, then it's a lot, and I think we should then wait until the [multiplatform Jetpack SQLite library](https://developer.android.com/kotlin/multiplatform/sqlite) has some convenience functions around transactions.
--
Reply to this email directly or view it on GitHub:
#6202 (review)
You are receiving this because your review was requested.
Message ID: ***@***.***>
|
|
Ah, of course it doesnt work! Well, at least, this is what I am thinking right now:
A transaction, as any SQL statement is written as one string. When you send off "begin transaction" as one sql statement, that statement in itself is already one transaction (containing that statement). If it doesn't already fail there (because within the statement, you didn't specify the transaction's end, which might be a SQL syntax error), then it will fail at "end transaction" because it's like, "what transaction?" (the two sql statements are not connected in any way).
El 8 de abril de 2025 22:28:50 CEST, Flo Edelmann ***@***.***> escribió:
…
@FloEdelmann commented on this pull request.
> + override fun <T> transaction(block: () -> T): T {
+ databaseConnection.execSQL("BEGIN IMMEDIATE TRANSACTION")
+ try {
+ val result = block()
+ databaseConnection.execSQL("END TRANSACTION")
+ return result
+ } catch (t: Throwable) {
+ databaseConnection.execSQL("ROLLBACK TRANSACTION")
+ throw t
+ }
+ }
This doesn't work correctly yet: I consistently get an error when downloading data:
```
Unable to download
android.database.SQLException: Error code: 5, message: cannot commit transaction - SQL statements in progress
at androidx.sqlite.driver.bundled.BundledSQLiteStatementKt.nativeStep(Native Method)
at androidx.sqlite.driver.bundled.BundledSQLiteStatementKt.access$nativeStep(BundledSQLiteStatement.jvmAndroid.kt:1)
at androidx.sqlite.driver.bundled.BundledSQLiteStatement.step(BundledSQLiteStatement.jvmAndroid.kt:100)
at androidx.sqlite.SQLite.execSQL(SQLite.kt:56)
at de.westnordost.streetcomplete.data.StreetCompleteDatabase.transaction(StreetCompleteDatabase.kt:130)
at de.westnordost.streetcomplete.data.osm.mapdata.WayDao.getAll(WayDao.kt:67)
at de.westnordost.streetcomplete.data.osm.mapdata.MapDataController$getWays$1.invoke(MapDataController.kt:193)
at de.westnordost.streetcomplete.data.osm.mapdata.MapDataController$getWays$1.invoke(MapDataController.kt:193)
at de.westnordost.streetcomplete.data.osm.mapdata.MapDataCache.getWays$lambda$26(MapDataCache.kt:265)
at de.westnordost.streetcomplete.data.osm.mapdata.MapDataCache.$r8$lambda$ZWQirpDjfvRnK1EiAAaEtzZT8dc(Unknown Source:0)
at de.westnordost.streetcomplete.data.osm.mapdata.MapDataCache$$ExternalSyntheticLambda8.invoke(D8$$SyntheticClass:0)
at de.westnordost.streetcomplete.data.osm.mapdata.MapDataCache.getElements(MapDataCache.kt:235)
at de.westnordost.streetcomplete.data.osm.mapdata.MapDataCache.getWays(MapDataCache.kt:265)
at de.westnordost.streetcomplete.data.osm.mapdata.MapDataController.getWays(MapDataController.kt:193)
at de.westnordost.streetcomplete.data.osm.mapdata.MapDataController.completeMapData(MapDataController.kt:151)
at de.westnordost.streetcomplete.data.osm.mapdata.MapDataController.putAllForBBox(MapDataController.kt:65)
at de.westnordost.streetcomplete.data.osm.mapdata.MapDataDownloader$download$2.invokeSuspend(MapDataDownloader.kt:29)
```
The [Android SQLite library](https://developer.android.com/training/data-storage/sqlite?hl=en) seems to have done much more than this function currently does: https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/java/android/database/sqlite/SQLiteSession.java
But I have no idea how much of that is needed. If it's everything, then it's a lot, and I think we should then wait until the [multiplatform Jetpack SQLite library](https://developer.android.com/kotlin/multiplatform/sqlite) has some convenience functions around transactions.
--
Reply to this email directly or view it on GitHub:
#6202 (review)
You are receiving this because your review was requested.
Message ID: ***@***.***>
|
app/src/main/java/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt
Outdated
Show resolved
Hide resolved
app/src/main/java/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt
Show resolved
Hide resolved
app/src/main/java/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt
Show resolved
Hide resolved
app/src/main/java/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt
Outdated
Show resolved
Hide resolved
app/src/main/java/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt
Outdated
Show resolved
Hide resolved
| val statement = databaseConnection.prepareInsert(table, columnNames.toList(), conflictAlgorithm) | ||
| val result = ArrayList<Long>() | ||
| transaction { | ||
| for (values in valuesList) { | ||
| require(values.size == columnNames.size) | ||
| statement.bindAll(values) | ||
| val rowId = statement.executeInsert() | ||
| result.add(rowId) | ||
| statement.clearBindings() | ||
| statement.reset() | ||
| } | ||
| statement.close() | ||
| } | ||
| return result |
There was a problem hiding this comment.
By the way, this can potentially be optimized (quite a bit?).
SQLite allows a syntax like this:
INSERT INTO my_table (column_a, column_b, column_c)
VALUES (1,2,"a"), (2,4,"b"), (3,1,"c"), (4,5,"d")to insert 4 rows. I.e. no need for a transaction, no need for repeating the first line x times.
To be honest, I thought I've already long done this, but apparently not.
There was a problem hiding this comment.
(But before blindly doing a premature optimization, it might be worth it to confirm that this would actually be faster in some unit test or something. Maybe binding, like, up to tens of thousands of parameters to a prepared statement with tens of thousands placeholders comes with implications or limitations in itself. Also, it needn't be part of this PR.)
to make sure it doesn't conflict with streetcomplete#6202 performance boost mostly relevant for old phones that can't run maplibre 11
|
Any news here? Or rather, do you intend to follow this through? I imagine some In case the above is not viable, any stuff that needs transactions to guarantee database integrity should go into one DAO, so for strongly interconnected tables, we have one DAO that actually manages multiple tables. Plus, of course, any queries involving transactions, i.e. multiple queries, the queries need to be rewritten probably with |
f73aab6 to
2444d09
Compare
|
I finally got around to looking into this PR, and I think I figured it out 🙂 Executing I rebased and force-pushed to make reviewing easier; the first commit is basically the one you already reviewed, just based upon the current master branch (i.e. DB instantiation in |
There was a problem hiding this comment.
Oh?
Hm. My mind was already set on that it would be necessary to either check if we need transactions spanning multiple queries at all, or to put any transactions we might need into one sql statement.
Let me try to wrap my head around this.
So if BEGIN TRANSACTION; executes just fine and opens a transaction and then, in code, submit more queries to sqlite, like that,
db.startTransaction()
db.query(…)
db.query(…)
db.endTransaction()
some other part of the program code might also call db.query() from a different thread and thus cause that query to be lumped in together with the transaction that was started by a completely unrelated DAO (as the database runs concurrent to the program code, of course). Worse yet, it is completely possible that another DAO explicitly starts another transaction, and latest then this should result in an error. (This error you already encountered, IIRC.) That resulting behavior is what you describe as "transactions out of sync", right?
The obvious solution for me would be to do everything for one transaction in one SQL statement
db.query("""
BEGIN TRANSACTION;
…
…
COMMIT;
""")
And, I would have expected, that the Android SQLite database layer would have done that under the hood. But, you checked this, and actually it does something else? Does it truly linearize all access to the database? That sounds... uh, that is has horrible consequences for performance: Any write operation will block any read operation? (Or already did in Android's SQLite database layer?)
Does the preparing of the statements also have to be in the synchronized section? (Or, for that matter, everything except statement.execute*)
Notes/thoughts to myself (can refactor later):
-
rename
Databaseinterface toDatabaseConnection -
rename
StreetCompleteDatabasetoSQLiteDatabaseConnection(or similar) and upgrade + create logic should move out of this class (aSQLiteDatabaseConnectionshould be the result of a database (connection) initialization operation, e.g.DatabaseInitializer.initialize(sqliteConnection) : DatabaseConnection
| private var transactionDepth = 0 | ||
|
|
||
| init { | ||
| databaseConnection.execSQL("PRAGMA journal_mode=WAL") |
There was a problem hiding this comment.
A comment as to why this is necessary is necessary. Related page from the documentation:
https://www.sqlite.org/isolation.html
I read a bit about this.
Apparently, all intermediate changes made within one database transaction are actually visible for all readers on the same database connection. We use just one database connection. So, basically the very thing that (we hoped) a transaction should do, guarantee data consistency at all times (e.g. there are no elements without geometry) is actually not guaranteed currently, unless this is prevented by Android database driver in one way or another.
A claim made in some blog article that intermediate changes made within one transaction would not be visible for all readers on the same database connection by turning WAL mode on isn't supported by the documentation. (To the contrary, it even says that when an UPDATE runs in parallel to a SELECT, that the data returned by the SELECT may contain old data, new data or both, i.e. it is undefined.)
Do you have more info on this matter?
(This is kind of important. Because if transactions already don't do what we hoped they do, and it did not lead to obvious problems, maybe we don't need them at all, then. Or, at least, in places where we need to make guarantees about data consistency, e.g. every element has a geometry, guarantee it programmatically after the query is done)
There was a problem hiding this comment.
We use just one database connection
Note that while I do not have Android experience specifically, generally with SQL databases if you use different (i.e. parallel) contexts / threads / etc. you should use different (multiple) database handles for each of them if you intend for their transactions to be separate.
There was a problem hiding this comment.
Actually, for proper encapsulation, we'd need to use one SQLite connection for every operation. E.g. getting OSM data by bounding box must be done with one SQLite connection, writing all those OSM elements and their geometries just downloaded to the database must be done with another SQLite connection. Otherwise, the getting of the bbox data may include partly updated and partly not-updated data.
However, there are a few question marks here:
-
The SQLite connections would need to be managed by the *Controller classes, though, because one operation might include updating several tables at once, see e.g. MapDataController and these should be in the same transaction / same connection
-
What would be the performance overhead for creating and closing a new sqlite connection for each operation? Is it greater than serializing all access to the database?
-
Using the standard Android SQLite API, which is of course also used under the hood at least when using androidx.sqlite:sqlite-bundled, is it actually possible to use more than one SQLite connection? The documentation sounds like SQLiteOpenHelper#getWritableDatabase() will always return the same connection
There was a problem hiding this comment.
And this is why the next step is to find out what is actually the status quo. I.e. what exactly does the current Android SQLite API do (because it works and doesn't create obvious problems). We then have the option to mirror that behavior, or do something else, in the hope of improving performance through parallelization.
|
|
||
| override fun insert( | ||
| table: String, | ||
| values: Collection<Pair<String, Any?>>, |
There was a problem hiding this comment.
(If we have to pick apart keys and values anyway, might just as well make the interface consistent to insertMany and have columns and values as separate parameters. Same for update)
Attempt to fix #5417.
As mentioned in #6119, the multiplatform Jetpack SQLite library is quite bare-bones, so a lot of stuff that was previously handled by the Android SQLite library now has to be implemented on our side.