Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.minutes

class AndroidStorageTest {
Expand Down Expand Up @@ -525,6 +527,63 @@ class AndroidStorageTest {
}
}

@Test
fun testBusyTimeout() = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().context
val dbFile = context.getDatabasePath("test_busy_timeout.db")
dbFile.delete()
try {
val storage1 = AndroidStorage(
databasePath = dbFile.absolutePath,
clock = TestClock,
keySize = 3
)
val storage2 = AndroidStorage(
databasePath = dbFile.absolutePath,
clock = TestClock,
keySize = 3
)
val tableSpec = StorageTableSpec("test_table", supportPartitions = false, supportExpiration = false)
val table1 = storage1.getTable(tableSpec)
val table2 = storage2.getTable(tableSpec)

table1.insert("k1", data = "v1".encodeToByteString())

// Android SQLite automatically configures a non-zero busy_timeout (typically 2500ms).
storage1.withDatabase { db ->
db.rawQuery("PRAGMA busy_timeout", null).use { cursor ->
if (cursor.moveToFirst()) {
val timeout = cursor.getLong(0)
assertTrue(timeout >= 2000L, "Expected busy_timeout >= 2000ms, got $timeout")
}
}
}

// Connection 1 holds an exclusive transaction for 200ms.
val lockJob = launch(Dispatchers.IO) {
storage1.withDatabase { db ->
db.execSQL("BEGIN EXCLUSIVE TRANSACTION")
try {
Thread.sleep(200)
db.execSQL("COMMIT")
} catch (e: Throwable) {
db.execSQL("ROLLBACK")
}
}
}

delay(50)

// Connection 2 writes while Connection 1 holds the lock.
// With Android's busy timeout, this automatically waits and succeeds.
table2.insert("k2", data = "v2".encodeToByteString())
assertEquals("v2".encodeToByteString(), table2.get("k2"))
lockJob.join()
} finally {
dbFile.delete()
}
}

private fun withStorage(block: suspend CoroutineScope.(storage: Storage) -> Unit) {
for (storage in transientStorageList) {
runBlocking {
Expand Down
16 changes: 16 additions & 0 deletions multipaz/src/commonMain/kotlin/org/multipaz/storage/Storage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@ package org.multipaz.storage
/**
* Storage (in most cases persistent) that holds data items. Collection of items are organized
* in named [StorageTable]s.
*
* ## Concurrency and Multiple Instances
*
* It is safe to use multiple [Storage] instances that reference the same underlying database
* concurrently, whether within the same process or across different processes (for example, a mobile
* application and an app extension sharing an App Group container).
*
* Implementations provide concurrency guarantees through the underlying database engine:
* - **Atomicity:** Individual operations on [StorageTable] (such as `insert`, `update`, `delete`,
* and `get`) are atomic.
* - **Contention Handling:** When multiple connections or processes attempt to access or modify
* the database simultaneously, implementations configure appropriate lock wait mechanisms
* (e.g., SQLite's `busy_timeout` on mobile platforms, transaction queues in IndexedDB, or MVCC
* in relational databases). Operations will automatically wait for concurrent locks to be released
* rather than immediately failing with contention errors. If contention persists longer than
* the configured timeout, the operation will fail with an exception.
*/
interface Storage {
/**
Expand Down
44 changes: 41 additions & 3 deletions multipaz/src/iosMain/kotlin/org/multipaz/storage/ios/IosStorage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,60 @@ import org.multipaz.storage.StorageTableSpec
import org.multipaz.storage.sqlite.SqliteStorage
import platform.Foundation.NSURL
import platform.Foundation.NSURLIsExcludedFromBackupKey
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds

/**
* Implementation of [Storage] for iOS platform.
*
* @param storageFileUrl a URL with the path to the database file.
* @param excludeFromBackup if true, the database file will be excluded from backup.
* @param busyTimeout timeout to wait for SQLite database locks before failing. Defaults to 5 seconds.
* @param clock clock to use for timestamps and expiration. Defaults to [Clock.System].
*/
@OptIn(ExperimentalForeignApi::class, DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class)
class IosStorage(
private val storageFileUrl: NSURL,
private val excludeFromBackup: Boolean = true
private val excludeFromBackup: Boolean = true,
busyTimeout: Duration = 5.seconds,
clock: Clock = Clock.System
): SqliteStorage(
connection = getConnection(storageFileUrl, excludeFromBackup),
clock = clock,
// Native sqlite crashes when used with Dispatchers.IO.
coroutineContext = newSingleThreadContext("DB")
coroutineContext = newSingleThreadContext("DB"),
busyTimeout = busyTimeout
) {
/**
* Constructor for backwards compatibility with Swift and Objective-C callers
* who do not specify [busyTimeout] or [clock].
*/
constructor(
storageFileUrl: NSURL,
excludeFromBackup: Boolean
) : this(
storageFileUrl = storageFileUrl,
excludeFromBackup = excludeFromBackup,
busyTimeout = 5.seconds,
clock = Clock.System
)

/**
* Constructor allowing Swift and Objective-C callers to specify a busy timeout in milliseconds.
*/
constructor(
storageFileUrl: NSURL,
excludeFromBackup: Boolean,
busyTimeoutMs: Long
) : this(
storageFileUrl = storageFileUrl,
excludeFromBackup = excludeFromBackup,
busyTimeout = busyTimeoutMs.milliseconds,
clock = Clock.System
)

companion object {
private fun getConnection(
storageFileUrl: NSURL,
Expand All @@ -43,4 +81,4 @@ class IosStorage(
return NativeSQLiteDriver().open(storageFileUrl.path!!)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.multipaz.storage.sqlite

import androidx.sqlite.SQLiteConnection
import androidx.sqlite.execSQL
import org.multipaz.storage.Storage
import org.multipaz.storage.base.BaseStorage
import org.multipaz.storage.base.BaseStorageTable
Expand All @@ -9,6 +10,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.coroutines.CoroutineContext

/**
Expand All @@ -31,8 +34,27 @@ open class SqliteStorage(
private val connection: SQLiteConnection,
clock: Clock = Clock.System,
private val coroutineContext: CoroutineContext = Dispatchers.IO,
internal val keySize: Int = 9
internal val keySize: Int = 9,
val busyTimeout: Duration = 5.seconds
): BaseStorage(clock) {
init {
val busyTimeoutMs = busyTimeout.inWholeMilliseconds
require(busyTimeoutMs >= 0) { "busyTimeout must not be negative" }
connection.execSQL("PRAGMA busy_timeout = $busyTimeoutMs")
}

constructor(
connection: SQLiteConnection,
clock: Clock = Clock.System,
coroutineContext: CoroutineContext = Dispatchers.IO,
keySize: Int = 9
): this(
connection = connection,
clock = clock,
coroutineContext = coroutineContext,
keySize = keySize,
busyTimeout = 5.seconds
)
override suspend fun createTable(tableSpec: StorageTableSpec): BaseStorageTable {
val table = SqliteStorageTable(this, tableSpec)
table.init()
Expand Down
Loading
Loading