Skip to content

Make View processing logic optional #3561

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 24 commits into from
Aug 27, 2020
Merged

Conversation

schmidt-sebastian
Copy link
Contributor

@schmidt-sebastian schmidt-sebastian commented Aug 4, 2020

This PR changes EventManager and SyncEngine to make listen/unlisten and the related View-processing logic optional. This is a stepping stone that will allow us to remove any Watch-related code for users that don't use Watch (or let them lazy load the components).

View processing logic is "injected" via a new docsChangeListener in SyncEngine that is provided when the first view is initialized: https://github.com/firebase/firebase-js-sdk/pull/3561/files#diff-50ed4637ed85c4c9895df1ce8112114aR867
I personally think this is pretty ugly, but I couldn't come up with a cleaner design. Suggestions welcome :)

@changeset-bot
Copy link

changeset-bot bot commented Aug 4, 2020

💥 No Changeset

Latest commit: 7f52b3c

Merging this PR will not cause any packages to be released. If these changes should not cause updates to packages in this repo, this is fine 🙂

If these changes should be published to npm, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@google-oss-bot
Copy link
Contributor

google-oss-bot commented Aug 4, 2020

Binary Size Report

Affected SDKs

  • @firebase/firestore

    Type Base (e8b950f) Head (b2e9ad5) Diff
    browser 249 kB 249 kB -163 B (-0.1%)
    esm2017 195 kB 195 kB +238 B (+0.1%)
    main 475 kB 478 kB +2.91 kB (+0.6%)
    module 246 kB 246 kB -185 B (-0.1%)
    react-native 195 kB 196 kB +238 B (+0.1%)
  • @firebase/firestore/exp

    Type Base (e8b950f) Head (b2e9ad5) Diff
    browser 189 kB 189 kB +245 B (+0.1%)
    main 468 kB 471 kB +2.95 kB (+0.6%)
    module 189 kB 189 kB +245 B (+0.1%)
    react-native 189 kB 189 kB +245 B (+0.1%)
  • @firebase/firestore/memory

    Type Base (e8b950f) Head (b2e9ad5) Diff
    browser 187 kB 186 kB -182 B (-0.1%)
    esm2017 146 kB 147 kB +210 B (+0.1%)
    main 349 kB 352 kB +2.82 kB (+0.8%)
    module 185 kB 184 kB -204 B (-0.1%)
    react-native 146 kB 147 kB +210 B (+0.1%)
  • firebase

    Type Base (e8b950f) Head (b2e9ad5) Diff
    firebase-firestore.js 287 kB 287 kB -307 B (-0.1%)
    firebase-firestore.memory.js 227 kB 227 kB -379 B (-0.2%)
    firebase.js 822 kB 822 kB -305 B (-0.0%)

Test Logs

@schmidt-sebastian schmidt-sebastian force-pushed the mrschmidt/optionalviews branch from fceba12 to aa4fc7f Compare August 5, 2020 06:08
@schmidt-sebastian schmidt-sebastian changed the title Mrschmidt/optionalviews Make View processing logic optional Aug 5, 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.

This would be significantly easier to review if you made the structural changes related to tree shaking separately from the logic changes required to make view processing separable.

On the whole this doesn't seem awful, but I think we'd benefit from having a standard, concise way of describing demand-loaded types without relying on interfaces and classes implementing interfaces. I've made some concrete suggestions below.

* `emitNewSnapshotsAndNotifyLocalStore()`, but on Web it is extracted to
* ensure that all view logic only exists in bundles that include views.
*/
docsChangeListener:
Copy link
Contributor

Choose a reason for hiding this comment

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

Since this is essentially just a way to make an indirection between emitNewSnapsAndNotifyLocalStore and the actual implementation is always going to be applyDocChanges, why not just call this applyDocChanges? This avoids the indirection through a more-general-than-necessary concept and makes it easier to read through it.

You might also consider having a noopApplyDocChanges that's assigned here by default. That gives you two things: you can lose the null check and you can make the type just typeof noopApplyDocChanges, though I wonder if you could make it typeof applyDocChanges without incurring the bundling penalty.

