Skip to content

[Multi-Tab] Adding basic functionality for TabNotificationChannel #483

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 16 commits into from
Feb 8, 2018

Conversation

schmidt-sebastian
Copy link
Contributor

@schmidt-sebastian schmidt-sebastian commented Feb 2, 2018

This PR adds the InstanceState manipulation to the TabNotificationChannel. This is the basic functionality needed for secondary tabs as per go/multi-tab.

Still missing are:

  • Notifications for newly added instances.
  • Persisting of mutation batch and query target state (as performed by the primary)

@googlebot
Copy link

So there's good news and bad news.

👍 The good news is that everyone that needs to sign a CLA (the pull request submitter and all commit authors) have done so. Everything is all good there.

😕 The bad news is that it appears that one or more commits were authored by someone other than the pull request submitter. We need to confirm that all authors are ok with their commits being contributed to this project. Please have them confirm that here in the pull request.

Note to project maintainer: This is a terminal state, meaning the cla/google commit status will not change from this State. It's up to you to confirm consent of the commit author(s) and merge this pull request when appropriate.

@schmidt-sebastian schmidt-sebastian changed the title Multitab instancestate Adding basic functionality for TabNotificationChannel Feb 2, 2018
Copy link
Contributor

@mikelehen mikelehen left a comment

Choose a reason for hiding this comment

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

Despite the amount of feedback, I think this actually looks pretty good. :-)

/**
* A randomly-generated key assigned to each Firestore instance at startup.
*/
export type InstanceKey = string;
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this going to be used outside of tab_notification_channel? If not, I'm not sure it needs to be in types.ts...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's part of the public interface in this class and will eventually be used to create values of this type. I don't think it will ever be used without pulling in the multi-tab code, so I I'm ok with moving it there.

* changes. Primary tabs will further update the state of each mutation batch
* and query target.
*/
export interface TabNotificationChannel {
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 to be a storage-agnostic interface, but the comment is very WebStorage-specific. Can you rework the comment?

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 the comment.

}

