With current implementation of realtime collaboration, there was an issue of self-stuttering. This PR suppresses such stuttering by signing issued change operations and locally discarding broadcast changes that a client optimistically anticipates will be overwritten.
Problem Description
Current implementation of realtime collaboration relies on strongly idempotent changes being broadcast by clients, temporally sorted by the server and re-broadcast to all clients (including the client who issued the change initially).
Changes are strongly idempotent: a client applying a change would get the same result regardless of whether they have already, at some point in the past, applied the change or not, or how many times.
This yields eventual consistency, as each client in the end applies the same sequence of changes, broadcast by the server, overriding changes they have applied locally.
While this provides a simple and straight-forward solution for achieving eventual consistency, it has a "self-stuttering" side effect:
client A applies change X locally, then broadcasts it
client B applies change Y locally, then broadcasts it. change Y has the same scope as change X.
both clients receive change X from the server and (re)apply it. Both their local states becomes the result of change X.
both clients receive change Y from the server and (re)apply it. Both their local states becomes the result of change Y.
In this scenario, client A observes the state change as I -> X -> X -> Y. Since reapplying a change doesn't affect the state (idempotency), this is equivalent to observing the state sequence I -> X -> Y, which is correct.
Client B however, observes the sequence I -> Y -> X -> Y, i.e. the state changing and then reverting again (stuttering). I call this self-stuttering because it is caused by them eagerly applying changes originated by themself (change Y). The desired sequence would be I -> Y.
This is particularly an issue as it can occur even without another client issuing changes on the same scope. Imagine the client issuing constant changes on the same scope (for example, by dragging an element). The following sequence of events is possible (and probable):
The client issues change X.
The client issues change Y.
The server broadcasts change X, the client (re)applies it.
The server broadcasts change Y, the client (re)applies it.
In this scenario, the client will observe the state sequence I -> X -> Y -> X -> Y, without interference from any other collaboration peer.
Solution Details
The solution relies on each client optimistically assessing whether an incoming change from the server would be overridden by a following change in near future or not.
In reliable network conditions, a client can assume that any change they broadcast will be broadcast back at some point in near future.
Subsequently, if they receive another change on the same scope from the server before receiving their own change, they can assume that it will be overridden in near future.
The client can hence keep track of changes they have issued for a scope and discard incoming changes on that scope until they receive their own issued change.
In case of current implementation, JSON Patch's replace operation is not only strongly idempotent, but also the main cause of self-stuttering. Its scope can be tracked by each client by tracking changes done to a certain path. To ensure the same change is broadcast back by the server, each client can sign each operation with a unique hash and check the hash of incoming operations.
A PatchVerifier utility class has been added for this purpose:
// webapp/src/main/utils/patch-verifier.ts
export class PatchVerifier {
public signOperation(op: Operation): SignedOperation;
public sign(patch: Patch): SignedPatch;
public isVerifiedOperation(op: Operation): boolean;
public verified(patch: Patch): Patch;
}
signOperation() method checks if the operation is a replace operation, and in that case signs it with a unique hash and records its path and hash pair in a mapping. sign() signs all operations inside a patch.
isVerifiedOperation() checks whether some incoming operation is a signed replace operation or not. If it is, it checks whether its path is recorded in the aforementioned mapping. If not, then it verifies the operation (it can be applied without causing stuttering), otherwise it denies the operation (it can be optimistically discarded). If the hash of a signed replace operation with a matching path also matches, then the client has received its own issued change back from the server, and the path, hash pair is removed from the mapping (following changes on the path will be verified and applied).
verified() method clears up a patch, leaving only verified operations.
Summary
With current implementation of realtime collaboration, there was an issue of self-stuttering. This PR suppresses such stuttering by signing issued change operations and locally discarding broadcast changes that a client optimistically anticipates will be overwritten.
Problem Description
Current implementation of realtime collaboration relies on strongly idempotent changes being broadcast by clients, temporally sorted by the server and re-broadcast to all clients (including the client who issued the change initially).
While this provides a simple and straight-forward solution for achieving eventual consistency, it has a "self-stuttering" side effect:
In this scenario, client A observes the state change as
I -> X -> X -> Y
. Since reapplying a change doesn't affect the state (idempotency), this is equivalent to observing the state sequenceI -> X -> Y
, which is correct.Client B however, observes the sequence
I -> Y -> X -> Y
, i.e. the state changing and then reverting again (stuttering). I call this self-stuttering because it is caused by them eagerly applying changes originated by themself (change Y). The desired sequence would beI -> Y
.This is particularly an issue as it can occur even without another client issuing changes on the same scope. Imagine the client issuing constant changes on the same scope (for example, by dragging an element). The following sequence of events is possible (and probable):
In this scenario, the client will observe the state sequence
I -> X -> Y -> X -> Y
, without interference from any other collaboration peer.Solution Details
The solution relies on each client optimistically assessing whether an incoming change from the server would be overridden by a following change in near future or not.
In case of current implementation, JSON Patch's replace operation is not only strongly idempotent, but also the main cause of self-stuttering. Its scope can be tracked by each client by tracking changes done to a certain
path
. To ensure the same change is broadcast back by the server, each client can sign each operation with a unique hash and check the hash of incoming operations.A
PatchVerifier
utility class has been added for this purpose:signOperation()
method checks if the operation is a replace operation, and in that case signs it with a unique hash and records itspath
andhash
pair in a mapping.sign()
signs all operations inside a patch.isVerifiedOperation()
checks whether some incoming operation is a signed replace operation or not. If it is, it checks whether itspath
is recorded in the aforementioned mapping. If not, then it verifies the operation (it can be applied without causing stuttering), otherwise it denies the operation (it can be optimistically discarded). If thehash
of a signed replace operation with a matchingpath
also matches, then the client has received its own issued change back from the server, and thepath, hash
pair is removed from the mapping (following changes on the path will be verified and applied).verified()
method clears up a patch, leaving only verified operations.