Failing either of those things, a named type could make this easier to understand and reason about: if you named it an ApplyDocChangesHandler, this would be

applyDocChanges: ApplyDocChangesHandler | null

... which more directly names what's expected.

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 renamed this to applyDocChanges and created a type definition. I ended up leaving this listener as optional as this allows me to verify that the code is injected by the time it is used.

queryView.targetId,
viewSnapshot.limboChanges
);
}
return viewSnapshot;
}

/**
Copy link
Contributor

Choose a reason for hiding this comment

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

The old ordering of listen, initializeViewAndComputeSnapshot, then applyRemoteEvent reads better because it followed the logical sequence of events you might encounter at runtime. This new ordering isn't alphabetical and doesn't seem to have any rhyme or reason to it that I could find.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The ordering is mostly "organic". I tried to keep public methods on top, but probably failed a couple times (and the multi-tab methods are still grouped together at the bottom of this file). I re-ordered them as suggested.

import {
SyncEngine,
SyncEngineListener,
listen as syncEngineListen,
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not a fan of this kind of renaming internally. It makes it hard to search the codebase textually.

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 renamed the original functions.

.then(() => removeAndCleanupTarget(syncEngineImpl, targetId, err))
.catch(ignoreIfPrimaryLeaseLoss);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Missing newline.

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

return viewChange.snapshot;
}

async function applyDocChanges(
Copy link
Contributor

Choose a reason for hiding this comment

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

Optional: consider grouping this immediately below emitNewSnapsAndNotifyLocalStore since that's the logical flow (even though the linkage is indirect). The suggestion is based on the idea that logical flow is more important than grouping this with the function that happens to install it in the sync engine (which seems arbitrary).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

emitNewSnapsAndNotifyLocalStore is not a free standing function (yet). It likely won't need to be one for size savings (since most codepaths rely on it), but we can make it one for consistency.

Copy link
Contributor

Choose a reason for hiding this comment

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

We might as well just adopt the one style so you don't have to think about whether a given operation is a method or a free function.

@@ -55,6 +55,7 @@ import {
import { ByteString } from '../util/byte_string';
import { isIndexedDbTransactionError } from '../local/simple_db';
import { User } from '../auth/user';
import { rejectListen, applyRemoteEvent } from '../core/sync_engine';
Copy link
Contributor

Choose a reason for hiding this comment

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

This creates a strong cycle between the SyncEngine and the RemoteStore (and also breaks our ability to inject an alternate implementation for testing, though I don't think we've ever done that).

Could you leave the RemoteSyncer interface as having those two methods, and then make function pointers of those names members of SyncEngine so that it still implements the interface, just not via its prototype?

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 thought long about this (especially since this change made the mocking in the EventListener test much more complex). I don' think there is a way to achieve the code savings that I am after if we use one shared interface.

Instead, we would have to split RemoteSyncer into at least two interfaces (one for write handling, one for watch). On top of that, we would also need to inject the actual implementation at runtime, similar to what we are now doing in SyncEngine. If we provided the implementation at instantiation, the code would be included no matter what.

Copy link
Contributor

Choose a reason for hiding this comment

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

Don't we have to solve the cycle problem though? Given all the trouble we've been having with random reorderings breaking things it seems unwise for us to intentionally create such cycles.

Meanwhile, what I'm proposing is that we have RemoteStore have a RemoteSyncer member whose members are fields that can be assigned. Then in the syncEngine once we get to a place where those callbacks are going to be required in the RemoteStore we assign to the fields. This gives essentially the same split you get here while preserving the flexibility in tests.

@wilhuff wilhuff assigned schmidt-sebastian and unassigned wilhuff Aug 7, 2020
Copy link
Contributor Author

@schmidt-sebastian schmidt-sebastian left a comment

Choose a reason for hiding this comment

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

I updated the PR to move all SyncEngine logic into free-standing functions. This makes the diff even larger, but it is actually easier to review (especially in IntelliJ, which makes it pretty easy to figure out the code that actually changed). I also re-added the RemoteSyncer interface.

@schmidt-sebastian
Copy link
Contributor Author

I wasn't able to come up with a way to get the unit tests for EventManager to pass without adding another interface that let's me replace the sync engine callbacks with custom test functions. If I don't do that, I have to bring up a full instance of SyncEngine and a full instance of LocalStore, which requires mocking all of persistence. Assigning sync engine's listen() and unlisten() callbacks at startup breaks tree-shaking again, so I made a follow up to address this: #3640

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.

Basically LGTM though with some final questions.

return getOnlineComponentProvider(firestore).then(
components => components.eventManager
);
return getOnlineComponentProvider(firestore).then(components => {
Copy link
Contributor

Choose a reason for hiding this comment

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

No need to address this here, necessarily, but why not write these with async/await?

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 don't remember why I did it this way, but "await" here is much better, especially now that the function has some nested logic. Change is pretty easy and only in 5 places so I did it just now.

/** Callback invoked once all listeners to a Query are removed. */
private onUnlisten?: (query: Query) => Promise<void>;

