Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ApolloPagination: Various API Improvements, refactor into actor #80

Merged

Conversation

Iron-Ham
Copy link
Contributor

@Iron-Ham Iron-Ham commented Oct 12, 2023

What are you trying to accomplish?

  1. Support for many potential simultaneous triggers of loadMore across many threads.
  2. Adds support for throwing transforms in type erased AnyGraphQLQueryPagers.
  3. Changes the API to use a canLoadNext function that can be properly used in a type erased pager.
  4. Allow AnyGraphQLQueryPager to operate over an individual model as well as arrays. I found that some instances of pagination involved updating a singular model, vs keeping an array of models.
  5. Ease of use for both UIKit and SwiftUI contexts.

What approach did you choose and why?

Thread Safety

I kept bashing my head into this towards the tail end of last week. On the morning of 10/15, the idea just came to me:

Instead of trying to use async/await APIs, or locks, or what have you, you can use a combination of Combine and async/await conversion APIs to achieve the goal.

This API is deceptively challenging and non-trivial to make thread safe. It boils down to the following set of requirements:

  1. We want to block the loadMore action if there is already an extant loadMore in flight.
  2. A loadMore isn't complete until it triggers the callback of its underlying watcher.
  3. A watcher can trigger that callback many times: Once or twice on initial call (depending on the cachePolicy), and then again if it observes any cache changes. This means that we want to block it only until the "canonical" load has landed.

A continuation can only be continued once. Continuing it more than once will trigger a crash.
A Combine Publisher can continuously receive values, until it receives a completion event, at which point it will no longer receive or publish values.

My thought boiled down to the following:

  1. Create a Combine publisher with the intent of only sending a completion when the "canonical load" has returned, or upon error.
  2. Use an async/await continuation to await on the result of the canonical load.
  3. Defer to the task that contains that async/await continuation if the loadMore function is called again.

By then turning the Pager into an actor, we can ensure thread safety as it runs all of its operations in a thread isolated context

Support for throwing transforms in type erased AnyGraphQLQueryPagers

Some models don't necessarily always initialize. I'm choosing to support throwing transforms here as opposed to (Query.Data) -> ModelType? with the assumption that one can be converted to the other, but throwing transforms provide additional information that can be bubbled up to the pager.

Changes the API to use a canLoadNext function that can be properly used in a type erased pager.

When used as a variable, we couldn't access this value as it required knowledge of the pager used during instantiation. However, as a function, we can pass its reference to the type erasure and access it accordingly.

Allow AnyGraphQLQueryPager to operate over an individual model as well as arrays.

I found that some instances of pagination involved updating a singular model, vs keeping an array of models. Some of our code at GitHub operates over a single model. For example, when rendering a pull request, we operate over a pull request model. When we paginate in that view, we are loading the events, comments, and so on that are associated with that pull request (@so-and-so has marked as draft, @so-and-so-and-so has closed with comment, etc.).

Ease of use for both UIKit and SwiftUI contexts.

The GraphQLQueryPager is easily used in UIKit, whereas SwiftUI can easily use both GraphQLQueryPager and GraphQLQueryPager.Actor (.task { ... } can use an actor easily).

Anything you want to highlight for special attention from reviewers?

Yes! This is pretty wild stuff. I would love a second set of eyes on this.


🤖 Generated by Copilot

Summary

🔄🚀🐛

This pull request introduces a new GraphQLQueryPagerWrapper class that adds concurrency and cancellation support to the existing GraphQLQueryPager class for pagination of GraphQL queries. It also updates various classes and tests that use the GraphQLQueryPager class to use the new wrapper class instead. Additionally, it refactors the type-erasing AnyGraphQLQueryPager class to use the new wrapper class and simplifies the pagination logic and interface. Finally, it adds a new error case loadInProgress to the PaginationError enum to handle concurrent load more requests.

There once was a class called GraphQLQueryPager
That needed some updates to make it much safer
It got a new wrapper
With actor and publisher
And now it supports concurrency and cancellation behavior

