Skip to content
This repository has been archived by the owner on Feb 4, 2022. It is now read-only.

Commit

Permalink
feat: add support for custom conflict handlers
Browse files Browse the repository at this point in the history
Custom conflict handlers allow consumers to provide additional domain-specific
conflict handling in addition to what's handled by default.
  • Loading branch information
matchai committed Jul 23, 2021
1 parent a1618df commit 83197ae
Show file tree
Hide file tree
Showing 3 changed files with 98 additions and 7 deletions.
32 changes: 31 additions & 1 deletion src/State.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,35 @@ import { OpMove } from "./OpMove";
import { Tree } from "./Tree";
import { TreeNode } from "./TreeNode";

interface StateOptions<Id, Metadata> {
/**
* An function to provide domain-specific conflict handling logic.
* The resulting boolean value determines whether the operation conflicts.
*
* This is useful if metadata collision can produce conflicts in your business
* logic. For example, making name collisions impossible in a filesystem.
*/
conflictHandler?: (
operation: OpMove<Id, Metadata>,
tree: Tree<Id, Metadata>
) => boolean;
}

export class State<Id, Metadata> {
/** A list of `LogOpMove` in descending timestamp order */
readonly operationLog: LogOpMove<Id, Metadata>[] = [];
/** A tree structure that represents the current state of the tree */
tree: Tree<Id, Metadata> = new Tree();
/** Returns true if the given operation should be discarded */
conflictHandler: (
operation: OpMove<Id, Metadata>,
tree: Tree<Id, Metadata>
) => boolean;

constructor(options: StateOptions<Id, Metadata> = {}) {
// Default to not handling conflict
this.conflictHandler = options.conflictHandler ?? (() => false);
}

/** Insert a log entry to the top of the log */
addLogEntry(entry: LogOpMove<Id, Metadata>) {
Expand Down Expand Up @@ -80,7 +104,7 @@ export class State<Id, Metadata> {
// `oldNode` records the previous parent and metadata of c.
const oldNode = this.tree.get(op.id);

// ensures no cycles are introduced. If the node c
// ensures no cycles are introduced. If the node c
// is being moved, and c is an ancestor of the new parent
// newp, then the tree is returned unmodified, ie the operation
// is ignored.
Expand All @@ -89,6 +113,12 @@ export class State<Id, Metadata> {
return { op, oldNode };
}

// ignores operations that produce conflicts according to the
// custom conflict handler.
if (this.conflictHandler(op, this.tree)) {
return { op, oldNode };
}

// Otherwise, the tree is updated by removing c from
// its existing parent, if any, and adding the new
// parent-child relationship (newp, m, c) to the tree.
Expand Down
23 changes: 20 additions & 3 deletions src/TreeReplica.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,37 @@
import { Clock } from "./Clock";
import { OpMove } from "./OpMove";
import { State } from "./State";
import { Tree } from "./Tree";
import { TreeNode } from "./TreeNode";

interface ReplicaOptions<Id, Metadata> {
/**
* An function to provide domain-specific conflict handling logic.
* The resulting boolean value determines whether the operation conflicts.
*
* This is useful if metadata collision can produce conflicts in your business
* logic. For example, making name collisions impossible in a filesystem.
*/
conflictHandler?: (
operation: OpMove<Id, Metadata>,
tree: Tree<Id, Metadata>
) => boolean;
}

export class TreeReplica<Id, Metadata> {
/** The Tree state */
state: State<Id, Metadata> = new State();
state: State<Id, Metadata>;
/** The logical clock for this replica/tree */
time: Clock<Id>;
/** Mapping of replicas and their latest time */
latestTimeByReplica: Map<Id, Clock<Id>> = new Map();
/** A tree structure that represents the current state of the tree */
tree = this.state.tree;
tree: Tree<Id, Metadata>;

constructor(authorId: Id) {
constructor(authorId: Id, options: ReplicaOptions<Id, Metadata> = {}) {
this.time = new Clock(authorId);
this.state = new State(options);
this.tree = this.state.tree;
}

/** Get a node by its id */
Expand Down
50 changes: 47 additions & 3 deletions test/tree.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { TreeReplica } from "../src";
import { OpMove, Tree, TreeReplica } from "../src";

let id = 1;
const newId = () => String(++id);
Expand Down Expand Up @@ -37,7 +37,7 @@ test("concurrent moves converge to a common location", () => {

// The state is the same on both replicas, converging to /root/c/a
// because last-write-wins and replica2's op has a later timestamp
expect(r1.state).toEqual(r2.state);
expect(r1.state.toString()).toEqual(r2.state.toString());
expect(r1.state.tree.nodes.get(ids.a)?.parentId).toBe(ids.c);
});

Expand Down Expand Up @@ -75,7 +75,51 @@ test("concurrent moves avoid cycles, converging to a common location", () => {

// The state is the same on both replicas, converging to /root/a/b
// because last-write-wins and replica2's op has a later timestamp
expect(r1.state).toEqual(r2.state);
expect(r1.state.toString()).toEqual(r2.state.toString());
expect(r1.state.tree.nodes.get(ids.b)?.parentId).toBe(ids.a);
expect(r1.state.tree.nodes.get(ids.a)?.parentId).toBe(ids.root);
});

test("custom conflict handler supports metadata-based custom conflicts", () => {
type Id = string;
type FileName = string;

// A custom handler that rejects if a sibling exists with the same name
function conflictHandler(op: OpMove<Id, FileName>, tree: Tree<Id, FileName>) {
const siblings = tree.children.get(op.parentId) ?? [];
return [...siblings].some(id => {
const isSibling = id !== op.id;
const hasSameName = tree.get(id)?.metadata === op.metadata;
return isSibling && hasSameName;
});
}

const r1 = new TreeReplica<Id, FileName>("a", { conflictHandler });
const r2 = new TreeReplica<Id, FileName>("b", { conflictHandler });

const ids = {
root: newId(),
a: newId(),
b: newId()
};

const ops = r1.opMoves([
[ids.root, "root", "0"],
[ids.a, "a", ids.root],
[ids.b, "b", ids.root]
]);

r1.applyOps(ops);
r2.applyOps(ops);

// Replica 1 renames /root/a to /root/b, producing a conflict
let repl1Ops = [r1.opMove(ids.a, "b", ids.root)];

r1.applyOps(repl1Ops);
r2.applyOps(repl1Ops);

// The state is the same on both replicas, ignoring the operation that
// produced conflicting metadata state
expect(r1.state.toString()).toEqual(r2.state.toString());
expect(r1.state.tree.nodes.get(ids.a)?.metadata).toBe("a");
});

0 comments on commit 83197ae

Please sign in to comment.