Skip to content

Add ComponentProvider #2828

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

Merged
merged 9 commits into from
Apr 7, 2020
Merged

Add ComponentProvider #2828

merged 9 commits into from
Apr 7, 2020

Conversation

schmidt-sebastian
Copy link
Contributor

@schmidt-sebastian schmidt-sebastian commented Mar 29, 2020

This PR removes most of the size overhead of Multi-Tab from the memory only build and may even allow us to create a slimmer non-Multi-Tab IndexedDB build at some point.

It does this by creating a subclass of LocalStore and SyncEngine and upgrading the PersistenceProvider to a ComponentProvider that is used by both FirestoreClient and the Spec tests to initialize and wire up a lot of the core components.

The PR also removes all non-supported multi-tab only operations from MemoryPersistence.

@google-oss-bot
Copy link
Contributor

google-oss-bot commented Mar 29, 2020

Binary Size Report

Affected SDKs

SDKTypeBase (5a63738)Head (fb500ed)Diff
@firebase/firestore/memorybrowser210531.00209948.00-583.00 (-0.28%)
module208912.00208351.00-561.00 (-0.27%)
esm2017167657.00167413.00-244.00 (-0.15%)
main374324.00373416.00-908.00 (-0.24%)
@firebase/firestorebrowser266846.00266659.00-187.00 (-0.07%)
module264832.00264645.00-187.00 (-0.07%)
esm2017213351.00213291.00-60.00 (-0.03%)
main484976.00484875.00-101.00 (-0.02%)
firebasefirebase.js840528.00840339.00-189.00 (-0.02%)
firebase-firestore.memory.js253605.00253054.00-551.00 (-0.22%)
firebase-firestore.js308630.00308442.00-188.00 (-0.06%)
firebase-database.js186644.00186643.00-1.00 (-0.00%)
@firebase/databasebrowser267885.00267892.00+7.00 (+0.00%)
module266342.00266349.00+7.00 (+0.00%)
esm2017234640.00234647.00+7.00 (+0.00%)
main268649.00268656.00+7.00 (+0.00%)
Metric Unit: byte

Test Logs

@schmidt-sebastian schmidt-sebastian force-pushed the mrschmidt/nomultitab branch 2 times, most recently from 2610229 to 46354ac Compare April 4, 2020 01:31
@schmidt-sebastian schmidt-sebastian changed the title Factor out Multi-Tab WIP Factor out Multi-Tab Apr 4, 2020
* Initializes and wires up all core components for Firestore. Consumers have to
* invoke initialize() once before accessing any of the individual components.
*/
export abstract class ComponentProvider {
Copy link
Contributor

Choose a reason for hiding this comment

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

Do you think this would end up as less code if this were just an interface that exposed the properties?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I changed this into an abstract class to share the asserts. I don't feel strongly.

datastore: Datastore,
clientId: ClientId,
initialUser: User,
maxConcurrentLimboResolutions: number,
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems like something that could be handled in a test-only subclass.

Indeed, it would be more flexible if instead of requiring subclasses to implement initialize, you required them to implement the getFoo calls and remember the result. Then for any dependency one component needs call getBar for it.

The downside of this approach would be that it's less deterministic, but the upside would be that:

  • the core initialization logic could be completely shared
  • test configurations that were just slightly different could be created by overriding just the one method's factory method.
  • You could pull in the loadConnection and Datastore construction here as well; it's slightly oddly outside here now

WDYT?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I tried to use shared code here, especially since this is likely the reason why the code size went up for the combined build. It's pretty tricky though since the providers instantiate different types and the IndexedDb implementation assign some listeners that are only available with multi tab (e.g. the primary state listener). If I want to use shared code I have to undo at least some of the state encapsulation that I tried to achieve.

As for removing initialize(): I debated doing this as well, but it doesn't change much in the code structure. getSyncEngine() would actually have to initialize LocalStore, which means that getSyncEngine() now has to take all the state required by both LocalStore and SyncEngine. On top of that, it would now be async.

We can probably remove some overhead by removing the asserts and exposing the member variables as properties. That would also remove the need for the abstract class.

Copy link
Contributor

Choose a reason for hiding this comment

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

I played around with trying to make this work and I agree this ends up looking pretty wonky. This is a snippet of what I had:

