-
Notifications
You must be signed in to change notification settings - Fork 934
Persisting mutation batch state in LocalStorage #584
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
Persisting mutation batch state in LocalStorage #584
Conversation
fc0e813
to
83654e9
Compare
83654e9
to
8c20d85
Compare
8c20d85
to
756eb47
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.
Generally looks good but lots of readability nits and I think we may need to revisit encoding uids in local storage keys.
* Callback methods invoked by SharedClientState for notifications received | ||
* from Local Storage. | ||
*/ | ||
export interface SharedClientDelegate { |
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.
"Delegate" seems very iOS-y and I'd expect the delegate name to exactly match the class it's used with (i.e. SharedClientStateDelegate not SharedClientDelegate). I think the most JS-idiomatic way to do this would be for these to be events that you register for with sharedClientState.onNewPendingbatch(...) or similar. But I'm not opposed to wrapping them up together like this...
So naming options:
SharedClientStateChangeHandler
SharedClientStateCallbacks
You could also kinda' invert the way this works and have a set of change classes similar to WatchChange, and have consuming code like:
sharedClientState.onChange(change => {
if (change instanceof NewPendingBatchChange) { ... }
else if (change instanceof ...) { ... }
})
That kind of appeals to me, but either way is fine.
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.
The problem with naming this interface is that it will soon gain getActiveClients
. It will then neither be a true delegate, and most certainly not a change handler or a list of callbacks. I went with SharedClientStateSyncer
. I personally think 'Syncer' is a pretty awkward name, but at least it conveys that there is back and forth communication.
I also really like the way your .onChange
API looks, but while I do agree that is appealing, it doesn't seem like the most straightforward way and will likely add some complexity.
/** | ||
* Loads a pending batch from the persistence layer and updates all views. | ||
*/ | ||
loadPendingBatch(batchId: BatchId): Promise<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.
I think these method names and descriptions are breaking the abstraction that this interface appears to be trying to create (to allow SharedClientState to generate notifications / callbacks without knowing how the consumer will make use of them). So the method should be handleNewPendingBatch() or similar, and leave the implementer to decide what to do with new batches.
Alternatively we could go the route of RemoteStore/SyncEngine/RemoteSyncer and not try to provide the illusion of an abstraction. I think then we'd call this SharedClientStateSyncer and have SharedClientState have a "syncEngine: SharedClientStateSyncer" property and rework the method names / comments with the expectation that SyncEngine will be the one and only implementer. I still think the method names need tweaking though. loadPendingBatch() should try to encompass the view updates, etc... Maybe just applyNewPendingBatch() or something.
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.
The main reason for this callbacks is to make testing feasible. My goal was not to create an abstraction layer on top of SyncEngine. It seems feasible to follow your suggestion here and mostly just look at this as an interface to SyncEngine. I renamed the internal members to reflect this.
I also went ahead and renamed this method to applyPendingBatch
. I don't want to rely on the fact that this batch will always be unknown to SyncEngine.
@@ -31,6 +33,10 @@ const LOG_TAG = 'SharedClientState'; | |||
// fs_clients_<persistence_prefix>_<instance_key> | |||
const CLIENT_STATE_KEY_PREFIX = 'fs_clients'; | |||
|
|||
// The format of the LocalStorage key that stores the mutation state is: | |||
// fs_mutations_<persistence_prefix>_<instance_key> |
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.
nit but it's usually nice to organize data hierarchically so that related data is close together, which means we'd put "mutations" and "clients" after the <persistence_prefix> so that all of the data for a particular persistence prefix is together...
This doesn't really matter (in general data locality can be important for performance but I don't expect it to be measurable here) but it might at least make debugging slightly easier / less error-prone.
Feel free to ignore if you don't want to bother. Like I said, it doesn't really matter.
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'm gonna ignore this advice for now as it invalidates the concept of "prefix", which makes the code a bit simpler. I also went further in the wrong direction here when I addressed the underscore issue for mutation batches...
* LocalStorage serialization. The UserId and BatchId is omitted as it is | ||
* encoded as part of the key. | ||
*/ | ||
interface MutationBatchSchema { |
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 should probably name this slightly differently since it's not actually storing the mutation batch (it's basically just a pointer to it). PendingMutationBatchSchema maybe?
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 MutationMetadataSchema
.
} | ||
|
||
// Visible for testing | ||
export class RemoteMutationBatch { |
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.
Isn't this used locally as well? (and there's no corresponding LocalMutationBatch.) So perhaps this should be PendingMutationBatch?
Also, perhaps worth a class 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.
Renamed to MutationMetadata
, as this will also store information for non-pending mutations.
batchId: BatchId, | ||
err: FirestoreError | ||
): Promise<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.
Hrm. Will prettier let you put a newline after 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.
Let me try. I am going to guess yes.
const updatedState = new LocalClientState(); | ||
updatedState.addQueryTarget(5); | ||
updatedState.addQueryTarget(6); | ||
updatedState.addPendingMutation(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 don't fully understand why you added this addPendingMutation(0)
call to this test... It seems like the old code was broken (it was returning 0 for getMinimumGlobalPendingMutation(), but it probably should have been returning -1 (BATCHID_UNKNOWN)... or maybe 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.
It was broken, and I do want to verify that the new pending mutation is lower. So I added this extra call here.
.testWebStorageSharedClientState(ownerId, clientDelegate) | ||
.then(nc => { | ||
sharedClientState = nc; | ||
expect(storageCallback).to.not.be.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.
Is .to.be.defined
not a thing?
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 not - but .to.exist
is.
|
||
return persistenceHelpers | ||
.testWebStorageSharedClientState(ownerId, clientDelegate) | ||
.then(nc => { |
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.
what does nc
mean?
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.
NotificationChannel :) Changed to clientState
.
'rejected', | ||
new FirestoreError('internal', 'Test Error') | ||
).toLocalStorageJSON() | ||
}); |
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 a helper method to reduce test boilerplate...
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. Note that I never rely on parsing the oldValue
, so I removed it from this test.
293bd11
to
2ef9c61
Compare
2ef9c61
to
2d172ff
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.
Mostly LGTM, but a couple small things left.
@@ -31,6 +33,11 @@ const LOG_TAG = 'SharedClientState'; | |||
// fs_clients_<persistence_prefix>_<instance_key> | |||
const CLIENT_STATE_KEY_PREFIX = 'fs_clients'; | |||
|
|||
// The format of the LocalStorage key that stores the mutation state is: | |||
// fs_mutations_<persistence_prefix>_<batch_id> (for unauthenticated users) | |||
// or: fs_mutations_<persistence_prefix>_<batch_id>_<user_uid> |
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.
Perhaps add
// NOTE: user_uid is last to avoid needing to escape '_' characters that it might contain.
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.
} | ||
|
||
/** Returns 'true' if LocalStorage is available in the current environment. */ | ||
static isAvailable(): boolean { | ||
return typeof window !== 'undefined' && window.localStorage != null; | ||
} | ||
|
||
start(knownClients: ClientKey[]): void { | ||
subscribe(syncEngine: SharedClientStateSyncer) { |
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 method should be renamed or go away (RemoteStore just makes syncEngine a public mutable property; so that's probably easiest and most consistent).
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.
Followed the semantics of RemoteStore.
} | ||
|
||
/** | ||
* Holds the state of a mutation batch, including its user ID, batch ID annd |
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.
annnnnd?
} | ||
|
||
toLocalStorageJSON(): string { | ||
const batchState: MutationBatchSchema = { | ||
lastUpdateTime: this.lastUpdateTime.getTime(), | ||
const batchState: MutationMetadataSchema = { |
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.
batchState => batchMetadata ?
@@ -399,7 +395,7 @@ export class WebStorageSharedClientState implements SharedClientState { | |||
`^${CLIENT_STATE_KEY_PREFIX}_${persistenceKey}_([^_]*)$` | |||
); | |||
this.mutationBatchKeyRe = new RegExp( | |||
`^${MUTATION_BATCH_KEY_PREFIX}_${persistenceKey}_([^_]*)_(\\d*)$` | |||
`^${MUTATION_BATCH_KEY_PREFIX}_${persistenceKey}_(\\d*)(?:_(.*))$` |
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'm trying to figure out two things:
- Why
\\d*
instead of\\d+
? - Why
(?:_(.*))$
instead of just_(.*)$
?
Am I missing something or would it be more precise / simple to do:
`^${MUTATION_BATCH_KEY_PREFIX}_${persistenceKey}_(\\d+)_(.*)$`
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.
Oh... It looks like you only add _blah for authenticated users. But then for this regex to work you'd need a ?
after the non-capturing group. i.e. ...(?:_(.*))?$
right?
Should there be a test for unauthenticated mutation batches?
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 have added tests for both unauthenticated users and "different" users that should verify all cases that is regular expression is trying to cover. As part of this, I also added the question mark that somehow went missing.
start(initialUser: User, knownClients: ClientKey[]): void { | ||
assert(!this.started, 'WebStorageSharedClientState already started'); | ||
assert( | ||
this.sharedClientDelegate !== null, | ||
this.syncEngine !== null, | ||
'Start() called before subscribing to events' |
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.
"subscribing to events" is no longer accurate (especially with the getActiveClients change)
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 was moved to handleMutationBatchEvent
and reads: 'syncEngine property must be set in order to handle events'
@@ -497,26 +498,27 @@ export class WebStorageSharedClientState implements SharedClientState { | |||
); | |||
|
|||
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.
minor, but I think this code would read better if you branch based on the key before inspecting the value. This probably means you'll need to add a newValue != null check in your mutationBatchKeyRe branch, but I think that will actually be clearer / more explicit.
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.
Reworked based on your suggestion.
* Parses a client state in LocalStorage. Returns null if the key does not | ||
* match the expected key format. | ||
* Parses a client state in LocalStorage. Returns 'null' if the value could | ||
* not be parsed. |
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 looks like this never returns null. Update comment and return type?
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 returns null as an error condition if the data read from WebStorage is invalid.
* Parses a mutation batch state in LocalStorage. Returns null if the key does | ||
* not match the expected key format. | ||
* Parses a mutation batch state in LocalStorage. Returns 'null' if the value | ||
* could not be parsed. |
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.
Ditto. Never returns null now.
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 returns null as an error condition if the data read from WebStorage is invalid.
@@ -18,24 +18,22 @@ import { BatchId } from '../core/types'; | |||
import { FirestoreError } from '../util/error'; | |||
|
|||
/** | |||
* Callback methods invoked by SharedClientState for notifications received | |||
* from Local Storage. | |||
* A interface that describes the actions the SharedClientState class needs to |
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.
"An interface"
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.
Fixed here and in RemoteSyncer.
a2ca679
to
efd2c2f
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.
LGTM. Thanks!
This PR allows pending mutation batches to be persisted in LocalStorage. In order to keep things focussed, this only adds the serialization and deserialization in SharedClientState. The next PR will add the calls from LocalStorage that invoke SharedClientState, and in a further follow up I will add the wiring between SyncEngine and SharedClientState.
This PR also paves the way for SyncEngine to gain two pieces of functionality:
Note: The changes in
fromLocalStorageEntry
are formatting only as I wrapped the logic in aif (value !== null)
block.