Skip to content

Commit 6bcbaf3

Browse files
committed
Avoid resource leaks for database tracker
1 parent 74e904e commit 6bcbaf3

File tree

6 files changed

+127
-81
lines changed

6 files changed

+127
-81
lines changed

core/build.gradle.kts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,11 @@ kotlin {
211211
dependsOn(commonTest.get())
212212
}
213213

214+
val commonJava by creating {
215+
kotlin.srcDir("commonJava")
216+
dependsOn(commonMain.get())
217+
}
218+
214219
commonMain.dependencies {
215220
implementation(libs.uuid)
216221
implementation(libs.kotlin.stdlib)
@@ -226,13 +231,18 @@ kotlin {
226231
api(libs.kermit)
227232
}
228233

229-
androidMain.dependencies {
230-
implementation(libs.ktor.client.okhttp)
234+
androidMain {
235+
dependsOn(commonJava)
236+
dependencies.implementation(libs.ktor.client.okhttp)
231237
}
232238

233-
jvmMain.dependencies {
234-
implementation(libs.ktor.client.okhttp)
235-
implementation(libs.sqlite.jdbc)
239+
jvmMain {
240+
dependsOn(commonJava)
241+
242+
dependencies {
243+
implementation(libs.ktor.client.okhttp)
244+
implementation(libs.sqlite.jdbc)
245+
}
236246
}
237247

238248
iosMain.dependencies {
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.powersync.db
2+
3+
import java.lang.ref.Cleaner
4+
5+
internal actual fun disposeWhenDeallocated(resource: ActiveDatabaseResource): Any {
6+
// Note: It's important that the returned object does not reference the resource directly
7+
val wrapper = CleanableWrapper()
8+
CleanableWrapper.cleaner.register(wrapper, resource::dispose)
9+
return wrapper
10+
}
11+
12+
private class CleanableWrapper {
13+
var cleanable: Cleaner.Cleanable? = null
14+
15+
companion object {
16+
val cleaner: Cleaner = Cleaner.create()
17+
}
18+
}
Lines changed: 75 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,81 @@
11
package com.powersync.db
22

3-
import com.powersync.PowerSyncDatabase
4-
import com.powersync.utils.ExclusiveMethodProvider
5-
6-
internal class ActiveInstanceStore : ExclusiveMethodProvider() {
7-
private val instances = mutableListOf<PowerSyncDatabase>()
8-
9-
/**
10-
* Registers an instance. Returns true if multiple instances with the same identifier are
11-
* present.
12-
*/
13-
suspend fun registerAndCheckInstance(db: PowerSyncDatabase) =
14-
exclusiveMethod("instances") {
15-
instances.add(db)
16-
return@exclusiveMethod instances.filter { it.identifier == db.identifier }.size > 1
3+
import co.touchlab.kermit.Logger
4+
import kotlinx.atomicfu.atomic
5+
import kotlinx.atomicfu.locks.SynchronizedObject
6+
import kotlinx.atomicfu.locks.synchronized
7+
import kotlinx.coroutines.sync.Mutex
8+
9+
/**
10+
* Returns an object that, when deallocated, calls [ActiveDatabaseResource.dispose].
11+
*/
12+
internal expect fun disposeWhenDeallocated(resource: ActiveDatabaseResource): Any
13+
14+
/**
15+
* An collection of PowerSync databases with the same path / identifier.
16+
*
17+
* We expect that each group will only ever have one database because we encourage users to write their databases as
18+
* singletons. We print a warning when two databases are part of the same group.
19+
* Additionally, we want to avoid two databases in the same group having a sync stream open at the same time to avoid
20+
* duplicate resources being used. For this reason, each active database group has a coroutine mutex guarding the
21+
* sync job.
22+
*/
23+
internal class ActiveDatabaseGroup(val identifier: String) {
24+
internal var refCount = 0 // Guarded by companion object
25+
internal val syncMutex = Mutex()
26+
27+
fun removeUsage() {
28+
synchronized(ActiveDatabaseGroup) {
29+
if (--refCount == 0) {
30+
allGroups.remove(this)
31+
}
32+
}
33+
}
34+
35+
companion object: SynchronizedObject() {
36+
internal val multipleInstancesMessage =
37+
"""
38+
Multiple PowerSync instances for the same database have been detected.
39+
This can cause unexpected results.
40+
Please check your PowerSync client instantiation logic if this is not intentional.
41+
""".trimIndent()
42+
43+
private val allGroups = mutableListOf<ActiveDatabaseGroup>()
44+
45+
private fun findGroup(warnOnDuplicate: Logger, identifier: String): ActiveDatabaseGroup {
46+
return synchronized(this) {
47+
val existing = allGroups.asSequence().firstOrNull { it.identifier == identifier }
48+
val resolvedGroup = if (existing == null) {
49+
val added = ActiveDatabaseGroup(identifier)
50+
allGroups.add(added)
51+
added
52+
} else {
53+
existing
54+
}
55+
56+
if (resolvedGroup.refCount++ != 0) {
57+
warnOnDuplicate.w { multipleInstancesMessage }
58+
}
59+
60+
resolvedGroup
61+
}
1762
}
1863

19-
suspend fun removeInstance(db: PowerSyncDatabase) =
20-
exclusiveMethod("instances") {
21-
instances.remove(db)
64+
internal fun referenceDatabase(warnOnDuplicate: Logger, identifier: String): Pair<ActiveDatabaseResource, Any> {
65+
val group = findGroup(warnOnDuplicate, identifier)
66+
val resource = ActiveDatabaseResource(group)
67+
68+
return resource to disposeWhenDeallocated(resource)
69+
}
70+
}
71+
}
72+
73+
internal class ActiveDatabaseResource(val group: ActiveDatabaseGroup) {
74+
var disposed = atomic(false)
75+
76+
fun dispose() {
77+
if (!disposed.getAndSet(true)) {
78+
group.removeUsage()
2279
}
80+
}
2381
}

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

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import com.powersync.sync.PriorityStatusEntry
2020
import com.powersync.sync.SyncStatus
2121
import com.powersync.sync.SyncStatusData
2222
import com.powersync.sync.SyncStream
23-
import com.powersync.utils.ExclusiveMethodProvider
2423
import com.powersync.utils.JsonParam
2524
import com.powersync.utils.JsonUtil
2625
import com.powersync.utils.throttle
@@ -57,8 +56,7 @@ internal class PowerSyncDatabaseImpl(
5756
private val dbFilename: String,
5857
val logger: Logger = Logger,
5958
driver: PsSqlDriver = factory.createDriver(scope, dbFilename),
60-
) : ExclusiveMethodProvider(),
61-
PowerSyncDatabase {
59+
): PowerSyncDatabase {
6260
companion object {
6361
internal val streamConflictMessage =
6462
"""
@@ -68,21 +66,15 @@ internal class PowerSyncDatabaseImpl(
6866
This connection attempt will be queued and will only be executed after
6967
currently connecting clients are disconnected.
7068
""".trimIndent()
71-
72-
internal val multipleInstancesMessage =
73-
"""
74-
Multiple PowerSync instances for the same database have been detected.
75-
This can cause unexpected results.
76-
Please check your PowerSync client instantiation logic if this is not intentional.
77-
""".trimIndent()
78-
79-
internal val instanceStore = ActiveInstanceStore()
8069
}
8170

8271
override val identifier = dbFilename
8372

8473
private val internalDb = InternalDatabaseImpl(driver, scope)
8574
internal val bucketStorage: BucketStorage = BucketStorageImpl(internalDb, logger)
75+
private val resource: ActiveDatabaseResource
76+
private val clearResourceWhenDisposed: Any
77+
8678
var closed = false
8779

8880
/**
@@ -97,12 +89,11 @@ internal class PowerSyncDatabaseImpl(
9789
private var uploadJob: Job? = null
9890

9991
init {
100-
val db = this
92+
val res = ActiveDatabaseGroup.referenceDatabase(logger, identifier)
93+
resource = res.first
94+
clearResourceWhenDisposed = res.second
95+
10196
runBlocking {
102-
val isMultiple = instanceStore.registerAndCheckInstance(db)
103-
if (isMultiple) {
104-
logger.w { multipleInstancesMessage }
105-
}
10697
val sqliteVersion = internalDb.queries.sqliteVersion().executeAsOne()
10798
logger.d { "SQLiteVersion: $sqliteVersion" }
10899
checkVersion()
@@ -154,8 +145,7 @@ internal class PowerSyncDatabaseImpl(
154145
syncJob =
155146
scope.launch {
156147
// Get a global lock for checking mutex maps
157-
val streamMutex =
158-
globalMutexFor("streaming-$identifier")
148+
val streamMutex = resource.group.syncMutex
159149

160150
// Poke the streaming mutex to see if another client is using it
161151
var obtainedLock = false
@@ -437,7 +427,7 @@ internal class PowerSyncDatabaseImpl(
437427
}
438428
disconnect()
439429
internalDb.close()
440-
instanceStore.removeInstance(this)
430+
resource.dispose()
441431
closed = true
442432
}
443433

core/src/commonMain/kotlin/com/powersync/utils/ExclusiveMethodProvider.kt

Lines changed: 0 additions & 39 deletions
This file was deleted.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.powersync.db
2+
3+
import kotlin.experimental.ExperimentalNativeApi
4+
import kotlin.native.ref.createCleaner
5+
6+
@OptIn(ExperimentalNativeApi::class)
7+
internal actual fun disposeWhenDeallocated(resource: ActiveDatabaseResource): Any {
8+
return createCleaner(resource) { it.dispose() }
9+
}

0 commit comments

Comments
 (0)