  private _persistenceKey?: string;
  getPersistenceKey(): string {
    if (this._persistenceKey == null) {
      this._persistenceKey = IndexedDbPersistence.buildStoragePrefix(
        this._databaseInfo
      );
    }
    return this._persistenceKey;
  }

  private _sharedClientState?: SharedClientState;
  getSharedClientState(): SharedClientState {
    if (this._sharedClientState == null) {
      this._sharedClientState = this._persistenceSettings.synchronizeTabs
        ? new WebStorageSharedClientState(
          this._asyncQueue,
          this._platform,
          this.getPersistenceKey(),
          this._clientId,
          this._initialUser
        )
        : new MemorySharedClientState();
    }
    return this._sharedClientState;
  }

  private _persistence?: Persistence;
  async getPersistence(): Promise<Persistence> {
    if (this._persistence == null) {
      this._persistence = await IndexedDbPersistence.createIndexedDbPersistence(
        {
          allowTabSynchronization: this._persistenceSettings.synchronizeTabs,
          persistenceKey: this.getPersistenceKey(),
          clientId: this._clientId,
          platform: this._platform,
          queue: this._asyncQueue,
          serializer: this.getSerializer(),
          lruParams: LruParams.withCacheSize(this._persistenceSettings.cacheSizeBytes),
          sequenceNumberSyncer: this.getSharedClientState()
        }
      );
    }
    return this._persistence;
  }

I think this side-steps the fundamental problem of needing to undo the state encapsulation you did for multi-tab because each implementation keeps variables of the types it wants and getFoo() methods can use a covariant return type.

In this implementation there's still an initialize() method, but essentially all it does is assign fields and then build the components map by invoking the getter for each entry in the components map. Any internal cross-wiring happens as a side-effect of these creation functions.

The trouble seems to be that while there is some shared code, only a few of them are truly copy/paste. That, in turn, doesn't seem to justify the considerable additional complexity of making the on-demand wiring work. I think it can be made to work, but I also think it's pretty expensive in terms of code and therefore may not be worth it.

I think the case gets better for this structure as you pull more construction in, including platform.loadConnection and constructing Datastore. However, that can also share that code between implementations of ComponentProvider by simpler, more ad-hoc means.

I've sent it back for your consideration in case you disagree with the conclusion I'm drawing from the experiment. If you like the idea, let's run with it. If you're not enthusiastic, let's skip it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I cleaned up 312e4cf a bit and chose that as my favorite. The reason I favor this are:

  • It creates a simple initialization flow that makes the sequence of events clear (or at least as clear as it might ever be).
  • It removes the need for async getters. If we want to return started components, almost all getters would need to be async (even WebStorageSharedClientState has a start()).
  • It ports better than e7cb1de.

Note that in the example above, we would even have to factor out WebStorageSharedClientState since we don't want to pull this into the Memory-only build (it isn't part of it right now).

I also updated the PR a bit: I moved the initialization of Datastore from the Auth-Token handler to initializeComponents (and added a TODO to make this part of the ComponentProvider if we ever figure out a clean strategy). As a consequence, we may now initialize Datastore, WebChannelConnection and Serializer more than once since we would re-run this code during persistence fallback. The construction of these objects seems pretty simple though, and hence I am not too worried about the overhead:

@@ -238,7 +240,7 @@ export class SyncEngine implements RemoteSyncer, SharedClientStateSyncer {
targetId,
status === 'current'
);
if (this.isPrimary) {
if (this.isPrimaryClient) {
Copy link
Contributor

Choose a reason for hiding this comment

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

It seems like this factoring leaves a lot of multi-tab knowledge in the base SyncEngine. Looking at how this logic breaks down I can't say I have any great ideas for how to improve that though.

@wilhuff wilhuff assigned schmidt-sebastian and unassigned wilhuff Apr 6, 2020
@wilhuff
Copy link
Contributor

wilhuff commented Apr 6, 2020

This seems like it could be two PRs: one that implements componentization, which seems unambiguously like a good idea that could be ported to the other platforms, and the multi-tab split which brings up separate issues (but also seems like a good idea).

@schmidt-sebastian
Copy link
Contributor Author

schmidt-sebastian commented Apr 7, 2020

I removed the Multi-Tab changes from this PR and pushed two different version:

312e4cf uses properties and removes the asserts. It's not necessarily following proper TypeScript conventions though (since the properties don't exist before initialize() is called and the internal and external state are closely linked).

e7cb1de is a type safe version that returns a list of initialized components. It is not very pretty though and might not port well. It does remove the size increase of this PR though.

I can pull in Datastore once we come up with a final strategy :)

