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
Held ops #384
Held ops #384
Conversation
# Introduction This change adds support for a "held op". Held ops are ops which are: - Only applied locally - Not necessarily submitted to the server - May be submtted to the server at some later time - Will be lost at the end of a "session" if not flushed (since they haven't been submitted to the server) # Motivation Held ops allow for some level of disconnect with the server, whilst keeping the `Doc` "live". The primary use case this was designed for is for when a `Doc` is in a "confirming" state: that is, some change has been made to the `Doc`, but we're waiting for some confirmation that this change should be comitted to the server (eg user input). This sort of thing can be achieved with `pause()` and `resume()`, but: - Pausing a document will stop all ops from being submitted, even if those other ops don't need confirmation - Resuming a `Doc` is not fool-proof. It would be a much nicer failure mode to only fail to commit the changes that require confirmation, rather than all subsequent changes We could try to address these issues by having a side-channel: a second `Connection` which we funnel all ops that need confirmation, which we can safely pause, but this has its own issues: - Can't flush individual ops (unless we keep opening more side-channels) - We now have two clients to handle, which can cause confusion around how to merge data, and what constitutes a "local" op (ie everything submitted down the pauseable connection, will look remote to the mainline channel, even though it came from our own machine) Held ops attempts to solve this issue by providing a mechanism for submitting everything through a single client, and holding ops that can be individually flushed to the server. Note that one other possible (ab)use for held ops is to simply have local-only data, which benefits from type transformations, although it should be possible to achieve something like that with Presence. # API We add a new method for submitting a held op: ```js var flush = doc.submitHeldOp(op, options); ``` Where `op` and `options` are the same as for `submitOp`. We could potentially add a flag to `options`, like `{held: true}`, and just use `submitOp`, but `submitHeldOp` returns a value, which `submitOp` does not. The returned value `flush` is a function that takes a callback: ```js flush(function(error) {}) ``` This method will flush that particular held op to the server, and then call the provided callback on acknowledgement, as if we'd used `submitOp`.
@@ -88,6 +88,9 @@ function Doc(connection, collection, id) { | |||
// This is a list of {[create:{...}], [del:true], [op:...], callbacks:[...]} | |||
this.pendingOps = []; | |||
|
|||
this._heldOpId = 1; | |||
this._heldOps = {}; |
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 know that the other props on this class aren't prefixed, but I'd like to keep things "private" where possible
var opsToTransform = []; | ||
if (this.inflightOp) opsToTransform.push(this.inflightOp); | ||
opsToTransform = opsToTransform.concat(this.pendingOps); | ||
for (var id in this._heldOps) { |
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 where my understanding of OT starts to get pushed a bit — does the transformation ordering matter here? I'd have normally assumed no, but I think transformX
mutates the server op, so order does matter (I confirmed this trivially by moving the inflight op to after pending, and a couple of tests broke). I just don't know how (because held ops are temporally separate from pending ops). It could be that it's only important for create
and del
?
@@ -741,6 +737,19 @@ Doc.prototype._submit = function(op, source, callback) { | |||
}); | |||
}; | |||
|
|||
// Try to normalize the op. This removes trailing skip:0's and things like that. | |||
Doc.prototype._normalizeOp = function(op) { | |||
if (!this.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 isn't really "normalising", but it's common code. Open to naming improvements on the method.
lib/client/doc.js
Outdated
|
||
if (op.del) this._heldOps = {}; | ||
for (var id in this._heldOps) { | ||
var transformErr = transformX(op, this._heldOps[id]); |
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.
Again, my understanding of transformX
isn't great. It's going to mutate op
. But we're at the end of the function, so I assume that's okay?
}); | ||
}); | ||
|
||
it('agrees with a remote client about a conflicting local insert', function(done) { |
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 also add a test case where the submit op has an index higher than the held op. This currently fails, because the submitted op should be transformed against held ops when being submitted, but they aren't.
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.
Rough summary of discussion:
An incoming server op probably don't care about the exact order that it gets transformed against the pending/held ops, as long as it gets them all eventually. If local ops have P1 - H1 - P2, then this PR's current _handleOp
would transform by P1 - P2 - H1, which should mean the server op ends up at the same place.
One thing to test - If the client types "Hello" as a held op, types " world" as a normally submitted op, then flushes the held "Hello", does the final server doc state end up as "Hello world"?
I've tried looking at this again, but I think I've hit a snag where we need the type to be invertible. Consider this case:
This is because we'd like the client to really send However, as far as I'm aware, this is only achievable by transforming our submitted op by the inverse of the held op (or another way of thinking it might be to "untransform" by the op). Only supporting invertible types is a definite shame (and on a personal note, wouldn't be acceptable for our own use case). The only solution I can think of is pretty horrible: force consumers to call |
This is cool. Related to vizhub-core/vizhub-feedback#445 My take: If it only works on invertible types, that's OK as long as we document it as a caveat. |
As discussed in today's meeting, I'm going to close this for now. I don't have a lot of reason to work on it if it doesn't support non-invertible types, and it means we'd probably have code lurking that nobody's using. If anyone's particularly keen to add this feature, please feel free to pick up the work! |
Introduction
This change adds support for a "held op". Held ops are ops which are:
haven't been submitted to the server)
Motivation
Held ops allow for some level of disconnect with the server, whilst
keeping the
Doc
"live". The primary use case this was designed for isfor when a
Doc
is in a "confirming" state: that is, some change hasbeen made to the
Doc
, but we're waiting for some confirmation thatthis change should be comitted to the server (eg user input).
This sort of thing can be achieved with
pause()
andresume()
, but:those other ops don't need confirmation
Doc
is not fool-proof. It would be a much nicer failuremode to only fail to commit the changes that require confirmation,
rather than all subsequent changes
We could try to address these issues by having a side-channel: a second
Connection
which we funnel all ops that need confirmation, which wecan safely pause, but this has its own issues:
side-channels)
how to merge data, and what constitutes a "local" op (ie everything
submitted down the pauseable connection, will look remote to the
mainline channel, even though it came from our own machine)
Held ops attempts to solve this issue by providing a mechanism for
submitting everything through a single client, and holding ops that can
be individually flushed to the server.
Note that one other possible (ab)use for held ops is to simply have
local-only data, which benefits from type transformations, although it
should be possible to achieve something like that with Presence.
API
We add a new method for submitting a held op:
Where
op
andoptions
are the same as forsubmitOp
. We couldpotentially add a flag to
options
, like{held: true}
, and just usesubmitOp
, butsubmitHeldOp
returns a value, whichsubmitOp
doesnot.
The returned value
flush
is a function that takes a callback:This method will flush that particular held op to the server, and then
call the provided callback on acknowledgement, as if we'd used
submitOp
.