Skip to content

Commit 9b3b421

Browse files
committed
Lease API that works better with Room
1 parent 62b5b72 commit 9b3b421

File tree

13 files changed

+196
-132
lines changed

13 files changed

+196
-132
lines changed

core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
package com.powersync
22

3-
import androidx.sqlite.SQLiteConnection
4-
import androidx.sqlite.execSQL
53
import app.cash.turbine.turbineScope
64
import co.touchlab.kermit.ExperimentalKermitApi
75
import com.powersync.db.ActiveDatabaseGroup
6+
import com.powersync.db.getString
87
import com.powersync.db.schema.Schema
98
import com.powersync.testutils.UserRow
109
import com.powersync.testutils.databaseTest
@@ -15,16 +14,17 @@ import io.kotest.assertions.throwables.shouldThrow
1514
import io.kotest.matchers.collections.shouldHaveSize
1615
import io.kotest.matchers.shouldBe
1716
import io.kotest.matchers.string.shouldContain
18-
import io.kotest.matchers.throwable.shouldHaveMessage
1917
import kotlinx.coroutines.CompletableDeferred
2018
import kotlinx.coroutines.Dispatchers
2119
import kotlinx.coroutines.async
2220
import kotlinx.coroutines.delay
21+
import kotlinx.coroutines.launch
2322
import kotlinx.coroutines.runBlocking
2423
import kotlinx.coroutines.withContext
2524
import kotlin.test.Test
2625
import kotlin.test.assertEquals
2726
import kotlin.test.assertNotNull
27+
import kotlin.time.Duration.Companion.milliseconds
2828

