Skip to content

Commit f4d98d5

Browse files
committed
Lease API that works better with Room
1 parent 2359b48 commit f4d98d5

File tree

13 files changed

+196
-130
lines changed

13 files changed

+196
-130
lines changed

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

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import co.touchlab.kermit.ExperimentalKermitApi
88
import com.powersync.db.ActiveDatabaseGroup
99
import com.powersync.db.crud.CrudEntry
1010
import com.powersync.db.crud.CrudTransaction
11+
import com.powersync.db.getString
1112
import com.powersync.db.schema.Schema
1213
import com.powersync.testutils.UserRow
1314
import com.powersync.testutils.databaseTest
@@ -18,17 +19,18 @@ import io.kotest.assertions.throwables.shouldThrow
1819
import io.kotest.matchers.collections.shouldHaveSize
1920
import io.kotest.matchers.shouldBe
2021
import io.kotest.matchers.string.shouldContain
21-
import io.kotest.matchers.throwable.shouldHaveMessage
2222
import kotlinx.coroutines.CompletableDeferred
2323
import kotlinx.coroutines.Dispatchers
2424
import kotlinx.coroutines.async
2525
import kotlinx.coroutines.delay
2626
import kotlinx.coroutines.flow.takeWhile
27+
import kotlinx.coroutines.launch
2728
import kotlinx.coroutines.runBlocking
2829
import kotlinx.coroutines.withContext
2930
import kotlin.test.Test
3031
import kotlin.test.assertEquals
3132
import kotlin.test.assertNotNull
33+
import kotlin.time.Duration.Companion.milliseconds
3234

3335
@OptIn(ExperimentalKermitApi::class)
3436
class DatabaseTest {
@@ -500,36 +502,52 @@ class DatabaseTest {
500502
}
501503

502504
@Test
503-
fun testRawConnection() =
505+
@OptIn(ExperimentalPowerSyncAPI::class)
506+
fun testLeaseReadOnly() =
504507
databaseTest {
505508
database.execute(
506509
"INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)",
507510
listOf("a", "[email protected]"),
508511
)
509-
var capturedConnection: SQLiteConnection? = null
510512

511-
database.readLock {
512-
it.rawConnection.prepare("SELECT * FROM users").use { stmt ->
513-
stmt.step() shouldBe true
514-
stmt.getText(1) shouldBe "a"
515-
stmt.getText(2) shouldBe "[email protected]"
516-
}
513+
val raw = database.leaseConnection(readOnly = true)
514+
raw.prepare("SELECT * FROM users").use { stmt ->
515+
stmt.step() shouldBe true
516+
stmt.getText(1) shouldBe "a"
517+
stmt.getText(2) shouldBe "[email protected]"
518+
}
519+
raw.close()
520+
}
517521

518-
capturedConnection = it.rawConnection
522+
@Test
523+
@OptIn(ExperimentalPowerSyncAPI::class)
524+
fun testLeaseWrite() =
525+
databaseTest {
526+
val raw = database.leaseConnection(readOnly = false)
527+
raw.prepare("INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)").use { stmt ->
528+
stmt.bindText(1, "name")
529+
stmt.bindText(2, "email")
530+
stmt.step() shouldBe false
531+
532+
stmt.reset()
533+
stmt.step() shouldBe false
519534
}
520535

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

525-
capturedConnection = null
526-
database.writeLock {
527-
it.rawConnection.execSQL("DELETE FROM users")
528-
capturedConnection = it.rawConnection
538+
// Verify that the statement indeed holds a lock on the database.
539+
val hadOtherWrite = CompletableDeferred<Unit>()
540+
scope.launch {
541+
database.execute(
542+
"INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)",
543+
listOf("another", "[email protected]"),
544+
)
545+
hadOtherWrite.complete(Unit)
529546
}
530547

531-
// Same thing for writes
532-
shouldThrow<IllegalStateException> { capturedConnection!!.prepare("SELECT * FROM users") } shouldHaveMessage
533-
"Connection lease already closed"
548+
delay(100.milliseconds)
549+
hadOtherWrite.isCompleted shouldBe false
550+
raw.close()
551+
hadOtherWrite.await()
534552
}
535553
}

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
@@ -329,6 +331,12 @@ internal class PowerSyncDatabaseImpl(
329331
return powerSyncVersion
330332
}
331333

334+
@ExperimentalPowerSyncAPI
335+
override suspend fun leaseConnection(readOnly: Boolean): SQLiteConnection {
336+
waitReady()
337+
return internalDb.leaseConnection(readOnly)
338+
}
339+
332340
override suspend fun <RowType : Any> get(
333341
sql: String,
334342
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)