ls1intum / Apollon_standalone

Standalone version of Apollon Editor
MIT License
11 stars 2 forks source link

suppress self-stuttering #70

Closed loreanvictor closed 10 months ago

loreanvictor commented 10 months ago

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:

  1. client A applies change X locally, then broadcasts it
  2. client B applies change Y locally, then broadcasts it. change Y has the same scope as change X.
  3. both clients receive change X from the server and (re)apply it. Both their local states becomes the result of change X.
  4. 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):

  1. The client issues change X.
  2. The client issues change Y.
  3. The server broadcasts change X, the client (re)applies it.
  4. 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 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.