2929
@OptIn(ExperimentalKermitApi::class)
3030
class DatabaseTest {
@@ -464,36 +464,52 @@ class DatabaseTest {
464464
}
465465

466466
@Test
467-
fun testRawConnection() =
467+
@OptIn(ExperimentalPowerSyncAPI::class)
468+
fun testLeaseReadOnly() =
468469
databaseTest {
469470
database.execute(
470471
"INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)",
471472
listOf("a", "a@example.org"),
472473
)
473-
var capturedConnection: SQLiteConnection? = null
474474

475-
database.readLock {
476-
it.rawConnection.prepare("SELECT * FROM users").use { stmt ->
477-
stmt.step() shouldBe true
478-
stmt.getText(1) shouldBe "a"
479-
stmt.getText(2) shouldBe "a@example.org"
480-
}
475+
val raw = database.leaseConnection(readOnly = true)
476+
raw.prepare("SELECT * FROM users").use { stmt ->
477+
stmt.step() shouldBe true
478+
stmt.getText(1) shouldBe "a"
479+
stmt.getText(2) shouldBe "a@example.org"
480+
}
481+
raw.close()
482+
}
481483

482-
capturedConnection = it.rawConnection
484+
@Test
485+
@OptIn(ExperimentalPowerSyncAPI::class)
486+
fun testLeaseWrite() =
487+
databaseTest {
488+
val raw = database.leaseConnection(readOnly = false)
489+
raw.prepare("INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)").use { stmt ->
490+
stmt.bindText(1, "name")
491+
stmt.bindText(2, "email")
492+
stmt.step() shouldBe false
493+
494+
stmt.reset()
495+
stmt.step() shouldBe false
483496
}
484497

485-
// When we exit readLock, the connection should no longer be usable
486-
shouldThrow<IllegalStateException> { capturedConnection!!.execSQL("DELETE FROM users") } shouldHaveMessage
487-
"Connection lease already closed"
498+
database.getAll("SELECT * FROM users") { it.getString("name") } shouldHaveSize 2
488499

489-
capturedConnection = null
490-
database.writeLock {
491-
it.rawConnection.execSQL("DELETE FROM users")
492-
capturedConnection = it.rawConnection
500+
// Verify that the statement indeed holds a lock on the database.
501+
val hadOtherWrite = CompletableDeferred<Unit>()
502+
scope.launch {
503+
database.execute(
504+
"INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)",
505+
listOf("another", "a@example.org"),
506+
)
507+
hadOtherWrite.complete(Unit)
493508
}
494509

495-
// Same thing for writes
496-
shouldThrow<IllegalStateException> { capturedConnection!!.prepare("SELECT * FROM users") } shouldHaveMessage
497-
"Connection lease already closed"
510+
delay(100.milliseconds)
511+
hadOtherWrite.isCompleted shouldBe false
512+
raw.close()
513+
hadOtherWrite.await()
498514
}
499515
}

core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.powersync.db
22

3+
import androidx.sqlite.SQLiteConnection
34
import co.touchlab.kermit.Logger
45
import com.powersync.DatabaseDriverFactory
6+
import com.powersync.ExperimentalPowerSyncAPI
57
import com.powersync.PowerSyncDatabase
68
import com.powersync.PowerSyncException
79
import com.powersync.bucket.BucketPriority
@@ -316,6 +318,12 @@ internal class PowerSyncDatabaseImpl(
316318
return powerSyncVersion
317319
}
318320

321+
@ExperimentalPowerSyncAPI
322+
override suspend fun leaseConnection(readOnly: Boolean): SQLiteConnection {
323+
waitReady()
324+
return internalDb.leaseConnection(readOnly)
325+
}
326+
319327
override suspend fun <RowType : Any> get(
320328
sql: String,
321329
parameters: List<Any?>?,

core/src/commonMain/kotlin/com/powersync/db/Queries.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package com.powersync.db
22

3+
import androidx.sqlite.SQLiteConnection
4+
import com.powersync.ExperimentalPowerSyncAPI
35
import com.powersync.PowerSyncException
46
import com.powersync.db.internal.ConnectionContext
57
import com.powersync.db.internal.PowerSyncTransaction
68
import kotlinx.coroutines.flow.Flow
79
import kotlin.coroutines.cancellation.CancellationException
10+
import kotlin.native.HiddenFromObjC
811
import kotlin.time.Duration
912
import kotlin.time.Duration.Companion.milliseconds
1013

@@ -183,4 +186,21 @@ public interface Queries {
183186
*/
184187
@Throws(PowerSyncException::class, CancellationException::class)
185188
public suspend fun <R> readTransaction(callback: ThrowableTransactionCallback<R>): R
189+
190+
/**
191+
* Obtains a connection from the read pool or an exclusive reference on the write connection.
192+
*
193+
* This is useful when you need full control over the raw statements to use.
194+
*
195+
* The connection needs to be released by calling [SQLiteConnection.close] as soon as you're
196+
* done with it, because the connection will occupy a read resource or the write lock while
197+
* active.
198+
*
199+
* Misusing this API, for instance by not cleaning up transactions started on the underlying
200+
* connection with a `BEGIN` statement or forgetting to close it, can disrupt the rest of the
201+
* PowerSync SDK. For this reason, this method should only be used if absolutely necessary.
202+
*/
203+
@ExperimentalPowerSyncAPI()
204+
@HiddenFromObjC()
205+
public suspend fun leaseConnection(readOnly: Boolean = false): SQLiteConnection
186206
}

core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,8 @@ import androidx.sqlite.SQLiteStatement
55
import com.powersync.PowerSyncException
66
import com.powersync.db.SqlCursor
77
import com.powersync.db.StatementBasedCursor
8-
import kotlin.native.HiddenFromObjC
98

109
public interface ConnectionContext {
11-
@HiddenFromObjC
12-
public val rawConnection: SQLiteConnection
13-
1410
@Throws(PowerSyncException::class)
1511
public fun execute(
1612
sql: String,
@@ -40,7 +36,7 @@ public interface ConnectionContext {
4036
}
4137

4238
internal class ConnectionContextImplementation(
43-
override val rawConnection: SQLiteConnection,
39+
private val rawConnection: SQLiteConnection,
4440
) : ConnectionContext {
4541
override fun execute(
4642
sql: String,

core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionPool.kt

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ internal class ConnectionPool(
3838
}
3939
}
4040

41-
suspend fun <R> withConnection(action: suspend (connection: SQLiteConnection) -> R): R {
41+
suspend fun obtainConnection(): RawConnectionLease {
4242
val (connection, done) =
4343
try {
4444
available.receive()
@@ -49,11 +49,7 @@ internal class ConnectionPool(
4949
)
5050
}
5151

52-
try {
53-
return action(connection)
54-
} finally {
55-
done.complete(Unit)
56-
}
52+
return RawConnectionLease(connection) { done.complete(Unit) }
5753
}
5854

5955
suspend fun <R> withAllConnections(action: suspend (connections: List<SQLiteConnection>) -> R): R {

core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import androidx.sqlite.SQLiteConnection
44
import androidx.sqlite.execSQL
55
import co.touchlab.kermit.Logger
66
import com.powersync.DatabaseDriverFactory
7+
import com.powersync.ExperimentalPowerSyncAPI
78
import com.powersync.PowerSyncException
89
import com.powersync.db.SqlCursor
910
import com.powersync.db.ThrowableLockCallback
@@ -22,7 +23,6 @@ import kotlinx.coroutines.flow.filter
2223
import kotlinx.coroutines.flow.onSubscription
2324
import kotlinx.coroutines.flow.transform
2425
import kotlinx.coroutines.sync.Mutex
25-
import kotlinx.coroutines.sync.withLock
2626
import kotlinx.coroutines.withContext
2727
import kotlin.time.Duration.Companion.milliseconds
2828

@@ -206,17 +206,30 @@ internal class InternalDatabaseImpl(
206206
}
207207
}
208208

209+
@ExperimentalPowerSyncAPI
210+
override suspend fun leaseConnection(readOnly: Boolean): SQLiteConnection =
211+
if (readOnly) {
212+
readPool.obtainConnection()
213+
} else {
214+
writeLockMutex.lock()
215+
RawConnectionLease(writeConnection, writeLockMutex::unlock)
216+
}
217+
209218
/**
210219
* Creates a read lock while providing an internal transactor for transactions
211220
*/
221+
@OptIn(ExperimentalPowerSyncAPI::class)
212222
private suspend fun <R> internalReadLock(callback: (SQLiteConnection) -> R): R =
213223
withContext(dbContext) {
214224
runWrapped {
215-
readPool.withConnection {
225+
val connection = leaseConnection(readOnly = true)
226+
try {
216227
catchSwiftExceptions {
217-
val lease = RawConnectionLease(it)
218-
callback(lease).also { lease.completed = true }
228+
callback(connection)
219229
}
230+
} finally {
231+
// Closing the lease will release the connection back into the pool.
232+
connection.close()
220233
}
221234
}
222235
}
@@ -235,11 +248,11 @@ internal class InternalDatabaseImpl(
235248
}
236249
}
237250

251+
@OptIn(ExperimentalPowerSyncAPI::class)
238252
private suspend fun <R> internalWriteLock(callback: (SQLiteConnection) -> R): R =
239253
withContext(dbContext) {
240-
writeLockMutex.withLock {
241-
val lease = RawConnectionLease(writeConnection)
242-
254+
val lease = leaseConnection(readOnly = false)
255+
try {
243256
runWrapped {
244257
catchSwiftExceptions {
245258
callback(lease)
@@ -248,8 +261,10 @@ internal class InternalDatabaseImpl(
248261
// Trigger watched queries
249262
// Fire updates inside the write lock
250263
updates.fireTableUpdates()
251-
lease.completed = true
252264
}
265+
} finally {
266+
// Returning the lease will unlock the writeLockMutex
267+
lease.close()
253268
}
254269
}
255270

core/src/commonMain/kotlin/com/powersync/db/internal/PowerSyncTransaction.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import com.powersync.db.SqlCursor
88
public interface PowerSyncTransaction : ConnectionContext
99

1010
internal class PowerSyncTransactionImpl(
11-
override val rawConnection: SQLiteConnection,
11+
private val rawConnection: SQLiteConnection,
1212
) : PowerSyncTransaction,
1313
ConnectionContext {
1414
private val delegate = ConnectionContextImplementation(rawConnection)

core/src/commonMain/kotlin/com/powersync/db/internal/RawConnectionLease.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ import androidx.sqlite.SQLiteStatement
88
*/
99
internal class RawConnectionLease(
1010
private val connection: SQLiteConnection,
11-
var completed: Boolean = false,
11+
private val returnConnection: () -> Unit,
1212
) : SQLiteConnection {
13+
private var isCompleted = false
14+
1315
private fun checkNotCompleted() {
14-
check(!completed) { "Connection lease already closed" }
16+
check(!isCompleted) { "Connection lease already closed" }
1517
}
1618

1719
override fun inTransaction(): Boolean {
@@ -26,5 +28,9 @@ internal class RawConnectionLease(
2628

2729
override fun close() {
2830
// Note: This is a lease, don't close the underlying connection.
31+
if (!isCompleted) {
32+
isCompleted = true
33+
returnConnection()
34+
}
2935
}
3036
}

drivers/common/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ kotlin {
4545
}
4646

4747
android {
48-
namespace = "com.powersync.compose"
48+
namespace = "com.powersync.drivers.common"
4949
compileSdk =
5050
libs.versions.android.compileSdk
5151
.get()

drivers/common/src/androidMain/kotlin/com/powersync/internal/driver/AndroidDriver.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import android.content.Context
44
import java.util.Properties
55
import java.util.concurrent.atomic.AtomicBoolean
66

7-
public class AndroidDriver(private val context: Context): JdbcDriver() {
7+
public class AndroidDriver(
8+
private val context: Context,
9+
) : JdbcDriver() {
810
override fun addDefaultProperties(properties: Properties) {
911
val isFirst = IS_FIRST_CONNECTION.getAndSet(false)
1012
if (isFirst) {

0 commit comments

Comments
 (0)