@schmidt-sebastian schmidt-sebastian removed their assignment Apr 7, 2020
Copy link
Contributor

@wilhuff wilhuff left a comment

Choose a reason for hiding this comment

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

I don't have a strong preference for either 312e4cf or e7cb1de. The former seems more likely to be directly portable, and it's a pattern we already use (e.g. in the FirestoreClient). I don't feel super strongly though, so if you have a preference, either is ultimately fine with me. Take a look at the notes I have on splitting up initialize before choosing though.

datastore: Datastore,
clientId: ClientId,
initialUser: User,
maxConcurrentLimboResolutions: number,
Copy link
Contributor

Choose a reason for hiding this comment

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

I played around with trying to make this work and I agree this ends up looking pretty wonky. This is a snippet of what I had:

  private _persistenceKey?: string;
  getPersistenceKey(): string {
    if (this._persistenceKey == null) {
      this._persistenceKey = IndexedDbPersistence.buildStoragePrefix(
        this._databaseInfo
      );
    }
    return this._persistenceKey;
  }

  private _sharedClientState?: SharedClientState;
  getSharedClientState(): SharedClientState {
    if (this._sharedClientState == null) {
      this._sharedClientState = this._persistenceSettings.synchronizeTabs
        ? new WebStorageSharedClientState(
          this._asyncQueue,
          this._platform,
          this.getPersistenceKey(),
          this._clientId,
          this._initialUser
        )
        : new MemorySharedClientState();
    }
    return this._sharedClientState;
  }

  private _persistence?: Persistence;
  async getPersistence(): Promise<Persistence> {
    if (this._persistence == null) {
      this._persistence = await IndexedDbPersistence.createIndexedDbPersistence(
        {
          allowTabSynchronization: this._persistenceSettings.synchronizeTabs,
          persistenceKey: this.getPersistenceKey(),
          clientId: this._clientId,
          platform: this._platform,
          queue: this._asyncQueue,
          serializer: this.getSerializer(),
          lruParams: LruParams.withCacheSize(this._persistenceSettings.cacheSizeBytes),
          sequenceNumberSyncer: this.getSharedClientState()
        }
      );
    }
    return this._persistence;
  }

I think this side-steps the fundamental problem of needing to undo the state encapsulation you did for multi-tab because each implementation keeps variables of the types it wants and getFoo() methods can use a covariant return type.

In this implementation there's still an initialize() method, but essentially all it does is assign fields and then build the components map by invoking the getter for each entry in the components map. Any internal cross-wiring happens as a side-effect of these creation functions.

The trouble seems to be that while there is some shared code, only a few of them are truly copy/paste. That, in turn, doesn't seem to justify the considerable additional complexity of making the on-demand wiring work. I think it can be made to work, but I also think it's pretty expensive in terms of code and therefore may not be worth it.

I think the case gets better for this structure as you pull more construction in, including platform.loadConnection and constructing Datastore. However, that can also share that code between implementations of ComponentProvider by simpler, more ad-hoc means.

I've sent it back for your consideration in case you disagree with the conclusion I'm drawing from the experiment. If you like the idea, let's run with it. If you're not enthusiastic, let's skip it.

Copy link
Contributor

@wilhuff wilhuff left a comment

Choose a reason for hiding this comment

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

LGTM

Change description to reflect that this PR is now about componentization?

components.sharedClientState.onlineStateHandler = onlineState =>
components.syncEngine!.applyOnlineStateChange(
this.sharedClientState.onlineStateHandler = onlineState =>
this.syncEngine!.applyOnlineStateChange(
Copy link
Contributor

Choose a reason for hiding this comment

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

this.syncEngine is already declared as force unwrapped. Is the ! necessary?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed

@schmidt-sebastian schmidt-sebastian changed the title Factor out Multi-Tab Add ComponentProvider Apr 7, 2020
@schmidt-sebastian schmidt-sebastian merged commit 6a4c00f into master Apr 7, 2020
@schmidt-sebastian schmidt-sebastian deleted the mrschmidt/nomultitab branch April 7, 2020 23:20
@firebase firebase locked and limited conversation to collaborators May 8, 2020
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants