y-lwwmap
v0.1.10
Published
a shared CRDT key-value map for Yjs using a "last-write-wins" (LWW) algorithm for conflict resolution
Downloads
110
Maintainers
Readme
y-lwwmap
a shared CRDT key-value map for Yjs using a "last-write-wins" (LWW) algorithm for conflict resolution
Yjs provides a complete ecosystem for (persisting and) sharing "Conflict-free replicated data types" (CRDT) among multiple clients using a variety of persistence and communication providers. The shared data types include arrays and maps, with shared maps becoming inefficient in most practical cases, which is why there is an alternative implementation based on shared arrays in the y-utility package.
Unfortunately, however, the standard approach to resolve conflicts during synchronization is unpredictable from a user's point of view - in particular, former changes may overwrite later ones when synchronized (see issue 520). The aim of y-lwwmap is therefore to keep the chronological order of changes (even in the case of - moderately - desynchronized wall clocks) and let only later changes superseed former ones.
All other characteristics of LWWMap
should be consistent with YKeyValue
such that an LWWMap
could be used as a direct drop-in for YKeyValue
.
NPM users: please consider the Github README for the latest description of this package (as updating the docs would otherwise always require a new NPM package version)
Just a small note: if you like this module and plan to use it, consider "starring" this repository (you will find the "Star" button on the top right of this page), so that I know which of my repositories to take most care of.
How it works
LWWMap
s are key-value maps with literal keys and values of multiple types (see below for details). Being compatible to the Yjs ecosystem, LWWMap
s can be shared as part of a Y.Doc using y-websocket, y-webrtc or similar and persisted using y-indexeddb or similar.
Its implementation is based on that of YKeyValue but uses a "last-write-wins" strategy during synchronization. This includes keeping track of deleted map entries - such that, upon synchronization, locally modified entries will be removed if deleted remotely after that local modification, or restored if deleted remotely but modified locally afterwards.
Deleted entries are marked as deleted for a limited time only (the "retention period") and removed afterwards.
When all sharing clients are connected and immediately synchronized, LWWMap
s should behave like ordinary YKeyValues - even in the case of unsynchronized wall clocks.
When reconnecting after a period of disconnection, clients with faster running clocks may have a better chance to push their changes, but only if clients with slower running clocks changed the same entry earlier than the timestamp of the faster client indicates. Assuming, that all wall clocks only differ slightly (let's say, by a few minutes), the slower client only has to wait for that small time offset (after a change made by the faster client) while offline to apply his/her change in order to let it survive the other one upon reconnection.
Nota bene: it might be worth mentioning that, although changes will be "synchronized", clients should avoid working on the same item simultaneously as there will always be a single "winner" who will overwrite the work of all other clients (CRDTs do not implement operational transforms which could be used to "merge" simultaneously applied changes together. However, CRDTs are good in synchronizing changes that were made one after the other by different clients)
Installation
y-lwwmap
may be used as an ECMAScript module (ESM), a CommonJS or AMD module or from a global variable.
You may either install the package into your build environment using NPM with the command
npm install y-lwwmap
or load the plain script file directly
<script src="https://unpkg.com/y-lwwmap"></script>
Access
How to access the package depends on the type of module you prefer
- ESM (or Svelte):
import { LWWMap } from 'y-lwwmap'
- CommonJS:
const LWWMap = require('y-lwwmap')
- AMD:
require(['y-lwwmap'], (LWWMap) => {...})
Alternatively, you may access the global variable LWWMap
directly.
Note for ECMAScript module users: all module functions and values are exported individually, thus allowing your bundler to perform some "tree-shaking" in order to include actually used functions or values (together with their dependencies) only.
Usage within Svelte
For Svelte, it is recommended to import the package in a module context. From then on, its exports may be used as usual:
<script context="module">
import * as Y from 'yjs'
import { LWWMap } from 'y-lwwmap'
</script>
<script>
const sharedDoc = new Y.Doc()
const sharedContainer = sharedDoc.getArray('sharedContainer')
const sharedMap = new LWWMap(sharedContainer)
...
</script>
Usage as ECMAscript, CommonJS or AMD Module (or as a global Variable)
Let's assume that you already "required" or "imported" (or simply loaded) the module according to your local environment. In that case, you may use it as follows:
...
const sharedMap = new LWWMap(sharedArray)
...
Choosing a "RetentionPeriod"
In order to choose a "useful" RetentionPeriod
, please keep in mind that
- deleted entries are remembered (albeit without their contents) for the given
RetentionPeriod
only and completely forgotten afterwards, - the
RetentionPeriod
is configured once in theLWWMap
constructor and remains constant from then on, - all
LWWMap
instances for the same shared Y.Array should always use the sameRetentionPeriod
- otherwise the synchronization behaviour after deletion of elements while offline may differ from your expectations (i.e., formerly deleted entries may suddenly appear again)
As a consequence, the following "rules of thumb" seem useful
- keep
RetentionPeriod
as short as possible if you plan to delete entries often (as every deleted entry still consumes memory keeping its key and deletion timestamp) - make
RetentionPeriod
larger than the longest expected offline duration for any client
API Reference
The following documentation shows method signatures as used by TypeScript - if you prefer plain JavaScript, just ignore the type annotations.
LWWMap
tries to mimic the interface of JavaScript Maps as closely as possible.
In particular, LWWMaps may also be used within for ... of
loops:
const sharedMap = new LWWMap(sharedArray)
for (const [Key,Value] of sharedMap) {
...
}
The following differences are important:
- keys must be strings - keys of other types are not supported
- values must be
null
,boolean
,number
orstring
primitives,Uint8Array
s,- plain (JSON-serializable)
Object
s, Array
s of the above,Y.Array
s or nestedLWWMap
s
- external changes are reported through
'change'
events (one event per transaction) containing JavaScript Maps with the following [key,value] pairs (the given key is always that of a modified LWWMap entry)[key, { action:'add', newValue:... }]
[key, { action:'update', oldValue:..., newValue:... }]
[key, { action:'delete', oldValue:... }]
Deleting a non-existing entry is permitted, but does neither change the LWWMap nor does it emit an event.
Constructor
LWWMap<T extends null|boolean|number|string|object|Uint8Array|Array<T>> extends Observable<T> (sharedArray:Y.Array<{ key: string, val: T }>, RetentionPeriod:number = 30*24*60*60*1000)
creates a newLWWMap
for elements of typeT
, synchronized using the given Y.ArraysharedArray
. If provided, deleted entries are kept for the givenRetentionPeriod
(measured from the time of deletion on) and forgotten afterwards
Properties
Container
contains a reference to the container of this LWWMap, i.e., thesharedArray
passed as the first argument to the constructorsize
contains the number of elements in this LWWMap
Methods
[Symbol.iterator]():IterableIterator<T>
works likeentries()
but allows this LWWMap to be used in afor ... of
loopclear ():void
removes all elements from this LWWMapdelete (Key:string):boolean
removes the element with the givenKey
from this LWWMap and returnstrue
if that element existed before - orfalse
otherwiseentries ():IterableIterator<[string, T]>
returns a new map iterator object that contains the [key, value] pairs for each element of this LWWMap in arbitrary orderforEach (Callback:(Value:T, Key:string, Map:LWWMap<T>) => void, thisArg?:any):void
executes a provided function once per each key/value pair in arbitrary orderget (Key:string):T | undefined
returns (a reference to) the element with the givenKey
in this LWWMap - orundefined
if such an element does not existhas (Key:string):boolean
returnstrue
if this LWWMap contains an element with the givenKey
- orfalse
if notkeys ():IterableIterator<string>
returns a new map iterator object that contains the keys for each element in this LWWMap in arbitrary orderset (Key:string, Value:T):void
adds or updates the element with the givenKey
in this LWWMap by setting the givenValue
values ():IterableIterator<T>
returns a new map iterator object that contains the values for each element in this LWWMap in arbitrary orderemit (EventName:string, ArgList:Array<any>):void
emits an event with the givenEventName
. All event listeners registered for this event will be invoked with the arguments specified inArgList
(see lib0/Observable)off (EventName:string, Handler:Function):void
unregisters the givenHandler
from the givenEventName
on (EventName:string, Handler:Function):void
registers the givenHandler
for the givenEventName
once (EventName:string, Handler:Function):void
registers the givenHandler
for the givenEventName
and automatically unregisters it again as soon as the first such event has been received
Synthetic Timestamps
LWWMaps use "synthetic timestamps" similar to Lamport timestamps (see here for a short description including some code) in order to keep the chronological order even in the case of (moderately) desynchronized wall clocks between clients.
These "synthetic timestamps" work as follows:
- LWWMaps keep track of the highest timestamp used in local operations and found during synchronizations;
- principally, operations are stamped with the current UTC wall clock time - unless a higher timestamp was observed before: in that case, the higher timestamp is incremented by one, used to stamp the operation and stored as the new highest timestamp;
- this approach guarantees that later operations always have higher timestamps as former ones;
- if two changes of the same entry appear to have the same timestamp (but different values), the one with the higher MD5 hash wins - this guarantees consistent behaviour for every client even in the case of timestamp collisions
This leads to the following LWWMap behaviour
- while connected (and immediately synchronizing upon operations), later changes actually overwrite former ones
- upon reconnection after having been offline for a while (i.e., during synchronization), peers with faster running clocks still have better chances to keep their changes - but only within the offset between slower and faster clocks (this is why clients should only have "moderately" desynchronized wall clocks)
In other words,
- let's say, two clients "past" and "future" have wall clocks which differ by 1 minute (with "future" having a faster clock than "past")
- with an active network connection, the differing wall clocks do not play any role (within the transmission time over this network)
- while beeing offline, "future" changes will superseed "past" ones - but only if the "past" one wasn't be applied more than 1 minute later than the "future" one
Build Instructions
You may easily build this package yourself.
Just install NPM according to the instructions for your platform and follow these steps:
- either clone this repository using git or download a ZIP archive with its contents to your disk and unpack it there
- open a shell and navigate to the root directory of this repository
- run
npm install
in order to install the complete build environment - execute
npm run build
to create a new build
You may also look into the author's build-configuration-study for a general description of his build environment.