Skip to content

Commit 6121ea1

Browse files
committed
Make watch and onChange more reliable
1 parent 22acdd7 commit 6121ea1

7 files changed

Lines changed: 129 additions & 89 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* Remove internal SQLDelight and SQLiter dependencies.
66
* Add `rawConnection` getter to `ConnectionContext`, which is a `SQLiteConnection` instance from
77
`androidx.sqlite` that can be used to step through statements in a custom way.
8+
* Fix an issue where `watch()` would run queries more often than intended.
89

910
## 1.5.1
1011

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -211,11 +211,10 @@ class DatabaseTest {
211211

212212
var changeSet = query.awaitItem()
213213
// The initial result
214-
changeSet.count() shouldBe 0
214+
changeSet shouldHaveSize 0
215215

216216
changeSet = query.awaitItem()
217-
changeSet.count() shouldBe 1
218-
changeSet.contains("users") shouldBe true
217+
changeSet shouldBe setOf("users")
219218

220219
query.cancel()
221220
}

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

Lines changed: 47 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package com.powersync.db.internal
22

33
import com.powersync.ExperimentalPowerSyncAPI
4-
import com.powersync.PowerSyncException
54
import com.powersync.db.SqlCursor
65
import com.powersync.db.ThrowableLockCallback
76
import com.powersync.db.ThrowableTransactionCallback
@@ -15,8 +14,9 @@ import kotlinx.coroutines.Dispatchers
1514
import kotlinx.coroutines.IO
1615
import kotlinx.coroutines.flow.Flow
1716
import kotlinx.coroutines.flow.SharedFlow
18-
import kotlinx.coroutines.flow.channelFlow
19-
import kotlinx.coroutines.flow.filter
17+
import kotlinx.coroutines.flow.emitAll
18+
import kotlinx.coroutines.flow.flow
19+
import kotlinx.coroutines.flow.map
2020
import kotlinx.coroutines.flow.onSubscription
2121
import kotlinx.coroutines.flow.transform
2222
import kotlinx.coroutines.withContext
@@ -79,75 +79,69 @@ internal class InternalDatabaseImpl(
7979
tables: Set<String>,
8080
throttleMs: Long,
8181
triggerImmediately: Boolean,
82-
): Flow<Set<String>> =
83-
channelFlow {
84-
// Match all possible internal table combinations
85-
val watchedTables =
86-
tables.flatMap { listOf(it, "ps_data__$it", "ps_data_local__$it") }.toSet()
87-
88-
// Accumulate updates between throttles
89-
val batchedUpdates = AtomicMutableSet<String>()
82+
): Flow<Set<String>> {
83+
// Match all possible internal table combinations
84+
val watchedTables =
85+
tables.flatMap { listOf(it, "ps_data__$it", "ps_data_local__$it") }.toSet()
9086

91-
updatesOnTables()
92-
.onSubscription {
93-
if (triggerImmediately) {
94-
// Emit an initial event (if requested). No changes would be detected at this point
95-
send(setOf())
96-
}
97-
}.transform { updates ->
98-
val intersection = updates.intersect(watchedTables)
99-
if (intersection.isNotEmpty()) {
100-
// Transform table names using friendlyTableName
101-
val friendlyTableNames = intersection.map { friendlyTableName(it) }.toSet()
102-
batchedUpdates.addAll(friendlyTableNames)
103-
emit(Unit)
104-
}
105-
}
106-
// Throttling here is a feature which prevents watch queries from spamming updates.
107-
// Throttling by design discards and delays events within the throttle window. Discarded events
108-
// still trigger a trailing edge update.
109-
// Backpressure is avoided on the throttling and consumer level by buffering the last upstream value.
110-
.throttle(throttleMs.milliseconds)
111-
.collect {
112-
// Emit the transformed tables which have changed
113-
val copy = batchedUpdates.toSetAndClear()
114-
send(copy)
115-
}
87+
return rawChangedTables(watchedTables, throttleMs, triggerImmediately).map {
88+
it.mapTo(mutableSetOf(), ::friendlyTableName)
11689
}
90+
}
11791

