Skip to content

Fetch the initial responses of watch() from the watcher interceptor - #379

Open
AlexanderGH wants to merge 6 commits into
apollographql:mainfrom
AlexanderGH:perf/overlap-watch-subscription
Open

Fetch the initial responses of watch() from the watcher interceptor #379
AlexanderGH wants to merge 6 commits into
apollographql:mainfrom
AlexanderGH:perf/overlap-watch-subscription

Conversation

@AlexanderGH

Copy link
Copy Markdown
Contributor

watch() executed the operation twice: once through toFlow() for the initial
responses, then again through copy().watchInternal() to subscribe to the
cache. The last initial response was withheld until that second execution had
subscribed, so callers waited on a flow teardown, a fresh
channelFlow/withContext dispatch, and a re-run of every interceptor ahead of
the cache before receiving data that was already in hand.

Move the initial fetch into WatcherInterceptor. It proceeds once with the
fetch policy, subscribes, releases the withheld response, and then proceeds
per cache change with the refetch policy - all from a single execution of the
interceptor chain. What the withheld response now waits on is a SharedFlow
subscription and nothing else.

The synchronisation point of apollographql/apollo-kotlin#3853 is preserved:
the response is still released only after the subscription is established, so
modifying the store on receiving it is still observed.

Subscribing before the initial fetch was tried first and is not viable: the
watcher then sees its own initial write publish and refetches, which breaks 13
tests with duplicated emissions and shifted sequences. The ordering here is
load-bearing, and the cost it used to carry came from the second chain
execution rather than from the ordering itself.

watch(data) keeps its existing behaviour through fetchInitialResponses=false:
no initial request, fetch policy used throughout, sentinel still emitted for
the public overload to filter.

@AlexanderGH
AlexanderGH force-pushed the perf/overlap-watch-subscription branch from 0269212 to 8cfd67c Compare August 13, 2026 02:40
AlexanderGH and others added 3 commits August 13, 2026 11:15
watch() executed the operation twice: once through toFlow() for the initial
responses, then again through copy().watchInternal() to subscribe to the
cache. The last initial response was withheld until that second execution had
subscribed, so callers waited on a flow teardown, a fresh
channelFlow/withContext dispatch, and a re-run of every interceptor ahead of
the cache before receiving data that was already in hand.

Move the initial fetch into WatcherInterceptor. It proceeds once with the
fetch policy, subscribes, releases the withheld response, and then proceeds
per cache change with the refetch policy - all from a single execution of the
interceptor chain. What the withheld response now waits on is a SharedFlow
subscription and nothing else.

The synchronisation point of apollographql/apollo-kotlin#3853 is preserved:
the response is still released only after the subscription is established, so
modifying the store on receiving it is still observed.

Subscribing *before* the initial fetch was tried first and is not viable: the
watcher then sees its own initial write publish and refetches, which breaks 13
tests with duplicated emissions and shifted sequences. The ordering here is
load-bearing, and the cost it used to carry came from the second chain
execution rather than from the ordering itself.

watch(data) keeps its existing behaviour through fetchInitialResponses=false:
no initial request, fetch policy used throughout, sentinel still emitted for
the public overload to filter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three properties the previous commit depends on had no test of their own.
Each was verified to fail against a deliberate break of what it covers.

- initialFetchDoesNotTriggerTheWatcher: the initial fetch writes to the cache
  and publishes, and the watcher subscribes only afterwards, so it must not
  react to its own write. Fails when the subscription is moved ahead of the
  fetch.

- refetchUsesTheRefetchPolicyRatherThanTheFetchPolicy: the initial responses
  use the fetch policy and the refetches use the refetch policy, including
  when they disagree. Pins the refetch request built by the interceptor, which
  has to clear the noCache set by a NetworkOnly fetch policy. Fails when
  refetches reuse the original request.

- watchExecutesTheInterceptorChainOnce: counts executions of an interceptor
  installed ahead of the cache. Fails with expected:<1> but was:<2> against
  the previous two-execution implementation, which is the regression it
  exists to catch.

The synchronisation point of apollographql/apollo-kotlin#3853 and the absence
of an initial request in watch(data) were already covered, by
storeWriteTriggersWatcher and cacheOnlyFetchPolicy respectively.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AlexanderGH
AlexanderGH force-pushed the perf/overlap-watch-subscription branch from 8cfd67c to d2de9ac Compare August 13, 2026 18:17
@AlexanderGH
AlexanderGH marked this pull request as ready for review August 14, 2026 02:28
AlexanderGH and others added 2 commits August 13, 2026 20:11
Cache headers travel in the ExecutionContext, and both the client and the
call can set them, so CacheHeadersContext merged the two in `fold`.
ExecutionContext.plus only reaches an element's `fold` when that element
sits at the far left of the combined context, though — everywhere else it
hands the element straight to the fold operation, and the accumulated
element for that key is dropped. Whether the client's headers survived a
call that set headers of its own therefore came down to the order the
options happened to be set in.

Give every CacheHeadersContext its own key so plus never drops one, and
merge them where they are read instead.

This surfaced on watch(): its refetch used to be a second execution of the
chain whose context happened to be shaped so the merge ran, so folding the
two executions into one lost the client's headers on the refetch and
`CacheOptionsTest.memoryCacheOnlyIsPropagated` failed. The initial fetch
never got them either way, which is what the new test covers directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* same [com.apollographql.apollo.api.ExecutionContext] and have to be merged.
*/
@Test
fun clientCacheHeadersSurviveCallCacheHeaders() = runTest(before = { setUp() }, after = { tearDown() }) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for noticing this bug + the test! I just simplified a little bit the fix in 45efbfb

}

assertEquals(channel.awaitElement()?.hero?.name, "R2-D2")
assertEquals("R2-D2", channel.awaitElement()?.hero?.name)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the noise, unrelated to that PR, but I fixed the warnings in that file in 9d7a5d5

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants