y-lwwmap
Version:
a shared CRDT key-value map for Yjs using a "last-write-wins" (LWW) algorithm for conflict resolution
539 lines (440 loc) • 18.5 kB
text/typescript
import * as Y from 'yjs'
import { Observable } from 'lib0/observable'
import md5 from 'blueimp-md5'
const TimestampFactor = 3000 // expected max. # of changes per ms
// however, having even more changes does not break this implementation
type ChangeLogEntry<T> = { // represents a change of an LWWMap entry
Key:string,
Value?:T,
Timestamp:number
}
// handling of deleted entries:
// - entries in this.localMap and this.sharedArray with missing "Value" property
// such entries will be removed "RetentionPeriod" ms after deletion
export class LWWMap<T extends object|boolean|Array<T>|string|number|null|Uint8Array> extends Observable<T> {
protected RetentionPeriod:number // how long to keep deletion log entries
protected sharedArray:any // elements with higher indices where added later
protected localMap:Map<string,ChangeLogEntry<T>> // caches validated changes
protected lastTimestamp:number // keeps track of most recent timestamp
public constructor (
sharedArray:Y.Array<{ key: string, val: T }>,
RetentionPeriod:number = 30*24*60*60*1000
) {
super()
this.sharedArray = sharedArray // this is the actually shared object
this.RetentionPeriod = RetentionPeriod * TimestampFactor
this.lastTimestamp = Date.now() * TimestampFactor
this.localMap = new Map<string,ChangeLogEntry<T>>()
this._initializeMap()
/**** "sharedArray" is where synchronization happens, observe it ****/
this.sharedArray.observe(
(Event:any,Transaction:any) => this._updateOnChange(Event,Transaction)
)
}
/**** @@iterator ****/
public [Symbol.iterator]():IterableIterator<T> {
return (
[...this.localMap.entries()]
.filter((Entry) => 'Value' in Entry[1])
.map((Entry) => [Entry[0],Entry[1].Value])
)[Symbol.iterator]() as IterableIterator<T>
}
/**** size ****/
public get size ():number {
let Result:number = 0
this.localMap.forEach((loggedEntry:ChangeLogEntry<T>) => {
if ('Value' in loggedEntry) { Result++ }
})
return Result
}
/**** clear ****/
public clear ():void {
if (this.size > 0) {
this.sharedArray.doc.transact(() => {
this._removeAnyObsoleteDeletions() // from localMap and sharedArray
this.sharedArray.delete(0,this.sharedArray.length)
this.localMap.forEach((loggedEntry:ChangeLogEntry<T>, Key:string) => {
if ('Value' in loggedEntry) {
this._updateLastTimestampWith(Date.now() * TimestampFactor)
let Change:ChangeLogEntry<T> = { Key, Timestamp:this.lastTimestamp }
this.localMap.set(Key,Change)
this.sharedArray.push([Change])
} else {
this.sharedArray.push([loggedEntry])
}
})
})
}
}
/**** delete ****/
public delete (Key:string):boolean {
if (this.localMap.has(Key)) { // ignore deletions of missing entries
this.sharedArray.doc.transact(() => {
this._removeAnyLogEntriesForKey(Key)
this._removeAnyObsoleteDeletions()
this._updateLastTimestampWith(Date.now() * TimestampFactor)
let Change:ChangeLogEntry<T> = { Key, Timestamp:this.lastTimestamp }
this.localMap.set(Key,Change)
this.sharedArray.push([Change])
})
return true
} else {
return false
}
}
/**** entries ****/
public entries ():IterableIterator<[string, T]> {
const localMapEntries = this.localMap.entries()
return {
[Symbol.iterator]() { return this }, // makes this object iterable
// @ts-ignore TS2322
next: ():{ value?:[string,T], done?:boolean } => {
let nextEntry = localMapEntries.next()
while (! nextEntry.done) {
let [Key,loggedChange] = nextEntry.value
if ('Value' in loggedChange) {
return { value:[Key,loggedChange.Value as T] }
} else {
nextEntry = localMapEntries.next()
}
}
return { done:true }
}
}
}
/**** forEach ****/
public forEach (Callback:(Value:T, Key:string, Map:LWWMap<T>) => void, thisArg?:any):void {
this.localMap.forEach((loggedEntry:ChangeLogEntry<T>,Key:string) => {
if ('Value' in loggedEntry) { // ignore entries describing deletions
Callback.call(thisArg, loggedEntry.Value as T, Key, this)
}
})
}
/**** get ****/
public get (Key:string):T | undefined {
return (
this.localMap.has(Key)
? (this.localMap.get(Key) as ChangeLogEntry<T>).Value
: undefined
)
}
/**** has ****/
public has (Key:string):boolean {
return (
this.localMap.has(Key) &&
('Value' in (this.localMap.get(Key) as ChangeLogEntry<T>))
)
}
/**** keys ****/
public keys ():IterableIterator<string> {
const localMapEntries = this.localMap.entries()
return {
[Symbol.iterator]() { return this }, // makes this object iterable
// @ts-ignore TS2322
next: ():{ value?:string, done?:boolean } => {
let nextEntry = localMapEntries.next()
while (! nextEntry.done) {
let [Key,loggedChange] = nextEntry.value
if ('Value' in loggedChange) {
return { value:Key }
} else {
nextEntry = localMapEntries.next()
}
}
return { done:true }
}
}
}
/**** set ****/
public set (Key:string, Value:T):void {
this.sharedArray.doc.transact(() => {
this._removeAnyLogEntriesForKey(Key)
this._removeAnyObsoleteDeletions()
this._updateLastTimestampWith(Date.now() * TimestampFactor)
let Change = { Key, Value, Timestamp:this.lastTimestamp }
this.localMap.set(Key,Change)
this.sharedArray.push([Change])
})
}
/**** values ****/
public values ():IterableIterator<T> {
const localMapEntries = this.localMap.entries()
return {
[Symbol.iterator]() { return this }, // makes this object iterable
// @ts-ignore TS2322
next: ():{ value?:T, done?:boolean } => {
let nextEntry = localMapEntries.next()
while (! nextEntry.done) {
let [Key,loggedChange] = nextEntry.value
if ('Value' in loggedChange) {
return { value:loggedChange.Value as T }
} else {
nextEntry = localMapEntries.next()
}
}
return { done:true }
}
}
}
/**** transact ****/
public transact (Callback:(Transaction:any) => void, Origin?:any):void {
this.sharedArray.doc.transact(Callback,Origin)
}
/**** Container ****/
public get Container ():Y.Array<{ key: string, val: T }> {
return this.sharedArray
}
/**** _LogEntryIsBroken ****/
protected _LogEntryIsBroken (LogEntry:any):boolean {
return (
(LogEntry == null) ||
(typeof LogEntry.Key !== 'string') ||
(typeof LogEntry.Timestamp !== 'number') ||
! isFinite(LogEntry.Timestamp) || (LogEntry.Timestamp < 0) ||
(Math.floor(LogEntry.Timestamp) !== LogEntry.Timestamp)
)
}
/**** _ChangesCollide - is "firstChange" newer than "secondChange"? ****/
private _md5Hash (Value:any):string {
try {
return md5(JSON.stringify(Value))
} catch (Signal:any) {
return ''
}
}
protected _ChangesCollide (
firstChange:ChangeLogEntry<T>, secondChange:ChangeLogEntry<T>
):boolean {
return (
(firstChange.Timestamp > secondChange.Timestamp) ||
(
(firstChange.Timestamp === secondChange.Timestamp) &&
(firstChange.Value !== secondChange.Value) &&
(this._md5Hash(firstChange.Value) > this._md5Hash(secondChange.Value))
) // consistent behaviour in case of timestamp collisions
)
}
/**** initialize "localMap" from "sharedArray", remove obsolete array items ****/
protected _initializeMap ():void {
const DeletionSet = new Map() // keeps track of deletion logs
const ChangeLog:ChangeLogEntry<T>[] = this.sharedArray.toArray()
this.sharedArray.doc.transact(() => {
for (let i = ChangeLog.length-1; i >= 0; i--) {// backwards for deletion
const loggedChange:ChangeLogEntry<T> = ChangeLog[i]
const Key = loggedChange.Key
const KeyIsKnown = this.localMap.has(Key) || DeletionSet.has(Key)
const cachedChange = (
KeyIsKnown
? this.localMap.get(Key) || DeletionSet.get(Key)
: undefined
)
if ('Value' in loggedChange) {// "loggedChange" defines existing entry
switch (true) {
case ! KeyIsKnown:
this.localMap.set(Key,loggedChange)
this._updateLastTimestampWith(loggedChange.Timestamp)
break
case this._ChangesCollide(cachedChange,loggedChange):
console.warn(
'LWWMap: timestamp mismatch for key "' + Key + '"'
)
this.sharedArray.delete(i) // remove obsolete log entry
break
default: // entry is already known, but its state is outdated
DeletionSet.delete(Key) // just in case...
this.localMap.set(Key,loggedChange)
this._updateLastTimestampWith(loggedChange.Timestamp)
} // the older ChangeLog entry will persist until next update
} else { // "loggedChange" defines a deleted entry
switch (true) {
case ! KeyIsKnown:
DeletionSet.set(Key,loggedChange)
this._updateLastTimestampWith(loggedChange.Timestamp)
break
case this._ChangesCollide(cachedChange,loggedChange):
console.warn(
'LWWMap: timestamp mismatch for key "' + Key + '"'
)
this.sharedArray.delete(i) // remove obsolete log entry
break
default: // entry is already known, but its state is outdated
DeletionSet.set(Key,loggedChange)
this.localMap.delete(Key) // just in case...
this._updateLastTimestampWith(loggedChange.Timestamp)
} // the older ChangeLog entry will persist until next update
}
}
})
}
/**** apply reported updates - if applicable ****/
protected _updateOnChange (Event:any,Transaction:any):void {
const TransactionLog = new Map() // verified updates in this transaction
let TransactionTime = this.lastTimestamp // temporary timestamp
const EventLog = new Map() // prepares final change event
/**** updateTransactionTimeWith ****/
function updateTransactionTimeWith (newTimestamp:number):void {
TransactionTime = Math.max(TransactionTime,newTimestamp)
if (TransactionTime >= Number.MAX_SAFE_INTEGER) {
throw new TypeError('timestamp has reached the allowed limit')
}
}
/**** analyze all updates in this transaction ****/
const UpdateLog:ChangeLogEntry<T>[] = Array.from(Event.changes.added).map(
(addedContent:any) => addedContent.content.getContent()
).flat() // updates are appended sharedArray elements
try {
UpdateLog.forEach((loggedUpdate:ChangeLogEntry<T>) => {
if (this._LogEntryIsBroken(loggedUpdate)) { return } // skip broken logs
const Key = loggedUpdate.Key
const KeyIsKnown = EventLog.has(Key) || this.localMap.has(Key)
const cachedUpdate = (
KeyIsKnown
? EventLog.get(Key) || this.localMap.get(Key)
: undefined
)
switch (true) {
case ! ('Value' in loggedUpdate): // log entry defines a deletion
if (KeyIsKnown) { // actually delete cached entries only
if (this._ChangesCollide(cachedUpdate,loggedUpdate)) {
console.warn(
'LWWMap: remotely deleted entry was later modified locally',
cachedUpdate.Timestamp,loggedUpdate.Timestamp
)
return
}
updateTransactionTimeWith(loggedUpdate.Timestamp)
TransactionLog.set(Key,loggedUpdate)
EventLog.set(Key, {
action:'delete', oldValue:cachedUpdate.Value
})
}
break
case KeyIsKnown && this._ChangesCollide(cachedUpdate,loggedUpdate):
console.warn(
'LWWMap: remote change is outdated',
cachedUpdate.Timestamp,loggedUpdate.Timestamp
)
return
default: // everything seems ok
updateTransactionTimeWith(loggedUpdate.Timestamp)
TransactionLog.set(Key,loggedUpdate)
if (this.localMap.has(Key)) { // not just "KeyIsKnown"
EventLog.set(Key, {
action:'update', oldValue:cachedUpdate.Value, newValue:loggedUpdate.Value
})
} else {
EventLog.set(Key, {
action:'add', newValue:loggedUpdate.Value
})
}
}
})
} catch (Signal:any) { // refresh affected entries to keep them consistent
if (Signal.message.startsWith('Conflict: ')) {
const KeysToRefresh = new Set()
const ChangesToDelete = new Set()
UpdateLog.forEach((loggedUpdate) => {
KeysToRefresh.add(loggedUpdate.Key) // refresh affected entries
ChangesToDelete.add(loggedUpdate) // remove inconsistent change logs
})
const ChangeLog:ChangeLogEntry<T>[] = this.sharedArray.toArray()
this.sharedArray.doc.transact(() => {
const ChangesToRefresh = new Map()
/**** remove any obsolete ChangeLog entries... ****/
for (let i = ChangeLog.length-1; i >= 0; i--) {// backw. for deletion
let loggedChange = ChangeLog[i]
let Key = loggedChange.Key
switch (true) {
case ChangesToDelete.has(loggedChange):
this.sharedArray.delete(i)
break
case KeysToRefresh.has(Key):
if (! ChangesToRefresh.has(Key)) {
ChangesToRefresh.set(Key,loggedChange)
} // "ChangesToRefresh" will only store latest logged change
this.sharedArray.delete(i)
}
}
/**** ...and reappend those that should be refreshed ****/
for (const [Key,loggedChange] of ChangesToRefresh) {
this.sharedArray.push([loggedChange])
}
})
return // no updates have been applied
} else {
throw Signal // do not swallow "real" exceptions
}
}
/**** now actually apply any updates ****/
if (EventLog.size > 0) {
for (const [Key,loggedUpdate] of TransactionLog) {
this.localMap.set(Key,loggedUpdate)
}
this.lastTimestamp = TransactionTime
}
/**** finally try to optimize the current ChangeLog ****/
this._removeAnyBrokenLogEntries()
this._removeAnyObsoleteDeletions()
if (EventLog.size > 0) {
const ChangeLog:ChangeLogEntry<T>[] = this.sharedArray.toArray()
this.sharedArray.doc.transact(() => {
for (let i = ChangeLog.length-1; i >= 0; i--) { // backw. for deletion
const loggedChange = ChangeLog[i]
const Key = loggedChange.Key
if (EventLog.has(Key) && (EventLog.get(Key).newValue !== loggedChange.Value)) {
this.sharedArray.delete(i)
}
}
})
}
/**** it's time to inform the client ****/
if (EventLog.size > 0) {
// @ts-ignore TS2339
this.emit('change',[EventLog,Transaction])
}
}
/**** _removeAnyBrokenLogEntries ****/
protected _removeAnyBrokenLogEntries ():void {
const ChangeLog:any[] = this.sharedArray.toArray()
for (let i = ChangeLog.length-1; i >= 0; i--) {
const loggedChange:any = ChangeLog[i]
if (this._LogEntryIsBroken(loggedChange)) {
this.sharedArray.delete(i)
}
}
}
/**** _removeAnyLogEntriesForKey ****/
protected _removeAnyLogEntriesForKey (Key:string):void {
const ChangeLog:ChangeLogEntry<T>[] = this.sharedArray.toArray()
for (let i = ChangeLog.length-1; i >= 0; i--) {
const loggedChange:ChangeLogEntry<T> = ChangeLog[i]
if (loggedChange.Key === Key) {
this.sharedArray.delete(i)
}
}
}
/**** _removeAnyObsoleteDeletions ****/
protected _removeAnyObsoleteDeletions ():void {
let RetentionTimestamp = Date.now() * TimestampFactor - this.RetentionPeriod
const ChangeLog:ChangeLogEntry<T>[] = this.sharedArray.toArray()
for (let i = ChangeLog.length-1; i >= 0; i--) {
const loggedChange:ChangeLogEntry<T> = ChangeLog[i]
if (
! ('Value' in loggedChange) &&
(loggedChange.Timestamp < RetentionTimestamp)
) {
this.localMap.delete(loggedChange.Key)
this.sharedArray.delete(i)
}
}
}
/**** _updateLastTimestampWith ****/
protected _updateLastTimestampWith (Timestamp:number):void {
let newTimestamp:number = Math.max(this.lastTimestamp + 1, Timestamp)
if (newTimestamp >= Number.MAX_SAFE_INTEGER) {
throw new TypeError('timestamp has reached the allowed limit')
} else {
this.lastTimestamp = newTimestamp
}
}
}