11892
override fun <RowType : Any> watch(
11993
sql: String,
12094
parameters: List<Any?>?,
12195
throttleMs: Long,
12296
mapper: (SqlCursor) -> RowType,
12397
): Flow<List<RowType>> =
124-
// Use a channel flow here since we throttle (buffer used under the hood)
125-
// This causes some emissions to be from different scopes.
126-
channelFlow {
98+
flow {
12799
// Fetch the tables asynchronously with getAll
128100
val tables =
129101
getSourceTables(sql, parameters)
130102
.filter { it.isNotBlank() }
131103
.toSet()
132104

105+
val queries =
106+
rawChangedTables(tables, throttleMs, triggerImmediately = true).map {
107+
getAll(sql, parameters = parameters, mapper = mapper)
108+
}
109+
emitAll(queries)
110+
}
111+
112+
private fun rawChangedTables(
113+
tableNames: Set<String>,
114+
throttleMs: Long,
115+
triggerImmediately: Boolean,
116+
): Flow<Set<String>> =
117+
flow {
118+
val batchedUpdates = AtomicMutableSet<String>()
119+
133120
updatesOnTables()
134-
// onSubscription here is very important.
135-
// This ensures that the initial result and all updates are emitted.
136121
.onSubscription {
137-
send(getAll(sql, parameters = parameters, mapper = mapper))
138-
}.filter {
139-
// Only trigger updates on relevant tables
140-
it.intersect(tables).isNotEmpty()
122+
if (triggerImmediately) {
123+
// Emit an initial event (if requested). No changes would be detected at this point
124+
emit(initialUpdateSentinel)
125+
}
126+
}.transform { updates ->
127+
if (updates === initialUpdateSentinel) {
128+
// This should always be emitted despite being empty and not intersecting with
129+
// the tables we care about.
130+
emit(Unit)
131+
} else {
132+
val intersection = updates.intersect(tableNames)
133+
if (intersection.isNotEmpty()) {
134+
batchedUpdates.addAll(intersection)
135+
emit(Unit)
136+
}
137+
}
141138
}
142139
// Throttling here is a feature which prevents watch queries from spamming updates.
143140
// Throttling by design discards and delays events within the throttle window. Discarded events
144141
// still trigger a trailing edge update.
145142
// Backpressure is avoided on the throttling and consumer level by buffering the last upstream value.
146-
// Note that the buffered upstream "value" only serves to trigger the getAll query. We don't buffer watch results.
147143
.throttle(throttleMs.milliseconds)
148-
.collect {
149-
send(getAll(sql, parameters = parameters, mapper = mapper))
150-
}
144+
.collect { emit(batchedUpdates.toSetAndClear()) }
151145
}
152146

153147
override suspend fun <T> useConnection(
@@ -259,6 +253,10 @@ internal class InternalDatabaseImpl(
259253
val p2: Long,
260254
val p3: Long,
261255
)
256+
257+
private companion object {
258+
val initialUpdateSentinel = emptySet<String>()
259+
}
262260
}
263261

264262
/**

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ public class AtomicMutableSet<T> : SynchronizedObject() {
1111
return set.add(element)
1212
}
1313

14-
public fun addAll(elements: Set<T>): Boolean =
14+
public fun addAll(elements: Collection<T>): Boolean =
1515
synchronized(this) {
1616
return set.addAll(elements)
1717
}

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

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,34 @@ import kotlinx.coroutines.flow.Flow
66
import kotlinx.coroutines.flow.buffer
77
import kotlinx.coroutines.flow.flow
88
import kotlin.time.Duration
9+
import kotlin.time.TimeSource
910

1011
/**
11-
* Throttles a flow with emissions on the leading and trailing edge.
12-
* Events, from the incoming flow, during the throttle window are discarded.
13-
* Events are discarded by using a conflated buffer.
14-
* This throttle method acts as a slow consumer, but backpressure is not a concern
15-
* due to the conflated buffer dropping events during the throttle window.
12+
* Throttles an upstream flow.
13+
*
14+
* When a new event is emitted on this (upstream) flow, it is passed on downstream. For each value
15+
* passed downstream, the resulting flow will pause for at least [window] (or longer if emitting
16+
* the value downstream takes longer).
17+
*
18+
* While this flow is paused, no further events are passed downstream. The latest upstream event
19+
* emitted during the pause state is buffered and handled once the pause is over.
20+
*
21+
* In other words, this flow will _drop events_, so it should only be used when the upstream flow
22+
* serves as a notification marker (meaning that something downstream needs to run in response to
23+
* events, but the actual event does not matter).
1624
*/
1725
internal fun <T> Flow<T>.throttle(window: Duration): Flow<T> =
1826
flow {
1927
// Use a buffer before throttle (ensure only the latest event is kept)
2028
val bufferedFlow = this@throttle.buffer(Channel.CONFLATED)
2129

2230
bufferedFlow.collect { value ->
23-
// Emit the event immediately (leading edge)
31+
// Pause for the downstream emit or the delay window, whatever is longer
32+
val pauseUntil = TimeSource.Monotonic.markNow() + window
2433
emit(value)
2534

26-
// Delay for the throttle window to avoid emitting too frequently
27-
delay(window)
35+
// Negating the duration because we want to pause until pauseUntil has passed.
36+
delay(-pauseUntil.elapsedNow())
2837

2938
// The next incoming event will be provided from the buffer.
3039
// The next collect will emit the trailing edge

core/src/commonTest/kotlin/com/powersync/utils/JsonTest.kt

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

3-
import kotlinx.coroutines.delay
4-
import kotlinx.coroutines.flow.flow
5-
import kotlinx.coroutines.flow.map
6-
import kotlinx.coroutines.flow.toList
7-
import kotlinx.coroutines.test.runTest
83
import kotlinx.serialization.json.JsonArray
94
import kotlinx.serialization.json.JsonNull
105
import kotlinx.serialization.json.JsonObject
@@ -23,7 +18,6 @@ import kotlin.test.Test
2318
import kotlin.test.assertEquals
2419
import kotlin.test.assertNotNull
2520
import kotlin.test.assertTrue
26-
import kotlin.time.Duration.Companion.milliseconds
2721

2822
class JsonTest {
2923
@Test
@@ -42,28 +36,6 @@ class JsonTest {
4236
assertEquals("test", jsonElement.content)
4337
}
4438

45-
@Test
46-
fun testThrottle() {
47-
runTest {
48-
val t =
49-
flow {
50-
emit(1)
51-
delay(10)
52-
emit(2)
53-
delay(20)
54-
emit(3)
55-
delay(100)
56-
emit(4)
57-
}.throttle(100.milliseconds)
58-
.map {
59-
// Adding a delay here to simulate a slow consumer
60-
delay(1000)
61-
it
62-
}.toList()
63-
assertEquals(t, listOf(1, 4))
64-
}
65-
}
66-
6739
@Test
6840
fun testBooleanToJsonElement() {
6941
val boolean = JsonParam.Boolean(true)
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package com.powersync.utils
2+
3+
import io.kotest.matchers.shouldBe
4+
import kotlinx.coroutines.delay
5+
import kotlinx.coroutines.flow.flow
6+
import kotlinx.coroutines.flow.map
7+
import kotlinx.coroutines.flow.toList
8+
import kotlinx.coroutines.test.runTest
9+
import kotlin.test.Test
10+
import kotlin.test.assertEquals
11+
import kotlin.time.Duration.Companion.milliseconds
12+
import kotlin.time.Duration.Companion.seconds
13+
14+
class ThrottleTest {
15+
@Test
16+
fun testThrottle() {
17+
runTest {
18+
val t =
19+
flow {
20+
emit(1)
21+
delay(10)
22+
emit(2)
23+
delay(20)
24+
emit(3)
25+
delay(100)
26+
emit(4)
27+
}.throttle(100.milliseconds)
28+
.map {
29+
// Adding a delay here to simulate a slow consumer
30+
delay(1000)
31+
it
32+
}.toList()
33+
assertEquals(t, listOf(1, 4))
34+
}
35+
}
36+
37+
@Test
38+
fun testWaitTimeIsNotAdditive() =
39+
runTest {
40+
val upstream =
41+
flow {
42+
repeat(5) {
43+
emit(it)
44+
delay(10.seconds)
45+
}
46+
}
47+
48+
// If throttle were to start the delay after the downstream emit completed, it would 11
49+
// seconds, skipping events. That's not what we want though, the downstream delay is
50+
// long enough to not need additional throttling.
51+
val events =
52+
upstream
53+
.throttle(5.seconds)
54+
.map {
55+
delay(6.seconds)
56+
it
57+
}.toList()
58+
59+
events shouldBe listOf(0, 1, 2, 3, 4)
60+
}
61+
}

0 commit comments

Comments
 (0)