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

Cache store scoping when possible #2527

Merged
merged 201 commits into from
Nov 26, 2023
Merged

Cache store scoping when possible #2527

merged 201 commits into from
Nov 26, 2023

Conversation

stephencelis
Copy link
Member

@stephencelis stephencelis commented Oct 20, 2023

Currently, store scoping can happen quite often: whenever a view that scopes a store is recomputed, the old scoped store is discarded and a brand new scoped store is created. This involves creating a new object and parent/child subscription every single time a store is scoped. While stores are generally lightweight objects, this is still unnecessary work.

This PR introduces the ability to cache child stores directly in the parent via new scope methods. These scope methods take a state key path instead of state transform function. The parent store uses this key path as a hashable identifier to store the child store in a dictionary. This means that after the first time a store is scoped, scoping to it again is a simple dictionary lookup.

Anyone using key path expression syntax for their scopes will automatically benefit from this change, while the older style of scoping (soft deprecated for now and probably soft deprecated for some time) will mostly go through the older code path.

Adapting an existing benchmark to scope a store X number of times before sending an action to the scoped store shows a pretty significant improvement:

Before:

name                       time             std        iterations
-----------------------------------------------------------------
Store.Scoped nested tap: 1   53291.000 ns ±   6.35 %      26188
Store.Scoped nested tap: 2 1818583.000 ns ±  31.98 %       2514
Store.Scoped nested tap: 3 3289500.000 ns ±   6.57 %        469
Store.Scoped nested tap: 4 4203916.500 ns ±   5.79 %        370
Store.Scoped nested tap: 5 5205125.000 ns ±   5.25 %        296

After:

name                       time             std        iterations
-----------------------------------------------------------------
Store.Scoped nested tap: 1   52375.000 ns ±   5.75 %      26657
Store.Scoped nested tap: 2  109833.000 ns ±   6.05 %      12640
Store.Scoped nested tap: 3  165834.000 ns ±   5.53 %       8371
Store.Scoped nested tap: 4  228583.000 ns ±   4.47 %       6103
Store.Scoped nested tap: 5  299500.000 ns ±   4.11 %       4645

