Skip to content

Commit 175c474

Browse files
AlexanderGHclaude
andcommitted
Compute watcher dependent keys lazily instead of before subscribing
WatcherInterceptor derived the watched dependent keys eagerly, before subscribing to CacheManager.changedKeys. That work re-serializes the whole response through MapJsonWriter (withErrors) and normalizes it again (normalize + dependentKeys), duplicating what ApolloCacheInterceptor already does when it writes the response to the cache. It also lands on a latency-sensitive path. watch() withholds the last of its initial responses until this interceptor has subscribed, so everything done before subscribing delays that response reaching the caller. For large operations the extra normalization is substantial: on a big query in a production Android app it accounted for most of a ~100ms gap between the network response being ready and the collector observing it, compared to the same query run through query(). The keys are only ever read to filter an incoming cache change, so compute them on the first such change instead. The subscription marker and ALL_KEYS events are answered without needing them at all, and a watcher that is cancelled before any change never pays the cost. Refetch responses now only record their data and mark the keys stale, keeping the same work off their delivery path too. Filtering is unchanged: the same keys are derived from the same data, just later. Three related cleanups while here: - Add Set.anyIntersection(), which short-circuits on the first shared element and looks up against the larger set, replacing an intersect() call that materialized a whole set only to test it for emptiness. - Build the dependent keys in a single pass. Going through Record.fieldKeys() allocated a list and a set per record, plus a list holding every key, on top of the set actually returned. - Replace filter + map + flattenConcatPolyfill with a single transform, which has the same sequential semantics without a flow per event, and drops the re-test of a condition the filter had already decided. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 34276eb commit 175c474

4 files changed

Lines changed: 152 additions & 45 deletions

File tree

