Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 35 additions & 38 deletions src/ControlledOpSet.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,8 @@
import {Timestamp} from "./helper/Timestamp";
import {
asType,
mapMapToMap,
mapWith,
mapWithout,
RoArray,
RoMap,
} from "./helper/Collection";
import {asType, RoArray, RoMap} from "./helper/Collection";
import {TypedValue} from "./helper/TypedValue";
import {AssertFailed} from "./helper/Assert";
import {HashMap} from "prelude-ts";

export class DeviceId extends TypedValue<"DeviceId", string> {}

Expand Down Expand Up @@ -45,7 +39,7 @@ export type UndoOp<Value, AppliedOp extends AppliedOpBase> = (

type DesiredHeads<Value, AppliedOp extends AppliedOpBase> = (
value: Value,
) => RoMap<DeviceId, "open" | OpList<AppliedOp>>;
) => HashMap<DeviceId, "open" | OpList<AppliedOp>>;

export class ControlledOpSet<Value, AppliedOp extends AppliedOpBase> {
static create<Value, AppliedOp extends AppliedOpBase>(
Expand All @@ -60,7 +54,7 @@ export class ControlledOpSet<Value, AppliedOp extends AppliedOpBase> {
desiredHeads,
value,
undefined,
RoMap(),
HashMap.empty(),
);
}

Expand All @@ -71,28 +65,31 @@ export class ControlledOpSet<Value, AppliedOp extends AppliedOpBase> {

readonly value: Value,
readonly appliedHead: AppliedOpList<AppliedOp> | undefined,
readonly heads: RoMap<DeviceId, OpList<AppliedOp>>,
readonly heads: HashMap<DeviceId, OpList<AppliedOp>>,
) {}

update(
remoteHeads: RoMap<DeviceId, undefined | OpList<AppliedOp>>,
remoteHeads: HashMap<DeviceId, undefined | OpList<AppliedOp>>,
): ControlledOpSet<Value, AppliedOp> {
const abstractDesiredHeads = this.desiredHeads(this.value);
const filteredAbstractDesiredHeads = RoMap(
Array.from(abstractDesiredHeads.entries()).filter(
([deviceId]) => remoteHeads.get(deviceId) !== undefined,
),
);
const desiredHeads = mapMapToMap(
filteredAbstractDesiredHeads,
(deviceId, openOrOp) => [
deviceId,
asType<OpList<AppliedOp>>(
openOrOp === "open" ? remoteHeads.get(deviceId)! : openOrOp,
),
],
const filteredAbstractDesiredHeads = abstractDesiredHeads.filterKeys(
(deviceId) => remoteHeads.get(deviceId).getOrUndefined() !== undefined,
);
if (ControlledOpSet.headsEqual(desiredHeads, this.heads)) return this;
const desiredHeads = filteredAbstractDesiredHeads.map<
DeviceId,
OpList<AppliedOp>
>((deviceId, openOrOp) => [
deviceId,
asType<OpList<AppliedOp>>(
openOrOp === "open"
? remoteHeads
.get(deviceId)
.getOrThrow("There should be a remote head for this deviceId")!
: openOrOp,
),
]);

if (desiredHeads.equals(this.heads)) return this;

const {value, appliedHead, ops} = ControlledOpSet.commonStateAndDesiredOps(
this.undoOp,
Expand Down Expand Up @@ -131,8 +128,8 @@ export class ControlledOpSet<Value, AppliedOp extends AppliedOpBase> {
AppliedOp extends AppliedOpBase,
>(
undoOp: UndoOp<Value, AppliedOp>,
desiredHeads: RoMap<DeviceId, OpList<AppliedOp>>,
actualHeads: RoMap<DeviceId, OpList<AppliedOp>>,
desiredHeads: HashMap<DeviceId, OpList<AppliedOp>>,
actualHeads: HashMap<DeviceId, OpList<AppliedOp>>,
value: Value,
appliedHead: undefined | AppliedOpList<AppliedOp>,
): {
Expand All @@ -142,7 +139,7 @@ export class ControlledOpSet<Value, AppliedOp extends AppliedOpBase> {
} {
const ops = new Array<AppliedOp["op"]>();

while (!ControlledOpSet.headsEqual(desiredHeads, actualHeads)) {
while (!desiredHeads.equals(actualHeads)) {
const {heads: nextRemainingDesiredHeads, op: desiredOp} =
ControlledOpSet.undoHeadsOnce(desiredHeads);
const {heads: nextActualHeads, op: actualOp} =
Expand Down Expand Up @@ -205,25 +202,25 @@ export class ControlledOpSet<Value, AppliedOp extends AppliedOpBase> {
}

private static undoHeadsOnce<AppliedOp extends AppliedOpBase>(
heads: RoMap<DeviceId, OpList<AppliedOp>>,
heads: HashMap<DeviceId, OpList<AppliedOp>>,
): {
op: undefined | AppliedOp["op"];
heads: RoMap<DeviceId, OpList<AppliedOp>>;
heads: HashMap<DeviceId, OpList<AppliedOp>>;
} {
if (heads.size === 0) return {op: undefined, heads};
if (heads.length() === 0) return {op: undefined, heads};

const [newestDeviceId, newestOpList] = Array.from(heads.entries()).reduce(
(winner, current) => {
const [newestDeviceId, newestOpList] = heads
.reduce((winner, current) => {
return winner[1].op.timestamp > current[1].op.timestamp
? winner
: current;
},
);
})
.getOrThrow("Reducing over empty heads");
return {
op: newestOpList.op,
heads: newestOpList.prev
? mapWith(heads, newestDeviceId, newestOpList.prev)
: mapWithout(heads, newestDeviceId),
? heads.put(newestDeviceId, newestOpList.prev)
: heads.remove(newestDeviceId),
};
}

Expand Down
55 changes: 28 additions & 27 deletions src/PermissionedTree.monkey.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,5 @@
import seedRandom from "seed-random";
import {
asType,
definedOrThrow,
mapMapToMap,
mapWith,
RoArray,
RoMap,
} from "./helper/Collection";
import {asType, definedOrThrow, RoArray} from "./helper/Collection";
import {
AppliedOp,
createPermissionedTree,
Expand All @@ -16,6 +9,7 @@ import {
import {DeviceId} from "./ControlledOpSet";
import {Clock} from "./helper/Clock";
import {Timestamp} from "./helper/Timestamp";
import {HashMap} from "prelude-ts";
import {expectPreludeEqual} from "./helper/Shared.testing";

type OpType = "add" | "move" | "update";
Expand Down Expand Up @@ -86,11 +80,11 @@ describe("PermissionedTree.monkey", function () {
priority: -1,
status: "open",
});
let devices = RoMap<DeviceId, PermissionedTree>(
Array.from(Array(4).keys()).map((index) => [
DeviceId.create(`device${index}`),
initialTree,
]),
let devices = HashMap<DeviceId, PermissionedTree>.of(
[DeviceId.create("device1"), initialTree],
[DeviceId.create("device2"), initialTree],
[DeviceId.create("device3"), initialTree],
[DeviceId.create("device4"), initialTree],
);

for (let turn = 0; turn < 10000; turn++) {
Expand All @@ -107,38 +101,42 @@ describe("PermissionedTree.monkey", function () {

// Update a device.
{
const device = randomInArray(rand, Array.from(devices.keys()))!;
const tree = devices.get(device)!;
const device = randomInArray(rand, Array.from(devices.keySet()))!;
const tree = devices.get(device).getOrThrow("Device should be listed");
const opType = randomOpType(rand);

log(`turn ${turn}, device ${device}, op type ${opType}`);
if (opType === "update") {
const remoteHeads = mapMapToMap(devices, (device, tree) => [
const remoteHeads = devices.map((device, tree) => [
device,
tree.heads.get(device),
tree.heads
.get(device)
.getOrThrow("Couldn't get device in tree heads"),
]);
const tree1 = tree.update(remoteHeads);
devices = mapWith(devices, device, tree1);
devices = devices.put(device, tree1);
} else {
const op = opForOpType(clock, log, rand, opType, tree);
const tree1 = applyNewOp(tree, device, op);
devices = mapWith(devices, device, tree1);
devices = devices.put(device, tree1);
}
}

// Check the devices.
if (turn % 10 === 0) {
const remoteHeads = mapMapToMap(devices, (device, tree) => [
const remoteHeads = devices.map((device, tree) => [
device,
tree.heads.get(device),
tree.heads
.get(device)
.getOrThrow("Couldn't retrieve device from tree heads"),
]);
const devices1 = mapMapToMap(devices, (device, tree) => [
const devices1 = devices.map((device, tree) => [
device,
tree.update(remoteHeads),
]);
// for (const [, tree] of devices1) {
// expectPreludeEqual(tree, Array.from(devices1.values())[0]);
// }
for (const [, tree] of devices1) {
expectPreludeEqual(tree, Array.from(devices1.values())[0]);
}
}
}
});
Expand Down Expand Up @@ -190,7 +188,10 @@ function applyNewOp(
device: DeviceId,
op: AppliedOp["op"],
): PermissionedTree {
const opList1 = {prev: tree.heads.get(device), op};
const heads1 = mapWith(tree.heads, device, opList1);
const opList1 = {
prev: tree.heads.get(device).getOrThrow("Couldn't construct op list 1"),
op,
};
const heads1 = tree.heads.put(device, opList1);
return tree.update(heads1);
}
56 changes: 33 additions & 23 deletions src/PermissionedTree.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import {
asType,
definedOrThrow,
mapMapToMap,
mapWith,
RoMap,
} from "./helper/Collection";
import {asType} from "./helper/Collection";
import {ControlledOpSet, DeviceId, OpList} from "./ControlledOpSet";
import {Timestamp} from "./helper/Timestamp";
import {TypedValue} from "./helper/TypedValue";
Expand Down Expand Up @@ -45,11 +39,27 @@ class ParentPos {
}
}

class PriorityStatus {
public priority: number;
public status: "open" | OpList<AppliedOp>;
constructor(priority: number, status: "open" | OpList<AppliedOp>) {
this.priority = priority;
this.status = status;
}
equals(other: PriorityStatus | undefined): boolean {
if (!other) return false;
return (
areEqual(this.priority, other.priority) &&
areEqual(this.status, other.status)
);
}
hashCode(): number {
return fieldsHashCode(this.priority, this.status);
}
}

type PermissionedTreeValue = {
writers: RoMap<
DeviceId,
{priority: number; status: "open" | OpList<AppliedOp>}
>;
writers: HashMap<DeviceId, PriorityStatus>;
nodes: HashMap<NodeId, ParentPos>;
};
export class NodeId extends TypedValue<"NodeId", string> {}
Expand Down Expand Up @@ -78,21 +88,22 @@ export function createPermissionedTree(owner: DeviceId): PermissionedTree {
return ControlledOpSet<PermissionedTreeValue, AppliedOp>.create(
persistentDoOpFactory((value, op) => {
if (op.type === "set writer") {
const devicePriority = definedOrThrow(
value.writers.get(op.device),
"Cannot find writer entry for op author",
).priority;
const writerPriority = value.writers.get(op.targetWriter)?.priority;
const devicePriority = value.writers
.get(op.device)
.getOrThrow("Cannot find writer entry for op author").priority;
const writerPriority = value.writers
.get(op.targetWriter)
.getOrUndefined()?.priority;
if (writerPriority !== undefined && writerPriority >= devicePriority)
return value;
if (op.priority >= devicePriority) return value;

return {
...value,
writers: mapWith(value.writers, op.targetWriter, {
priority: op.priority,
status: op.status,
}),
writers: value.writers.put(
op.targetWriter,
new PriorityStatus(op.priority, op.status),
),
};
} else {
if (ancestor(value.nodes, op.node, op.parent)) return value;
Expand All @@ -106,10 +117,9 @@ export function createPermissionedTree(owner: DeviceId): PermissionedTree {
}
}),
persistentUndoOp,
(value) =>
mapMapToMap(value.writers, (device, info) => [device, info.status]),
(value) => value.writers.mapValues((info) => info.status),
asType<PermissionedTreeValue>({
writers: RoMap([[owner, {priority: 0, status: "open"}]]),
writers: HashMap.of([owner, new PriorityStatus(0, "open")]),
nodes: HashMap.empty(),
}),
);
Expand Down
4 changes: 3 additions & 1 deletion src/helper/TypedValue.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export class TypedValue<Name extends string, T> {
import {HasEquals} from "prelude-ts";

export class TypedValue<Name extends string, T> implements HasEquals {
static create<Name extends string, T>(
this: new (raw: T) => TypedValue<Name, T>,
raw: T,
Expand Down