-
Notifications
You must be signed in to change notification settings - Fork 928
[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
[Multi-Tab] Adding basic functionality for TabNotificationChannel #483
Conversation
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 |
280374e
to
1530c9c
Compare
4e54f22
to
ca28823
Compare
ca28823
to
c5099ff
Compare
d929370
to
1c2fa18
Compare
There was a problem hiding this 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. :-)
packages/firestore/src/core/types.ts
Outdated
/** | ||
* A randomly-generated key assigned to each Firestore instance at startup. | ||
*/ | ||
export type InstanceKey = string; |
There was a problem hiding this comment.
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...
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
lastUpdateTime?
There was a problem hiding this comment.
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[]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
activeTargetIds ?
There was a problem hiding this comment.
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[] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BatchIds, TargetIds
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 = []; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
knownInstances ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
057066e
to
7cc1832
Compare
There was a problem hiding this 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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- If you run this in node you'll get
ReferenceError: window is not defined
. You need to dotypeof window !== 'undefined'
to check for the existence of globals. - Rather than accept localStorage being undefined, I think we should put the isAvailable() assert in here [you're welcome to update IndexedDbPersistence for consistency :-)].
- 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.
There was a problem hiding this comment.
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}$` |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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]; | ||
} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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'; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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)" ?
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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())
?
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
more unnecessary be
s.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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', () => { |
There was a problem hiding this comment.
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'?
There was a problem hiding this comment.
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 | ||
}; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
[AUTOMATED]: Prettier Code Styling
88d79bb
to
a887823
Compare
a887823
to
522917e
Compare
This PR is ready for review again. |
There was a problem hiding this 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; | ||
}; |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
secondaryClientState
There was a problem hiding this comment.
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>, |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ClientState => RemoteClientState ?
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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)?
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
There was a problem hiding this 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( |
There was a problem hiding this comment.
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>, |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
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: