UNPKG

mute-structs

Version:

NodeJS module providing an implementation of the LogootSplit CRDT algorithm

660 lines (656 loc) 26.7 kB
/* This file is part of MUTE-structs. Copyright (C) 2017 Matthieu Nicolas, Victorien Elvinger This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. */ import { isObject } from "./data-validation"; import { Identifier } from "./identifier"; import { IdentifierInterval } from "./identifierinterval"; import * as IDFactory from "./idfactory"; import { isInt32 } from "./int32"; import { compareBase, } from "./iteratorhelperidentifier"; import { LogootSBlock } from "./logootsblock"; import { LogootSDel } from "./operations/delete/logootsdel"; import { TextDelete } from "./operations/delete/textdelete"; import { LogootSAdd } from "./operations/insert/logootsadd"; import { TextInsert } from "./operations/insert/textinsert"; import { mkNodeAt, RopesNodes } from "./ropesnodes"; import * as TextUtils from "./textutils"; function leftChildOf(aNode) { return aNode.left; } function rightChildOf(aNode) { return aNode.right; } export class LogootSRopes { constructor(replica = 0, clock = 0, root = null, str = "") { console.assert(isInt32(replica), "replica ∈ int32"); console.assert(isInt32(clock), "clock ∈ int32"); this.replicaNumber = replica; this.clock = clock; this.root = root; this.str = str; const baseToBlock = {}; if (root !== null) { console.assert(str.length === root.sizeNodeAndChildren, "str length must match the number of elements in the model"); const blocks = root.getBlocks(); for (const b of blocks) { const key = b.idInterval.base.join(","); baseToBlock[key] = b; } } else { console.assert(str.length === 0, "str must be empty when no root is provided"); } this.mapBaseToBlock = baseToBlock; } static empty() { return new LogootSRopes(0, 0); } static fromPlain(o) { if (isObject(o) && isInt32(o.replicaNumber) && isInt32(o.clock) && typeof o.str === "string") { const root = RopesNodes.fromPlain(o.root); if ((root !== null && o.str.length === root.sizeNodeAndChildren) || (root === null && o.str.length === 0)) { return new LogootSRopes(o.replicaNumber, o.clock, root, o.str); } } return null; } get height() { return (this.root) ? this.root.height : 0; } getBlock(idInterval) { const mapBaseToBlock = this.mapBaseToBlock; const key = idInterval.base.join(","); let result; if (mapBaseToBlock.hasOwnProperty(key)) { result = mapBaseToBlock[key]; } else { result = new LogootSBlock(idInterval, 0); this.mapBaseToBlock[key] = result; } return result; } /** * Add a interval of identifiers and its corresponding string to the model * * @param {string} str The inserted string * @param {IdentifierInterval} idi The corresponding interval of identifiers * @param {RopesNodes} from The starting point of the search * @param {number} startOffset ??? */ addBlockFrom(str, idi, from, startOffset) { const result = this.addBlockFromRec(str, idi, from, startOffset); result.forEach((textInsert) => { this.applyTextInsert(textInsert); }); return result; } addBlockFromRec(str, idi, from, startOffset) { const author = idi.idBegin.replicaNumber; const path = []; const result = []; let con = true; let i = startOffset; while (con) { path.push(from); // B1 is the block we are adding // B2 is the block to which we are comparing switch (compareBase(idi, from.getIdentifierInterval())) { case 0 /* B1_AFTER_B2 */: { if (from.right === null) { from.right = RopesNodes.leaf(this.getBlock(idi), idi.begin, str.length); i = i + from.leftSubtreeSize() + from.length; result.push(new TextInsert(i, str, author)); con = false; } else { i = i + from.leftSubtreeSize() + from.length; from = from.right; } break; } case 1 /* B1_BEFORE_B2 */: { if (from.left === null) { from.left = RopesNodes.leaf(this.getBlock(idi), idi.begin, str.length); result.push(new TextInsert(i, str, author)); con = false; } else { from = from.left; } break; } case 2 /* B1_INSIDE_B2 */: { // split b2 the object node const indexOffset = from.getIdBegin().length - 1; const offsetToSplit = idi.idBegin.tuples[indexOffset].offset; const rp = RopesNodes.leaf(this.getBlock(idi), idi.begin, str.length); path.push(from.split(offsetToSplit - from.actualBegin + 1, rp)); i = i + from.leftSubtreeSize(); result.push(new TextInsert(i + offsetToSplit - from.actualBegin + 1, str, author)); con = false; break; } case 3 /* B2_INSIDE_B1 */: { // split b1 the node to insert const indexOffset = idi.idBegin.length - 1; const offsetToSplit = from.getIdBegin().tuples[indexOffset].offset; let ls = str.substr(0, offsetToSplit + 1 - idi.begin); let idi1 = new IdentifierInterval(idi.idBegin, offsetToSplit); if (from.left === null) { from.left = RopesNodes.leaf(this.getBlock(idi1), idi1.begin, ls.length); result.push(new TextInsert(i, ls, author)); } else { Array.prototype.push.apply(result, this.addBlockFromRec(ls, idi1, from.left, i)); } // i=i+ls.size() ls = str.substr(offsetToSplit + 1 - idi.begin, str.length); const newIdBegin = Identifier.fromBase(idi.idBegin, offsetToSplit + 1); idi1 = new IdentifierInterval(newIdBegin, idi.end); i = i + from.leftSubtreeSize() + from.length; if (from.right === null) { from.right = RopesNodes.leaf(this.getBlock(idi1), idi1.begin, ls.length); result.push(new TextInsert(i, ls, author)); } else { Array.prototype.push.apply(result, this.addBlockFromRec(ls, idi1, from.right, i)); } con = false; break; } case 4 /* B1_CONCAT_B2 */: { // node to insert concat the node if (from.left !== null) { const split = from.getIdBegin().minOffsetAfterPrev(from.left.max, idi.begin); const l = str.substr(split - idi.begin, str.length); if (l.length > 0) { from.appendBegin(l.length); result.push(new TextInsert(i + from.leftSubtreeSize(), l, author)); this.ascendentUpdate(path, l.length); } // check if previous is smaller or not if ((split - 1) >= idi.begin) { str = str.substr(0, split - idi.begin); idi = new IdentifierInterval(idi.idBegin, split - 1); from = from.left; } else { con = false; } } else { result.push(new TextInsert(i, str, author)); from.appendBegin(str.length); this.ascendentUpdate(path, str.length); con = false; } break; } case 5 /* B2_CONCAT_B1 */: { // concat at end if (from.right !== null) { const split = from.getIdEnd().maxOffsetBeforeNext(from.right.min, idi.end); const l = str.substr(0, split + 1 - idi.begin); i = i + from.leftSubtreeSize() + from.length; if (l.length > 0) { from.appendEnd(l.length); result.push(new TextInsert(i, l, author)); this.ascendentUpdate(path, l.length); } if (idi.end >= (split + 1)) { str = str.substr(split + 1 - idi.begin, str.length); const newIdBegin = Identifier.fromBase(idi.idBegin, split + 1); idi = new IdentifierInterval(newIdBegin, idi.end); from = from.right; i = i + l.length; } else { con = false; } } else { i = i + from.leftSubtreeSize() + from.length; result.push(new TextInsert(i, str, author)); from.appendEnd(str.length); this.ascendentUpdate(path, str.length); con = false; } break; } case 6 /* B1_EQUALS_B2 */: { con = false; break; } } } this.balance(path); return result; } // FIXME: Put this function elsewhere? applyTextInsert(textInsert) { this.str = TextUtils.insert(this.str, textInsert.index, textInsert.content); } // FIXME: Put this function elsewhere? applyTextDelete(textDelete) { const end = textDelete.index + textDelete.length - 1; this.str = TextUtils.del(this.str, textDelete.index, end); } addBlock(str, id) { const author = id.replicaNumber; const idi = new IdentifierInterval(id, id.lastOffset + str.length - 1); if (this.root === null) { const bl = new LogootSBlock(idi, 0); this.mapBaseToBlock[bl.idInterval.base.join(",")] = bl; this.root = RopesNodes.leaf(bl, id.lastOffset, str.length); const textInsert = new TextInsert(0, str, author); this.applyTextInsert(textInsert); return [textInsert]; } else { return this.addBlockFrom(str, idi, this.root, 0); } } mkNode(id1, id2, length) { console.assert(isInt32(length) && length > 0, "length ∈ int32"); console.assert(length > 0, "length > 0"); const id = IDFactory.createBetweenPosition(id1, id2, this.replicaNumber, this.clock++); const node = mkNodeAt(id, length); this.mapBaseToBlock[node.getIdentifierInterval().base.join(",")] = node.block; return node; } insertLocal(pos, l) { console.assert(isInt32(pos), "pos ∈ int32"); if (this.root === null) { // empty tree this.root = this.mkNode(null, null, l.length); this.str = TextUtils.insert(this.str, pos, l); return new LogootSAdd(this.root.getIdBegin(), l); } else { let newNode; const length = this.str.length; this.str = TextUtils.insert(this.str, pos, l); let path; if (pos === 0) { // begin of string path = []; path.push(this.root); const n = this.getXest(leftChildOf, path); newNode = this.mkNode(null, n.getIdBegin(), l.length); n.left = newNode; } else if (pos >= length) { // end path = []; path.push(this.root); const n = this.getXest(rightChildOf, path); if (n.isAppendableAfter(this.replicaNumber, l.length)) { // append const id3 = n.appendEnd(l.length); this.ascendentUpdate(path, l.length); return new LogootSAdd(id3, l); } else { // add at end newNode = this.mkNode(n.getIdEnd(), null, l.length); n.right = newNode; } } else { // middle const inPos = this.searchNode(pos); // TODO: why non-null? if (inPos.i > 0) { // split const id1 = inPos.node.block.idInterval.getBaseId(inPos.node.actualBegin + inPos.i - 1); const id2 = inPos.node.block.idInterval.getBaseId(inPos.node.actualBegin + inPos.i); newNode = this.mkNode(id1, id2, l.length); path = inPos.path; path.push(inPos.node.split(inPos.i, newNode)); } else { const prev = this.searchNode(pos - 1); // TODO: why non-null? if (prev.node.isAppendableAfter(this.replicaNumber, l.length)) { // append after const id4 = prev.node.appendEnd(l.length); this.ascendentUpdate(prev.path, l.length); return new LogootSAdd(id4, l); } else { newNode = this.mkNode(prev.node.getIdEnd(), inPos.node.getIdBegin(), l.length); newNode.right = prev.node.right; prev.node.right = newNode; path = prev.path; path.push(newNode); } } } this.balance(path); return new LogootSAdd(newNode.getIdBegin(), l); } } getXest(aChildOf, aPath) { let n = aPath[aPath.length - 1]; let child = aChildOf(n); while (child !== null) { n = child; aPath[aPath.length] = child; child = aChildOf(child); } return n; } searchPos(id, path) { let i = 0; let node = this.root; while (node !== null) { path.push(node); if (id.compareTo(node.getIdBegin()) === -1) { node = node.left; } else if (id.compareTo(node.getIdEnd()) === 1) { i = i + node.leftSubtreeSize() + node.length; node = node.right; } else { if (id.equalsBase(node.getIdBegin())) { return i + node.leftSubtreeSize(); } else { // Could not find the identifier, stop the search node = null; } } } // FIXME: Clear path? return -1; } searchNode(pos) { console.assert(isInt32(pos), "pos ∈ int32"); let node = this.root; const path = []; while (node !== null) { path.push(node); const before = node.leftSubtreeSize(); if (pos < before) { node = node.left; } else if (pos < before + node.length) { return { i: pos - before, node, path, }; } else { pos -= before + node.length; node = node.right; } } return null; } ascendentUpdate(path, length) { console.assert(isInt32(length), "length ∈ int32"); // `length" may be negative for (const item of path) { item.addString(length); } } delBlock(idInterval, author) { const l = []; let i; while (true) { const path = []; i = this.searchPos(idInterval.idBegin, path); if (i === -1) { // Could not find the first identifier from the interval if (idInterval.begin < idInterval.end) { // Shifting the interval and resuming the search const newIdBegin = Identifier.fromBase(idInterval.idBegin, idInterval.begin + 1); idInterval = new IdentifierInterval(newIdBegin, idInterval.end); } else { break; } } else { // Was able to find the position of the identifier const node = path[path.length - 1]; // Retrieving the node containing the identifier const end = Math.min(idInterval.end, node.actualEnd); const pos = i + idInterval.begin - node.actualBegin; const length = end - idInterval.begin + 1; l.push(new TextDelete(pos, length, author)); const t = node.deleteOffsets(idInterval.begin, end); if (node.length === 0) { // del node this.delNode(path); } else if (t !== null) { path.push(t); this.balance(path); } else { // TODO: Check second argument this.ascendentUpdate(path, idInterval.begin - end - 1); } if (end === idInterval.end) { break; } else { // TODO: Check if still valid const newIdBegin = Identifier.fromBase(idInterval.idBegin, end); idInterval = new IdentifierInterval(newIdBegin, idInterval.end); } } } l.forEach((textDelete) => { this.applyTextDelete(textDelete); }); return l; } delLocal(begin, end) { console.assert(isInt32(begin), "begin ∈ int32"); console.assert(isInt32(end), "end ∈ int32"); console.assert(begin <= end, "" + begin, " <= " + end); this.str = TextUtils.del(this.str, begin, end); let length = end - begin + 1; const li = []; do { const start = this.searchNode(begin); if (start !== null) { const newBegin = start.node.actualBegin + start.i; const newEnd = Math.min(newBegin + length - 1, start.node.actualEnd); const prevIdBegin = start.node.getIdBegin(); const newIdBegin = Identifier.fromBase(prevIdBegin, newBegin); li.push(new IdentifierInterval(newIdBegin, newEnd)); const r = start.node.deleteOffsets(newBegin, newEnd); length -= newEnd - newBegin + 1; if (start.node.length === 0) { this.delNode(start.path); } else if (r !== null) { // node has been splited start.path.push(r); this.balance(start.path); } else { this.ascendentUpdate(start.path, newBegin - newEnd - 1); } } else { length = 0; } } while (length > 0); return new LogootSDel(li, this.replicaNumber); } delNode(path) { const node = path[path.length - 1]; if (node.block.nbElement === 0) { delete this.mapBaseToBlock[node.block.idInterval.base.join(",")]; } if (node.right === null) { if (node === this.root) { this.root = node.left; } else { path.pop(); path[path.length - 1].replaceChildren(node, node.left); } } else if (node.left === null) { if (node === this.root) { this.root = node.right; } else { path.pop(); path[path.length - 1].replaceChildren(node, node.right); } } else { // two children path.push(node.right); const min = this.getMinPath(path); node.become(min); path.pop(); path[path.length - 1].replaceChildren(min, min.right); } this.balance(path); } getMinPath(path) { console.assert(path.length !== 0, "path has at least one item"); let node = path[path.length - 1]; // precondition while (node.left !== null) { node = node.left; path.push(node); } return node; } // TODO: Implémenter la balance de Google (voir AVL.js) et vérifier ses performances en comparaison balance(path) { while (path.length > 0) { const node = path.pop(); // Loop condition let father = path.length === 0 ? null : path[path.length - 1]; node.sumDirectChildren(); let balance = node.balanceScore(); while (Math.abs(balance) >= 2) { if (balance >= 2) { if (node.right !== null && node.right.balanceScore() <= -1) { father = this.rotateRL(node, father); // Double left } else { father = this.rotateLeft(node, father); } } else { if (node.left !== null && node.left.balanceScore() >= 1) { father = this.rotateLR(node, father); // Double right } else { father = this.rotateRight(node, father); } } path.push(father); balance = node.balanceScore(); } } } rotateLeft(node, father) { console.assert(node.right !== null, "There exists a right node"); console.assert((node === this.root) === (father === null), "The father is null when we are rotating left the root"); const r = node.right; // precondition if (node === this.root) { this.root = r; } else { // FIXME: Should we not replace the left child in this case? father.replaceChildren(node, r); // FIXME: This assert fails from time to time, verify its correctness // console.assert((father as RopesNodes).left !== null, "There exists a left node") } node.right = r.left; r.left = node; node.sumDirectChildren(); r.sumDirectChildren(); return r; } rotateRight(node, father) { console.assert(node.left !== null, "There exists a left node"); console.assert((node === this.root) === (father === null), "The father is null when we are rotating right the root"); const r = node.left; // precondition if (node === this.root) { this.root = r; } else { // FIXME: Should we not replace the right child in this case? father.replaceChildren(node, r); // FIXME: This assert fails from time to time, verify its correctness // console.assert((father as RopesNodes).right !== null, "There exists a right node") } node.left = r.right; r.right = node; node.sumDirectChildren(); r.sumDirectChildren(); return r; } rotateRL(node, father) { console.assert(node.right !== null, "There exists a right node"); const rightNode = node.right; // precondition console.assert(rightNode.left !== null, "There exists a left node of the right node"); this.rotateRight(rightNode, node); return this.rotateLeft(node, father); } rotateLR(node, father) { console.assert(node.left !== null, "There exists a left node"); const leftNode = node.left; // precondition console.assert(leftNode.right !== null, "There exists a right node of the left node"); this.rotateLeft(leftNode, node); return this.rotateRight(node, father); } getNext(path) { const node = path[path.length - 1]; if (node.right === null) { if (path.length > 1) { const father = path[path.length - 2]; if (father.left === node) { path.pop(); return true; } } return false; } else { path.push(node.right); this.getXest(leftChildOf, path); return true; } } /** * @return tree digest */ digest() { let result = 0; const root = this.root; if (root !== null) { const linearRpr = root.toList(); for (const idi of linearRpr) { result = (result * 17 + idi.digest()) | 0; // Convert to 32bits integer } } return result; } toList() { if (this.root !== null) { return this.root.toList(); } return []; } } //# sourceMappingURL=logootsropes.js.map