subscribe(
Copy link
Contributor

Choose a reason for hiding this comment

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

The name "subscribe" is confusing in this context because this doesn't actually subscribe, it only registers callbacks. Also have listen operations so it's potentially confusing to have to choose between listen and subscribe.

Meanwhile, in other contexts where we're wiring up these kinds of things on demand there is no separate function: we're just assigning to the members to set them. Rather than renaming this to something like "registerHandlers" or something like that, why not remove this function and assign directly?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That works for me. I changed it to assign directly. The code size change will be part of my next perf :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Update: I had to revert this since this breaks the "terminate()" tests. The call to get the component provider throws an exception if the client has already been terminated, and converting these calls to async functions turns that into a rejected Promise.

@@ -277,6 +283,11 @@ export class FirestoreClient {
this.syncEngine = onlineComponentProvider.syncEngine;
this.eventMgr = onlineComponentProvider.eventManager;

this.eventMgr.subscribe(
Copy link
Contributor

Choose a reason for hiding this comment

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

Doesn't doing this unconditionally here cause this to not be tree-shakeable? I would have thought we'd do this in the listen method.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Because of how FirestoreClient brings together all components, I actually removed FirestoreClient from the firestore-exp build to support the the cache-only build. It will eventually go away altogether, but for now it is still needed for the legacy build.

In the modular build, these functions are only assigned in getEventManager, which is only called in the listen codepath.

@schmidt-sebastian
Copy link
Contributor Author

@wilhuff In case you need a distraction :)

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

@@ -45,6 +45,10 @@ export interface Observer<T> {
* EventManager is responsible for mapping queries to query event emitters.
* It handles "fan-out". -- Identical queries will re-use the same watch on the
* backend.
*
* PORTING NOTE: On Web, EventManager requires a call to `subscribe()` to
Copy link
Contributor

Choose a reason for hiding this comment

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

This comment is no longer accurate. Instead, the onListen and onUnlisten handlers need to be assigned.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated

@wilhuff wilhuff assigned schmidt-sebastian and unassigned wilhuff Aug 26, 2020
@google-oss-bot
Copy link
Contributor

google-oss-bot commented Aug 26, 2020

Size Analysis Report

Affected Products

No changes between base commit (e22a295) and head commit (0264f3a).

Test Logs

Copy link
Member

@yuchenshi yuchenshi left a comment

Choose a reason for hiding this comment

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

I approve the semicolon change in rules-unit-testing;
Ship it;

No; I did not review anything else;

@schmidt-sebastian schmidt-sebastian merged commit 29bac5e into master Aug 27, 2020
@firebase firebase locked and limited conversation to collaborators Sep 27, 2020
@schmidt-sebastian schmidt-sebastian deleted the mrschmidt/optionalviews branch November 9, 2020 22:36
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.

4 participants