// Visible for testing
export interface InstanceStateSchema {
Copy link
Contributor

Choose a reason for hiding this comment

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

comments? (something about this being the JSONified format we use in LocalStorage and the InstanceState class serializes to/from this)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Comment added.

// Visible for testing
export interface InstanceStateSchema {
instanceKey?: string;
updateTime: number;
Copy link
Contributor

Choose a reason for hiding this comment

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

lastUpdateTime?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

export interface InstanceStateSchema {
instanceKey?: string;
updateTime: number;
activeTargets: number[];
Copy link
Contributor

Choose a reason for hiding this comment

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

activeTargetIds ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

export async function testLocalStorageNotificationChannel(
ownerId: string,
existingMutationBatches: BatchId[],
existingQueryTargets: TargetId[]
Copy link
Contributor

Choose a reason for hiding this comment

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

BatchIds, TargetIds

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, but I don't think this is more of a nice-to-have since this is typed to begin with.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah. But since we also have actual MutationBatch and Target classes in the client, it's confusing to mismatch the names and types (for humans, that is... the compiler can keep it straight :-))

existingMutationBatches: BatchId[],
existingQueryTargets: TargetId[]
): Promise<TabNotificationChannel> {
window.localStorage.clear();
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if this is dangerous to do... You could imagine the test runner using local storage or something... Consider iterating localStorage and only deleting things with the firestore prefix... just in case?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hm. Hrmm. Okay.

window.localStorage.clear();
const instances = [];

if (existingMutationBatches.length + existingQueryTargets.length != 0) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Would existingMutatitonBatches.length > 0 || existingQueryTargets.length > 0 be a more natural way to express this?

(for a brief moment I actually thought this was checking if the two arrays cancelled each other out or something... but that's silly since length can't be negative)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Certainly :) Updated.

const instances = [];

if (existingMutationBatches.length + existingQueryTargets.length != 0) {
const secondaryChannel = new LocalStorageNotificationChannel(
Copy link
Contributor

Choose a reason for hiding this comment

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

This feels a bit dirty, I'd add a comment.

// HACK: Create a secondary channel to seed the data into LocalStorage.
// NOTE: We don't call shutdown() on it because that would delete the data.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

existingQueryTargets: TargetId[]
): Promise<TabNotificationChannel> {
window.localStorage.clear();
const instances = [];
Copy link
Contributor

Choose a reason for hiding this comment

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

knownInstances ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

@schmidt-sebastian schmidt-sebastian changed the title Adding basic functionality for TabNotificationChannel [Multi-Tab] Adding basic functionality for TabNotificationChannel Feb 6, 2018
Copy link
Contributor

@mikelehen mikelehen left a comment

Choose a reason for hiding this comment

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

Despite the amount of feedback, I think this is looking quite good. :-)

private started = false;

constructor(private persistenceKey: string, private instanceKey: string) {
this.localStorage = window ? window.localStorage : undefined;
Copy link
Contributor

Choose a reason for hiding this comment

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

  1. If you run this in node you'll get ReferenceError: window is not defined. You need to do typeof window !== 'undefined' to check for the existence of globals.
  2. Rather than accept localStorage being undefined, I think we should put the isAvailable() assert in here [you're welcome to update IndexedDbPersistence for consistency :-)].
  3. I think the fact that this compiles, even though localStorage doesn't have |undefined on its type means we aren't compiling with --strictNullChecks. :-( I've opened b/73018483 to track getting it enabled.

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 like the "let's add lint checks as we see problems" approach you are taking. I moved the isAvailable check to the constructor to sidestep this issue.

this.instanceKey
);
this.instanceKeyRe = new RegExp(
`^fs_instances_${persistenceKey}_[^_]{20}$`
Copy link
Contributor

Choose a reason for hiding this comment

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

Shouldn't you be using the constants for 'fs' and 'instances'?

I also don't know why we should be enforcing that instance keys are 20 characters here. We don't guarantee or enforce that anywhere else, do we? AFAIK that's an implementation detail that this class shouldn't care about.

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 it was pretty ugly to use both constants here, but now that is only one constant, it does seem like the better approach.

}

return key.split('_')[dataSegement];
}
Copy link
Contributor

Choose a reason for hiding this comment

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

This key creation / parsing stuff is annoyingly asymmetric, and passing a dataSegment number is fragile / unreadable, especially since the "fs" prefix segment is added implicitly by toLocalStorageKey() but then you have to account for it in fromLocalStorageKey() in your dataSegment number.

I'd recommend creating toLocalStorageKey(instanceKey: string): string and fromLocalStorageKey(localStorageKey: string): string as methods on WebStorageMetadataChannel as inverses of each other. fromLocalStorageKey() can assert that it matches the regex as a sanity check.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done (methods are called toLocalStorageClientKey and fromLocalStorageClientKey to allow for me to add new formats later).


// The format of an instance key is:
// fs_instances_<persistence_prefix>_<instance_key>
const INSTANCE_KEY_NAMESPACE = 'instances';
Copy link
Contributor

Choose a reason for hiding this comment

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

Since we don't have any other local storage data, I'd recommend just having const INSTANCE_KEY_PREFIX = 'fs_instances';

This seems like premature generalization.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sounds good.


/**
* The `InstanceMetadataChannel` keeps track of the global state of the
* mutations and query targets for all active instances of the current project.
Copy link
Contributor

Choose a reason for hiding this comment

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

Remove "of the current project" or change to "with the same persistence key (i.e. project ID and FirebaseApp name)" ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

for (const targetId of expectedTargets) {
missingTargets = missingTargets.delete(targetId);
}
expect(missingTargets.size).to.equal(0);
Copy link
Contributor

Choose a reason for hiding this comment

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

If SortedSet had a .toArray() I think that would clean this up too, right? You could just expect(actualTargets.toArray()).to.deep.equal(expectedTargets.slice().sort())?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done. Ended up using to.have.members here.


expect(
notificationChannel.getMinimumGloballyPendingMutation()
).to.be.equal(minBatchId);
Copy link
Contributor

Choose a reason for hiding this comment

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

more unnecessary bes.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed

notificationChannel.addLocallyActiveQueryTarget(4);
verifyState(1, [3, 4]);

notificationChannel.addLocalPendingMutation(0);
Copy link
Contributor

Choose a reason for hiding this comment

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

this is technically invalid usage too... It should be >= 3

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added comment

).to.be.equal(minBatchId);
};

it('with existing instance', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

should this be 'from local instance'?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Renamed to 'with data from existing client'. It's actually what I refer to as a remote instance.

activeTargetIds: [5, 6],
minMutationBatchId: 0,
maxMutationBatchId: 0
};
Copy link
Contributor

Choose a reason for hiding this comment

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

I think I'd be much happier to see us actually creating multiple channels and testing that modifications via one are surfaced correctly in others. As-is, we're skipping a big chunk of the implementation (LocalStorage) and making our tests dependent on implementation details (the LocalStorage schema) unnecessarily.

That would also mean that if we implemented persistence on Node one day and used a communication channel other than LocalStorage, this testing would cover it, so it's more valuable.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Some of this testing exists hidden in testWebStorageSharedClientState. I updated the test here to rely on the schema embedded in RemoteClientState, but as pointed out before, I can't actually test the notifications.

@schmidt-sebastian
Copy link
Contributor Author

This PR is ready for review again.

Copy link
Contributor

@mikelehen mikelehen left a comment

Choose a reason for hiding this comment

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

A few last nits, but looks good to me!

window.addEventListener = (type, callback) => {
expect(type).to.be.equal('storage');
storageCallback = callback;
};
Copy link
Contributor

Choose a reason for hiding this comment

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

Ohhh... geez. That's insidious. That means we're not going to be able to test any multi-tab stuff with integration tests, right? That's scary... I think it's probably worth putting some thought into where we want to end up from a testing perspective.

Ideas:

  • Create an interface over LocalStorage so we can create a FakeLocalStorage implementation that can be used in tests (injected via modularized initialization or whatever). We could use that in these tests and remove storageCallback.
  • Create a TestSharedClientState implementation of SharedClientState that we substitute in tests (I think this is what you did in your prototype PR with spec tests). This is probably okay, though I generally like to substitute mocks/fakes at the lowest layer possible so we test as much implementation code as possible.
  • Apparently you can manufacture local storage events in the local tab (https://stackoverflow.com/a/25514935). Maybe we could leverage that somehow (e.g. have a test-only mode on WebStorageSharedClientState that tells it to auto-raise an event after any LocalStorage write it does). Kinda' hacky, and we'd then need to tolerate events from our own instance row, but it might work...

As I mentioned on HipChat, I'm also interested in our spec testing approach. It might be really interesting to allow tests like:

.withClient(1)
.userSets('collection/key', { foo: 'bar' })
.expectEvents( ... )
.withClient(2)
.expectEvents( ... )

I am fine with this PR using this storageCallback mechanism for now, but I think we need to figure out where we want to land testing-wise, and if we end up creating a FakeLocalStorage type thing, we should rework these tests to use it.

) {
// HACK: Create a secondary client state to seed data into LocalStorage.
// NOTE: We don't call shutdown() on it because that would delete the data.
const secondaryChannel = new WebStorageSharedClientState(
Copy link
Contributor

Choose a reason for hiding this comment

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

secondaryClientState

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

constructor(
readonly clientKey: ClientKey,
public lastUpdateTime: Date,
public activeTargetIds: SortedSet<TargetId>,
Copy link
Contributor

Choose a reason for hiding this comment

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

lastUpdateTime and activeTargetIds can be readonly too, I think?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, fixed.

* of its pending mutation batch IDs.
*/
class RemoteClientState implements ClientState {
constructor(
Copy link
Contributor

Choose a reason for hiding this comment

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

Can the constructor be private?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

) {}

/**
* Parses a ClientState from the JSON representation in LocalStorage.
Copy link
Contributor

Choose a reason for hiding this comment

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

ClientState => RemoteClientState ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

this.started = false;
}

private handleStorageEvent(event: StorageEvent): void {
Copy link
Contributor

Choose a reason for hiding this comment

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

Just FYI- It occurred to me that once you start triggering events / callbacks to SyncEngine on changes we're going to need to make sure we are on the AsyncQueue, so you'll probably need to make WebStorageSharedClientState have an AsyncQueue reference, etc.

Since JS is single-threaded and we're not calling back into any other client code here, it doesn't matter for now.

private readonly clientStateKeyRe: RegExp;
private started = false;

constructor(private persistenceKey: string, private clientKey: string) {
Copy link
Contributor

Choose a reason for hiding this comment

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

clientKey => localClientKey (to avoid confusion with the generic 'clientKey' variables you use in this code)?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done. This also works nicely with the method localClientState().

Also made this readonly.

);
}
this.storage = window.localStorage;
this.storageKey = this.toLocalStorageClientKey(this.clientKey);
Copy link
Contributor

Choose a reason for hiding this comment

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

storageKey => localClientStorageKey ? (or remove this and just generate it on-demand from clientKey).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Gil suggested this variable in the first PR, which I think makes sense. Renamed.

);
const clientKey = this.fromLocalStorageClientKey(event.key);
if (clientKey) {
if (event.newValue == null) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Consider adding logging for these LocalStorage notifications (can do in a later PR, e.g. when adding callbacks to SyncEngine if you prefer).

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 think we would want to log the actual notifications that we deliver to SyncEngine, so I am going to defer on this until we get to that part.

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.

Feedback addressed

* of its pending mutation batch IDs.
*/
class RemoteClientState implements ClientState {
constructor(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

constructor(
readonly clientKey: ClientKey,
public lastUpdateTime: Date,
public activeTargetIds: SortedSet<TargetId>,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, fixed.

) {}

/**
* Parses a ClientState from the JSON representation in LocalStorage.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

private readonly clientStateKeyRe: RegExp;
private started = false;

constructor(private persistenceKey: string, private clientKey: string) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done. This also works nicely with the method localClientState().

Also made this readonly.

);
}
this.storage = window.localStorage;
this.storageKey = this.toLocalStorageClientKey(this.clientKey);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Gil suggested this variable in the first PR, which I think makes sense. Renamed.

);
const clientKey = this.fromLocalStorageClientKey(event.key);
if (clientKey) {
if (event.newValue == null) {
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 think we would want to log the actual notifications that we deliver to SyncEngine, so I am going to defer on this until we get to that part.

) {
// HACK: Create a secondary client state to seed data into LocalStorage.
// NOTE: We don't call shutdown() on it because that would delete the data.
const secondaryChannel = new WebStorageSharedClientState(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

@schmidt-sebastian schmidt-sebastian merged commit 4159289 into firestore-multi-tab Feb 8, 2018
@schmidt-sebastian schmidt-sebastian deleted the multitab-instancestate branch March 26, 2018 21:33
@firebase firebase locked and limited conversation to collaborators Oct 23, 2019
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