@@ -24,10 +24,8 @@ final class EnumTests: BaseIntegrationTests {
StoreOf<BasicsView.Feature?>.init
StoreOf<BasicsView.Feature?>.init
StoreOf<BasicsView.Feature?>.init
StoreOf<BasicsView.Feature?>.init
Copy link
Member Author

Choose a reason for hiding this comment

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

These deletions in our integration suite represent scoped stores that are able to be reused rather than discarded and recreated each render.

@@ -697,116 +813,6 @@ public final class Store<State, Action> {
/// ```
public typealias StoreOf<R: Reducer> = Store<R.State, R.Action>

extension Reducer {
fileprivate func rescope<ChildState, ChildAction>(
Copy link
Member Author

Choose a reason for hiding this comment

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

Since stores are now being cached, we think this rescope machinery is no longer necessary in most cases. I also did not measure much of a performance difference in its removal in our very limited suite.

@iampatbrown Let me know your thoughts on this, especially if you have apprehensions. I think we could potentially keep rescope for stores that are not cached, though I'm not 100% sure what that would look like.

Copy link
Contributor

Choose a reason for hiding this comment

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

No apprehensions :) I'm a fan of the cached approach.
Scoping via a key path is probably preferable. Computed properties might be an alternative for people using a closure as well.

let childStore = Store<ChildState, ChildAction>(
initialState: initialChildState
) {
Reduce(internal: { [weak self] childState, childAction in
Copy link
Member Author

Choose a reason for hiding this comment

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

We should probably explore using a dedicated reducer conformance for scoping (as was done before with rescope), though it'd be nice to measure the difference.

Copy link
Contributor

@iampatbrown iampatbrown Oct 21, 2023

Choose a reason for hiding this comment

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

It could potentially contain some of the strong/weak dance too.

@pyrtsa
Copy link
Contributor

pyrtsa commented Oct 21, 2023

Could you explain how this affects the lifetimes of child stores? Will the parent retain them for as long as it lives, or if not then when will a child be removed from the children dictionary?

@stephencelis
Copy link
Member Author

Could you explain how this affects the lifetimes of child stores? Will the parent retain them for as long as it lives, or if not then when will a child be removed from the children dictionary?

@pyrtsa Child stores live as long as they are "valid". Optional/presented children are removed when the associated state goes away. While this means some stores may live longer than previously, where they'd only live as long as a view is in the hierarchy, we think it won't ever be an inordinate number.

@acosmicflamingo
Copy link
Contributor

acosmicflamingo commented Oct 21, 2023

Many of the custom UICollectionViewListCell subclasses I have in my codebase have a Store in them that are actually tuples, and I am wondering if/how I could leverage caching from this PR in that use-case. For example, here's how I register my supplementary views:

let footerRegistration = UICollectionView.SupplementaryRegistration<SupplementaryView>(
  elementKind: UICollectionView.elementKindSectionFooter
) { [weak store] footerView, elementKind, indexPath in
  guard let store else { return }

  store.scope(
    state: {
      let item = $0.sections[indexPath.section].footer
      return (
        item: item,
        elementKind: elementKind
      )
    },
    action: { $0 }
  ).ifLet { [weak footerView] store in
    footerView?.update(with: store)
  }
  .store(in: &footerView.cancellables)
}

Would there be a way I could benefit from cached scoping if I am using closures to transform a parent state to a tuple child state? I imagine the answer is no because I think 99% of the time the scoped state is a subset of original state, but that can't be said for what I'm doing in the example above (elementKind value does not come from store).

@stephencelis
Copy link
Member Author

@acosmicflamingo You can always define computed properties and subscripts to make new key paths. In this case I think you could add a custom subscript that takes the element kind.

Something like:

extension Feature.State {
  subscript(
    section section: Int, elementKind elementKind: ElementKind
  ) -> (item: Item, elementKind: ElementKind) {
    (item: self.sections[section].footer, elementKind: elementKind)
  }
}

And then:

store.scope(
  state: \.[section: indexPath.section, elementKind: elementKind],
  action: { $0 }
).ifLet { [weak footerView] store in
  footerView?.update(with: store)
}
.store(in: &footerView.cancellables)

@acosmicflamingo
Copy link
Contributor

@stephencelis BRILLIANT! Thank you for the reply AND example too!

@acosmicflamingo
Copy link
Contributor

acosmicflamingo commented Nov 19, 2023

@mbrandonw that is correct. The commit where the regression occurs only includes changes where Store.action is now being passed a casePath instead of a closure. I also verified that the scope where the issue occurs only exists in store-tree and not 1.4.2. I'll try to create a reproducer soon.

@acosmicflamingo
Copy link
Contributor

@mbrandonw this might be quite a corner case that only impacts me because I am doing something a little different in order to work around an issue that has to do with how UICollectionView itself handles applying snapshots when the snapshot sectionIdentifiers are dynamic. There was a workaround that an Apple engineer in the UICollectionView team recommended years ago where to work around this issue I should apply an empty snapshot first and then the actual snapshot to a data source...so it might not be a coincidence that the instances where I'm getting these issues are the same ones that involve this workaround. Any time a UICollectionReusableView instance is brought into view, it contains the right data as well.

I'll have to do further investigation but I do not think that this is a blocker for the store-tree release. In all other cases in my app where I utilize diffable data source snapshot API, everything works perfectly.

@mbrandonw
Copy link
Member

@acosmicflamingo Thanks for following up. Please do let us know if you uncover anything fishy happening.

@acosmicflamingo
Copy link
Contributor

acosmicflamingo commented Nov 20, 2023

Hmm...how does a Store know when to clear out the Store cache whose key comprises the keyPath and casePath of the state and action, respectively? With some print statements, I have found that a store instance theoretically has the right value, but the scoped store does not reflect that:

  if
    let item = store.withState(\.snapshotData.sections[safeIndex: indexPath.section])
  {
    print("Store.withState:", item)
  }
  if let scopedState = store.scope(
    state: \.snapshotData.sections[safeIndex: indexPath.section],
    action: \.self
  ).withState({ $0 }) {
    print("ScopedStore.withState", scopedState)
  }
  
Prints:
Store.withState: string("A")
ScopedStore.withState string("Next Week")
Store.withState: string("B")
ScopedStore.withState string("Today")

When I asked the question earlier about the SupplementaryView displaying the wrong text, I thought that there was some race condition going on (and maybe there still is), but now I can definitely say that a Store instance does certainly have the value I expect, but scoping to that with the kePath/casePath scope function gives me the wrong value. I know you asked for a reproducer but it's going to take a whole lot more time than I anticipated to do that.

@acosmicflamingo
Copy link
Contributor

I'm pretty confident I have found the root of the problem and it looks to be a race condition. In my scenario, I'm extracting some scoped state within a cell registration closure and it appears that by getting the cached Store, it's so absolutely fast that my SupplementaryView instance is extracting some state before the stateSubject publisher has even had a chance to emit a new stream and update itself with a new value: https://github.com/pointfreeco/swift-composable-architecture/blob/50b5cc6fffc09bf2c2a1ecca3058aa4b8e61b4da/Sources/ComposableArchitecture/Store.swift#L559C10-L559C10.

I can confirm this with some trusty print statements:

ChildState has updated in scoped subscription closure with: Optional(MediaLibraryFeature.MediaLibrarySection.string("A"))
Store.withState: string("A")
Returning childStore
ScopedStore.withState string("A")
MediaLibrarySectionCell updated with A
Store.withState: string("B")
Returning childStore
ScopedStore.withState string("Today")
MediaLibrarySectionCell updated with Today
ChildState has updated in scoped subscription closure with: Optional(MediaLibraryFeature.MediaLibrarySection.string("B"))
ChildState has updated in scoped subscription closure with: Optional(MediaLibraryFeature.MediaLibrarySection.string("A"))
ChildState has updated in scoped subscription closure with: Optional(MediaLibraryFeature.MediaLibrarySection.string("B"))
image

Notice that the first header view is correct, and the logs show that the subscription for the letter "A" is executed before any of the Store.scope functions within the cell registration closure have printed anything. Now notice that the order is flipped on the incorrect "Today" header at the bottom (which should be the letter "B"). It's very interesting that the logs show that MediaLibrarySectionCell have updated with the out-of-date state before "ChildState has updated in scoped subscription closure with: Optional(MediaLibraryFeature.MediaLibrarySection.string("B"))" ever appeared.

@mbrandonw
Copy link
Member

Thanks for the details, but without a minimal reproducing project there really isn't much help we can offer. I think that would be the best path forward.

@acosmicflamingo
Copy link
Contributor

@mbrandonw Yep, I understand. Since I've narrowed the problem down, it'll be easier for me to create a reproducer. In addition, there are things I can do on my end to work around this (like slapping on a receive(on: DispatchQueue.main). Thanks!

@acosmicflamingo
Copy link
Contributor

acosmicflamingo commented Nov 21, 2023

Here's a reproducer: https://github.com/acosmicflamingo/StoreScopingCacheReproducer.git. To reproduce, simply tap on the second segmented control button and you'll see that sometimes the header does not reflect what should be shown. These logs that I have also confirm what I'm seeing:

/*
Initial:
Store.withState: first("9")
ScopedStore.withState first("9")
MusicLibrarySectionCell updated with 9
Store.withState: first("A")
ScopedStore.withState first("A")
MusicLibrarySectionCell updated with A
Store.withState: first("B")
ScopedStore.withState first("B")
MusicLibrarySectionCell updated with B
Store.withState: first("C")
ScopedStore.withState first("C")
MusicLibrarySectionCell updated with C
Store.withState: first("D")
ScopedStore.withState first("D")
MusicLibrarySectionCell updated with D
*/
image
/*
After tapping second segment:
Store.withState: first("E")
ScopedStore.withState first("E")
MusicLibrarySectionCell updated with E
Store.withState: first("W")
ScopedStore.withState first("9")
MusicLibrarySectionCell updated with 9
Store.withState: first("S")
ScopedStore.withState first("S")
MusicLibrarySectionCell updated with S
Store.withState: first("H")
ScopedStore.withState first("H")
MusicLibrarySectionCell updated with H
Store.withState: first("D")
ScopedStore.withState first("D")
MusicLibrarySectionCell updated with D
Store.withState: first("E")
ScopedStore.withState first("E")
MusicLibrarySectionCell updated with E
Store.withState: first("S")
ScopedStore.withState first("S")
MusicLibrarySectionCell updated with S
Store.withState: first("H")
ScopedStore.withState first("H")
MusicLibrarySectionCell updated with H
Store.withState: first("W")
ScopedStore.withState first("9")
MusicLibrarySectionCell updated with 9
Store.withState: first("D")
ScopedStore.withState first("D")
MusicLibrarySectionCell updated with D
Store.withState: first("W")
ScopedStore.withState first("9")
MusicLibrarySectionCell updated with 9
Store.withState: first("H")
ScopedStore.withState first("H")
MusicLibrarySectionCell updated with H
Store.withState: first("S")
ScopedStore.withState first("S")
MusicLibrarySectionCell updated with S
Store.withState: first("E")
ScopedStore.withState first("E")
MusicLibrarySectionCell updated with E
*/
image

It is a race condition so sometimes it will work, in which case you just have to rebuild and try tapping again. Any of the headers could have the issue too; it's just a matter of looking in the log and seeing that Store.withState does not coincide with ScopedStore.withState. Sorry to dump cold water on the potential store-tree merge but maybe we'll find what I'm doing wrong :)

@stephencelis stephencelis merged commit af5ae21 into main Nov 26, 2023
5 checks passed
@stephencelis stephencelis deleted the store-tree branch November 26, 2023 17:58
@acosmicflamingo
Copy link
Contributor

It looks like the reproducer I created demonstrating this race condition still has the problem where the cached store is not guaranteed to have its sink function executed by the time it's returned, resulting in incorrect data sometimes being used to populate a UICollectionView's section headers. Is this a bug or a feature?

@mbrandonw
Copy link
Member

@acosmicflamingo It's hard to say. In order for us to best look into the issue we think we need a smaller reproducing example. The current one is quite complex and it's hard to tell if it's an issue with the library or in UIKit.

Ideally the problem could be expressed as a simple unit test, but if that's not possible then an integration test with fewer moving parts would be best.

@acosmicflamingo
Copy link
Contributor

acosmicflamingo commented Nov 27, 2023

@mbrandonw how is this latest commit acosmicflamingo/StoreScopingCacheReproducer@07930c8? If you check it out, you'll notice that I've removed all possible models, extensions, and view/controllers that makes the app more complex than it needs to be (and hopefully makes it easier to debug this issue). I've also added an integration test that will fail about half the time:

final class StoreScopingCacheReproducerUITests: XCTestCase {
  override func setUpWithError() throws {
    continueAfterFailure = false
  }

  func testSectionHeaderText() throws {
    let app = XCUIApplication()
    app.launch()

    // Always passes
    app.buttons["A to Z"].tap()
    let headerA = app.collectionViews.element(boundBy: 0).cells.element(boundBy: 0)
    XCTAssertEqual(headerA.label, "9")

    // Does not always pass
    app.buttons["Z to A"].tap()
    let headerZ = app.collectionViews.element(boundBy: 0).cells.element(boundBy: 0)
    XCTAssertEqual(headerZ.label, "W")
  }
}
 
Result: Executed 100 tests, with 58 failures (0 unexpected) in 527.966 (528.031) seconds

cgrindel-self-hosted-renovate bot referenced this pull request in cgrindel/rules_swift_package_manager Nov 27, 2023
…ure to from: "1.5.0" (#773)

This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
|
[pointfreeco/swift-composable-architecture](https://togithub.com/pointfreeco/swift-composable-architecture)
| minor | `from: "1.4.2"` -> `from: "1.5.0"` |

---

### Release Notes

<details>
<summary>pointfreeco/swift-composable-architecture
(pointfreeco/swift-composable-architecture)</summary>

###
[`v1.5.0`](https://togithub.com/pointfreeco/swift-composable-architecture/releases/tag/1.5.0)

[Compare
Source](https://togithub.com/pointfreeco/swift-composable-architecture/compare/1.4.2...1.5.0)

#### What's Changed

- Added: A new `Store.scope` method that takes a state key path and
action case key path
([https://github.com/pointfreeco/swift-composable-architecture/pull/2527](https://togithub.com/pointfreeco/swift-composable-architecture/pull/2527)).
This method uses the hashability of key paths to cache scoped child
stores in the parent, improving the performance of store scoping,
especially when SwiftUI views are recomputed.

See [the migration
guide](https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5)
for more info.
- Added: DependenciesMacros is now automatically exported, making
`@DependencyClient` available by default when importing the Composable
Architecture (thanks
[@&#8203;tgrapperon](https://togithub.com/tgrapperon),
[https://github.com/pointfreeco/swift-composable-architecture/pull/2586](https://togithub.com/pointfreeco/swift-composable-architecture/pull/2586)).
- Infrastructure: Fixed links in the migration guide (thanks
[@&#8203;woxtu](https://togithub.com/woxtu),
[https://github.com/pointfreeco/swift-composable-architecture/pull/2578](https://togithub.com/pointfreeco/swift-composable-architecture/pull/2578)).

**Full Changelog**:
pointfreeco/swift-composable-architecture@1.4.2...1.5.0

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config help](https://togithub.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR has been generated by [Renovate
Bot](https://togithub.com/renovatebot/renovate).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNi4xMDAuMCIsInVwZGF0ZWRJblZlciI6IjM2LjEwMC4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiJ9-->

Co-authored-by: Self-hosted Renovate Bot <361546+cgrindel-self-hosted-renovate[bot]@users.noreply.github.enterprise.com>
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.

6 participants