normalized-cache/src/commonMain/kotlin/com/apollographql/cache/normalized/api/Record.kt

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,22 @@ fun Record.expirationDate(field: String) = metadata[field]?.get(ApolloCacheHeade
142142
*/
143143
typealias RecordValue = Any?
144144

145+
/**
146+
* Returns the set of all field keys of all the given records.
147+
* A field key incorporates any GraphQL arguments in addition to the field name.
148+
*/
145149
fun Collection<Record>?.dependentKeys(): Set<String> {
146-
return this?.flatMap {
147-
it.fieldKeys()
148-
}?.toSet() ?: emptySet()
150+
if (this == null) {
151+
return emptySet()
152+
}
153+
// Built in one pass: going through `fieldKeys()` would allocate a list and a set per record, plus
154+
// a list holding every key, on top of the set actually returned. For a large operation that is
155+
// thousands of throwaway collections.
156+
return buildSet {
157+
for (record in this@dependentKeys) {
158+
for (fieldName in record.fields.keys) {
159+
add(record.key.fieldKey(fieldName))
160+
}
161+
}
162+
}
149163
}

normalized-cache/src/commonMain/kotlin/com/apollographql/cache/normalized/internal/WatcherInterceptor.kt

Lines changed: 62 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.apollographql.cache.normalized.internal
33
import com.apollographql.apollo.api.ApolloRequest
44
import com.apollographql.apollo.api.ApolloResponse
55
import com.apollographql.apollo.api.CustomScalarAdapters
6+
import com.apollographql.apollo.api.Error
67
import com.apollographql.apollo.api.Operation
78
import com.apollographql.apollo.api.Query
89
import com.apollographql.apollo.exception.DefaultApolloException
@@ -16,12 +17,9 @@ import com.apollographql.cache.normalized.watchContext
1617
import kotlinx.coroutines.flow.Flow
1718
import kotlinx.coroutines.flow.SharedFlow
1819
import kotlinx.coroutines.flow.emitAll
19-
import kotlinx.coroutines.flow.filter
20-
import kotlinx.coroutines.flow.flow
21-
import kotlinx.coroutines.flow.flowOf
22-
import kotlinx.coroutines.flow.map
2320
import kotlinx.coroutines.flow.onEach
2421
import kotlinx.coroutines.flow.onSubscription
22+
import kotlinx.coroutines.flow.transform
2523

2624
internal val WatcherSentinel = DefaultApolloException("The watcher has started")
2725

@@ -35,12 +33,37 @@ internal class WatcherInterceptor(val cacheManager: CacheManager) : ApolloInterc
3533

3634
val customScalarAdapters = request.executionContext[CustomScalarAdapters]!!
3735

36+
/**
37+
* The data whose dependent keys are watched, and the keys themselves.
38+
*
39+
* Computing the keys normalizes the whole data again, which is significant work for large
40+
* operations. The keys are only ever read to filter an incoming cache change, so they are
41+
* computed on the first such change rather than up front.
42+
*
43+
* This matters because of when the work would otherwise happen:
44+
* [com.apollographql.cache.normalized.watch] withholds the last of its initial responses until
45+
* this interceptor has subscribed to [CacheManager.changedKeys], so anything done before
46+
* subscribing delays that response reaching the caller. The same applies to the responses of a
47+
* refetch below, which is why those only record the data and leave the keys stale.
48+
*/
49+
var dataToWatch: Operation.Data? = watchContext.data
50+
var errorsToWatch: List<Error>? = null
51+
var watchedKeys: Set<String>? = null
52+
var watchedKeysAreStale = true
53+
3854
@Suppress("UNCHECKED_CAST")
39-
var watchedKeys: Set<String>? =
40-
watchContext.data?.let { data ->
55+
fun computeWatchedKeysIfStale() {
56+
if (!watchedKeysAreStale) {
57+
return
58+
}
59+
watchedKeysAreStale = false
60+
val data = dataToWatch
61+
watchedKeys = if (data == null) {
62+
null
63+
} else {
4164
val dataWithErrors = (data as D).withErrors(
4265
executable = request.operation,
43-
errors = null,
66+
errors = errorsToWatch,
4467
customScalarAdapters = customScalarAdapters,
4568
)
4669
cacheManager.normalize(
@@ -50,47 +73,44 @@ internal class WatcherInterceptor(val cacheManager: CacheManager) : ApolloInterc
5073
customScalarAdapters = customScalarAdapters,
5174
).values.dependentKeys()
5275
}
76+
}
77+
78+
fun isWatched(changedKeys: Set<*>): Boolean {
79+
if (changedKeys === CacheManager.ALL_KEYS) {
80+
// Matches regardless of the watched keys, so there is no need to compute them.
81+
return true
82+
}
83+
computeWatchedKeysIfStale()
84+
val watched = watchedKeys ?: return true
85+
86+
@Suppress("UNCHECKED_CAST")
87+
return (changedKeys as Set<String>).anyIntersection(watched)
88+
}
5389

5490
return (cacheManager.changedKeys as SharedFlow<Any>)
5591
.onSubscription {
5692
emit(Unit)
5793
}
58-
.filter { changedKeys ->
59-
changedKeys !is Set<*> ||
60-
changedKeys === CacheManager.ALL_KEYS ||
61-
watchedKeys == null ||
62-
changedKeys.intersect(watchedKeys!!).isNotEmpty()
63-
}.map {
64-
if (it == Unit) {
65-
flowOf(ApolloResponse.Builder(request.operation, request.requestUuid).exception(WatcherSentinel).build())
66-
} else {
67-
chain.proceed(request)
68-
.onEach { response ->
69-
if (response.data != null) {
70-
val dataWithErrors = response.data!!.withErrors(
71-
executable = request.operation,
72-
errors = response.errors,
73-
customScalarAdapters = customScalarAdapters,
74-
)
75-
watchedKeys = cacheManager.normalize(
76-
executable = request.operation,
77-
dataWithErrors = dataWithErrors,
78-
rootKey = CacheKey.QUERY_ROOT,
79-
customScalarAdapters = customScalarAdapters,
80-
).values.dependentKeys()
81-
}
82-
}
94+
.transform { event ->
95+
if (event !is Set<*>) {
96+
// The marker emitted by `onSubscription`. Answered without computing the watched keys,
97+
// so that the initial response of `watch` is not held back by normalization.
98+
emit(ApolloResponse.Builder(request.operation, request.requestUuid).exception(WatcherSentinel).build())
99+
return@transform
100+
}
101+
if (!isWatched(event)) {
102+
return@transform
83103
}
104+
emitAll(
105+
chain.proceed(request)
106+
.onEach { response ->
107+
if (response.data != null) {
108+
dataToWatch = response.data
109+
errorsToWatch = response.errors
110+
watchedKeysAreStale = true
111+
}
112+
}
113+
)
84114
}
85-
.flattenConcatPolyfill()
86115
}
87116
}
88-
89-
/**
90-
* A copy/paste of the kotlinx.coroutines version until it becomes stable
91-
*
92-
* This is taken from 1.5.2 and replacing `unsafeFlow {}` with `flow {}`
93-
*/
94-
private fun <T> Flow<Flow<T>>.flattenConcatPolyfill(): Flow<T> = flow {
95-
collect { value -> emitAll(value) }
96-
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.apollographql.cache.normalized.internal
2+
3+
/**
4+
* Returns whether this set and [other] have at least one element in common.
5+
*
6+
* Unlike `intersect`, this allocates nothing and stops at the first match. Elements are looked up in
7+
* the larger of the two sets, so the number of (constant time) lookups is bounded by the size of the
8+
* smaller one.
9+
*/
10+
internal fun <T> Set<T>.anyIntersection(other: Set<T>): Boolean {
11+
return if (size < other.size) {
12+
any { it in other }
13+
} else {
14+
other.any { it in this }
15+
}
16+
}

tests/normalized-cache/src/commonTest/kotlin/WatcherTest.kt

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ class WatcherTest {
6262
private val episodeHeroNameChangedTwoData = EpisodeHeroNameQuery.Data(EpisodeHeroNameQuery.Hero("Droid", "ArTwo"))
6363

6464
private val episodeHeroNameWithIdData = EpisodeHeroNameWithIdQuery.Data(EpisodeHeroNameWithIdQuery.Hero("Droid", "2001", "R2-D2"))
65+
private val episodeHeroNameWithIdChangedData =
66+
EpisodeHeroNameWithIdQuery.Data(EpisodeHeroNameWithIdQuery.Hero("Droid", "2001", "ArTwo"))
67+
68+
private val starshipByIdData = StarshipByIdQuery.Data(
69+
StarshipByIdQuery.Starship("Starship", "Starship1", "SuperRocket", listOf(listOf(900.0, 800.0)))
70+
)
6571

6672

6773
private val heroAndFriendsNamesWithIDsData = HeroAndFriendsNamesWithIDsQuery.Data(
@@ -316,6 +322,57 @@ class WatcherTest {
316322
job.cancel()
317323
}
318324

325+
/**
326+
* The keys a watcher matches cache changes against come from the latest response it has seen, and
327+
* they are only computed when a change actually needs them. Both of those have to keep holding
328+
* across a refetch: a watcher that loses its keys would match every subsequent change.
329+
*/
330+
@Test
331+
fun watchedKeysStillFilterAfterARefetch() = runTest(before = { setUp() }) {
332+
val channel = Channel<EpisodeHeroNameWithIdQuery.Data?>()
333+
334+
// The first query should get a "R2-D2" name
335+
val episodeHeroNameWithIdQuery = EpisodeHeroNameWithIdQuery(Episode.EMPIRE)
336+
apolloClient.enqueueTestResponse(episodeHeroNameWithIdQuery, episodeHeroNameWithIdData)
337+
val job = launch {
338+
apolloClient.query(episodeHeroNameWithIdQuery).watch().collect {
339+
channel.send(it.data)
340+
}
341+
}
342+
343+
// Cache miss is emitted first (null data)
344+
assertNull(channel.awaitElement())
345+
assertEquals(channel.awaitElement()?.hero?.name, "R2-D2")
346+
347+
// An overlapping query triggers a refetch, which is where the watched keys are refreshed
348+
val heroAndFriendsNamesWithIDsQuery = HeroAndFriendsNamesWithIDsQuery(Episode.NEWHOPE)
349+
apolloClient.enqueueTestResponse(heroAndFriendsNamesWithIDsQuery, heroAndFriendsNamesWithIDsNameChangedData)
350+
apolloClient.query(heroAndFriendsNamesWithIDsQuery)
351+
.fetchPolicy(FetchPolicy.NetworkOnly)
352+
.execute()
353+
354+
assertEquals(channel.awaitElement()?.hero?.name, "Artoo")
355+
356+
// A query sharing no key with the watched one is still filtered out after that refetch
357+
val starshipByIdQuery = StarshipByIdQuery("Starship1")
358+
apolloClient.enqueueTestResponse(starshipByIdQuery, starshipByIdData)
359+
apolloClient.query(starshipByIdQuery)
360+
.fetchPolicy(FetchPolicy.NetworkOnly)
361+
.execute()
362+
363+
channel.assertEmpty()
364+
365+
// ...while an overlapping one still reaches the watcher
366+
apolloClient.enqueueTestResponse(episodeHeroNameWithIdQuery, episodeHeroNameWithIdChangedData)
367+
apolloClient.query(episodeHeroNameWithIdQuery)
368+
.fetchPolicy(FetchPolicy.NetworkOnly)
369+
.execute()
370+
371+
assertEquals(channel.awaitElement()?.hero?.name, "ArTwo")
372+
373+
job.cancel()
374+
}
375+
319376
/**
320377
* A test to test refetching with a NetworkOnly refetchPolicy. On every change, the watcher should get new information
321378
* from the network

0 commit comments

Comments
 (0)