Walkthrough

  • Replace GraphQLQueryPager type with GraphQLQueryPagerWrapper type in various pagination scenarios and tests
  • Add a new case loadInProgress to the PaginationError enum
  • Add a new function test_concurrentFetches_nonisolated to the ConcurrencyTests class
  • Refactor the logic of the fetch and loadMore methods of the GraphQLQueryPager class into smaller methods that handle the initial fetch, the subsequent fetch, and the task cancellation
  • Remove the subscribe method from the GraphQLQueryPager class and the AnyGraphQLQueryPager class
  • Remove the fetch method from the GraphQLQueryPager class and the AnyGraphQLQueryPager class
  • Remove the _subject property and the subject property from the AnyGraphQLQueryPager class
  • Remove the _subject.send(completion: .finished) line from the cancel method of the GraphQLQueryPager class
  • Remove an empty line from the AnyGraphQLQueryPagerTests class

@Iron-Ham Iron-Ham marked this pull request as draft October 12, 2023 03:05
@Iron-Ham Iron-Ham changed the title ApolloPagination: Extra conveniences, support throwing transforms ApolloPagination: Extra conveniences, API Changes Oct 13, 2023
@Iron-Ham Iron-Ham marked this pull request as ready for review October 13, 2023 19:04
@Iron-Ham Iron-Ham changed the title ApolloPagination: Extra conveniences, API Changes ApolloPagination: Various API Improvements Oct 15, 2023
@Iron-Ham Iron-Ham force-pushed the hs/support-throwing-transforms branch from 13919b6 to 552c689 Compare October 16, 2023 21:26
@Iron-Ham Iron-Ham marked this pull request as draft October 17, 2023 00:19
@Iron-Ham Iron-Ham marked this pull request as ready for review October 18, 2023 00:03
@Iron-Ham Iron-Ham changed the title ApolloPagination: Various API Improvements ApolloPagination: Various API Improvements, refactor into actor Oct 18, 2023
@@ -1,160 +1,205 @@
import Apollo
import ApolloAPI

Copy link
Contributor

Choose a reason for hiding this comment

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

Can we add a function here for easily converting a GraphQLPager to an AnyGraphQLPager like a map function? Not sure what to call it, maybe mappingResults?

So you can do this:

let pager: AnyGraphQLPager<CustomModel> = makeQueryPager(
    client: client,
    queryProvider: { _ in PageQuery() },
    extractPageInfo: { data in CustomPage(data) }
).mappingResults { result in
 CustomModel(result)
}

Copy link
Contributor Author

@Iron-Ham Iron-Ham Oct 26, 2023

Choose a reason for hiding this comment

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

I have those over in AnyGraphQLPager.swift -- extensions on GraphQLQueryPager named eraseToAnyPager, which allow you to supply a function mapping Output to your custom model.

Copy link
Contributor

@AnthonyMDev AnthonyMDev left a comment

Choose a reason for hiding this comment

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

This is coming along so nicely! Thanks!

Co-authored-by: Anthony Miller <anthonymdev@gmail.com>
@BobaFetters
Copy link
Member

@Iron-Ham any idea why the CI tests etc arent running anymore for this PR? Doesn't look like the additions you made should have affected that but maybe they did?

@Iron-Ham
Copy link
Contributor Author

@BobaFetters No clue -- the CI changes in here shouldn't have an impact.

I imagine it's related to the verified commit accepting Anthony's suggestion? I can double check when I'm back in office 😜

@AnthonyMDev AnthonyMDev merged commit 6da21aa into apollographql:main Nov 8, 2023
BobaFetters pushed a commit to apollographql/apollo-ios-pagination that referenced this pull request Nov 8, 2023
BobaFetters pushed a commit that referenced this pull request Nov 8, 2023
BobaFetters pushed a commit that referenced this pull request Nov 8, 2023
c618c8d8 ApolloPagination: Various API Improvements, refactor into  (#80)

git-subtree-dir: apollo-ios-pagination
git-subtree-split: c618c8d8fcbafa73f23484e79e03dc02d000c072
BobaFetters pushed a commit that referenced this pull request Nov 8, 2023
… Improvements, refactor into

git-subtree-dir: apollo-ios-pagination
git-subtree-mainline: b5daee0
git-subtree-split: c618c8d8fcbafa73f23484e79e03dc02d000c072
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.

4 participants