feat(chord): optimize immutable batch application

This commit is contained in:
Mario Zechner 2026-09-25 00:08:32 +02:00
parent b2bd111f2d
commit 9e70c3d505
6 changed files with 329 additions and 93 deletions

View file

@ -7,7 +7,7 @@ Immutability is an ownership contract. Nothing is frozen or defensively copied,
so an illegal mutation is not detected. It silently corrupts state.
```ts
import { applyImmutable, track } from "@earendil-works/chord/delta";
import { applyImmutable, applyImmutableBatches, track } from "@earendil-works/chord/delta";
const initial = { output: "", entries: [] as { id: number }[] };
const tracker = track(initial); // `initial` is transferred: never mutate it again
@ -34,7 +34,7 @@ const replica = applyImmutable(prepared.base, prepared.ops);
| External value after assigning or inserting it into a draft | Yes. The draft stored a validated clone. |
| `tracker.value`, `prepared.base`, retained older revisions | No. |
| `prepared.value`, `prepared.ops`, op tuples, paths, permutations, payloads | No. Payloads may be the same objects as parts of `prepared.value`. |
| `applyImmutable()` inputs and result | No. The result shares containers with both inputs. |
| `applyImmutable()` / `applyImmutableBatches()` inputs and result | No. The result shares containers with both inputs. |
| Values from replicated state (`value`, listener values, loopback consumers) | No. In-process consumers may share the provider's containers. |
| Mutable replica passed to `apply()` | Only through `apply()`, with batches it owns exclusively. Code that edits it between batches breaks convergence. |
| Batch passed to `apply()` | Consumed. `apply()` adopts payload containers into the replica. Use a detached copy for exactly one replica and never touch it again. |
@ -50,6 +50,20 @@ let replica = tracker.value; // shared with authority; never mutated
replica = applyImmutable(replica, prepared.ops); // replica must equal prepared.base
```
When only the final result of an ordered backlog is needed, replay its batches
without concatenating their operations. One copy-on-write scope is shared across
the complete call, so no intermediate revision is exposed or safe to retain:
```ts
replica = applyImmutableBatches(
replica,
queuedFrames.map((frame) => frame.ops),
);
```
Use separate `applyImmutable()` calls when every intermediate revision is
published or retained.
Mutable replay needs a detached starting root and a detached copy of every batch
for every replica. Never apply one in-memory batch to two mutable replicas:
@ -155,8 +169,8 @@ and large edit sets may fold into a region splice, an ancestor `s`, or `r`.
Reserved keys: the tracker never emits `__proto__`, `constructor`, or
`prototype` as a path segment. A mutation at or below such a key is folded into a
set of the nearest safe ancestor, or `r` at the root. `apply()`,
`applyImmutable()`, and `decoder()` reject those segments with `UnsafePathError`.
set of the nearest safe ancestor, or `r` at the root. `apply()`, `applyImmutable()`, `applyImmutableBatches()`, and `decoder()` reject
those segments with `UnsafePathError`.
The appliers write values as own data properties, never through a prototype setter.
Appliers check op shape and path safety but not payload strictness. Chord's
replicated-state replicas validate each resulting revision.

View file

@ -1,71 +0,0 @@
import type { JsonValue } from "../types.ts";
import {
apply as applyMutable,
assertValidOp,
type Op,
type Path,
PathError,
type Seg,
UnsafePathError,
} from "./index.ts";
type JsonContainer = JsonValue[] | Record<string, JsonValue>;
/** Apply one batch while copying each touched container at most once. */
export function applyImmutable<T>(target: T | undefined, operations: readonly Op[]): T {
let root = target as unknown as JsonValue;
const owned = new WeakSet<object>();
for (const operation of operations) {
assertValidOp(operation);
if (operation[0] === "r") {
root = operation[1];
continue;
}
const path = operation[0] === "p" || operation[0] === "m" ? operation[1] : operation[1].slice(0, -1);
root = copyPath(root, path, owned);
root = applyMutable(root, [operation]);
}
return root as unknown as T;
}
function copyPath(root: JsonValue, path: Path, owned: WeakSet<object>): JsonValue {
if (!isContainer(root)) throw new PathError(path);
let copiedRoot = root;
if (!owned.has(root)) {
copiedRoot = shallowCopy(root);
owned.add(copiedRoot);
}
let destination = copiedRoot as JsonContainer;
for (const segment of path) {
if (Array.isArray(destination) && typeof segment !== "number") throw new UnsafePathError(segment);
if (!Object.hasOwn(destination, segment)) throw new PathError(path);
let child = (destination as Record<Seg, JsonValue>)[segment]!;
if (!isContainer(child)) throw new PathError(path);
if (!owned.has(child)) {
const copy = shallowCopy(child);
defineData(destination, segment, copy);
owned.add(copy);
child = copy;
}
destination = child;
}
return copiedRoot;
}
function shallowCopy(value: JsonContainer): JsonContainer {
if (Array.isArray(value)) return Array.from(value);
const result = Object.create(Object.getPrototypeOf(value) === null ? null : Object.prototype) as Record<
string,
JsonValue
>;
for (const key of Object.keys(value)) defineData(result, key, value[key]!);
return result;
}
function defineData(target: object, key: PropertyKey, value: JsonValue): void {
Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true });
}
function isContainer(value: unknown): value is JsonContainer {
return value !== null && typeof value === "object";
}

View file

@ -405,22 +405,35 @@ function applyOps<T>(target: T | undefined, ops: readonly Op[]): T {
return root as unknown as T;
}
/** Apply decoded operations without mutating the previous immutable value. */
/** Apply one decoded operation batch without mutating the previous immutable value. */
export function applyImmutable<T>(target: T | undefined, ops: readonly Op[]): T {
return applyImmutableBatches(target, [ops]);
}
/**
* Apply decoded operation batches as one final-result-only replay.
*
* Containers copied for an earlier batch may be mutated while applying a later
* batch, so this deliberately exposes no intermediate revisions.
*/
export function applyImmutableBatches<T>(target: T | undefined, batches: Iterable<readonly Op[]>): T {
let root = target as unknown as JsonValue;
for (const op of ops) {
assertValidOp(op);
if (op[0] === "r") {
root = op[1];
continue;
const owned = new WeakSet<object>();
for (const ops of batches) {
for (const op of ops) {
assertValidOp(op);
if (op[0] === "r") {
root = op[1];
continue;
}
root = copyContainers(root, op[0] === "p" || op[0] === "m" ? op[1] : op[1].slice(0, -1), owned);
root = applyOps(root, [op]);
}
root = copyContainers(root, op[0] === "p" || op[0] === "m" ? op[1] : op[1].slice(0, -1));
root = applyOps(root, [op]);
}
return root as unknown as T;
}
function copyContainers(root: JsonValue, path: Path): JsonValue {
function copyContainers(root: JsonValue, path: Path, owned: WeakSet<object>): JsonValue {
const copy = (value: JsonValue): JsonValue[] | Record<string, JsonValue> => {
if (Array.isArray(value)) return value.slice();
if (!isObj(value)) throw new PathError(path);
@ -438,13 +451,23 @@ function copyContainers(root: JsonValue, path: Path): JsonValue {
}
return result;
};
const copiedRoot = copy(root);
let source = root;
if (!isObj(root)) throw new PathError(path);
let copiedRoot: JsonValue[] | Record<string, JsonValue>;
if (owned.has(root)) copiedRoot = root as JsonValue[] | Record<string, JsonValue>;
else {
copiedRoot = copy(root);
owned.add(copiedRoot);
}
let destination = copiedRoot;
for (const segment of path) {
if (!isObj(source) || !Object.hasOwn(source, segment)) throw new PathError(path);
if (Array.isArray(source) && typeof segment !== "number") throw new UnsafePathError(segment);
const child = (source as Record<Seg, JsonValue>)[segment]!;
if (!Object.hasOwn(destination, segment)) throw new PathError(path);
if (Array.isArray(destination) && typeof segment !== "number") throw new UnsafePathError(segment);
const child = (destination as Record<Seg, JsonValue>)[segment]!;
if (!isObj(child)) throw new PathError(path);
if (owned.has(child)) {
destination = child as JsonValue[] | Record<string, JsonValue>;
continue;
}
const copiedChild = copy(child);
Object.defineProperty(destination, segment, {
value: copiedChild,
@ -452,7 +475,7 @@ function copyContainers(root: JsonValue, path: Path): JsonValue {
enumerable: true,
configurable: true,
});
source = child;
owned.add(copiedChild);
destination = copiedChild;
}
return copiedRoot;

View file

@ -1,9 +1,8 @@
import { copyJson } from "../json.ts";
import type { JsonValue } from "../types.ts";
import { applyImmutable } from "./apply-immutable-batch.ts";
import { applyImmutableTrusted } from "./apply-immutable-trusted.ts";
import type { Draft } from "./draft.ts";
import { type NonEmptyPath, type Op, overlap, type Path, RESERVED_SEGMENTS } from "./index.ts";
import { applyImmutable, type NonEmptyPath, type Op, overlap, type Path, RESERVED_SEGMENTS } from "./index.ts";
export type { Draft, JsonValue, Op };

View file

@ -0,0 +1,272 @@
import { describe, expect, it } from "vitest";
import {
apply,
applyImmutable,
applyImmutableBatches,
type JsonValue,
type Op,
PathError,
track,
UnsafePathError,
} from "../src/delta/index.ts";
function freezeDeep<T>(value: T): T {
if (value === null || typeof value !== "object" || Object.isFrozen(value)) return value;
for (const child of Object.values(value)) freezeDeep(child);
return Object.freeze(value);
}
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
describe("checked immutable operation application", () => {
it("copies each touched container once while preserving input and payload ownership", () => {
const shared = freezeDeep({ nested: { value: 1 } });
const untouched = freezeDeep({ value: 9 });
const rowPayload = freezeDeep({ id: 4, label: "placed" });
const base = freezeDeep({
text: "abcdef",
stable: { value: 7 },
branch: { value: 1 },
copy: null as { value: number } | null,
placed: null as typeof shared | null,
untouched: null as typeof untouched | null,
left: null as typeof shared | null,
right: null as typeof shared | null,
meta: { count: 0, obsolete: true },
rows: [
{ id: 1, label: "one" },
{ id: 2, label: "two" },
{ id: 3, label: "three" },
],
});
const operations = freezeDeep([
["t", ["text"], 2],
["a", ["text"], "!"],
["s", ["meta", "count"], 1],
["s", ["meta", "count"], 2],
["d", ["meta", "obsolete"]],
["s", ["copy"], base.branch],
["s", ["copy", "value"], 2],
["s", ["placed"], shared],
["s", ["placed", "nested", "value"], 2],
["s", ["untouched"], untouched],
["s", ["left"], shared],
["s", ["right"], shared],
["s", ["left", "nested", "value"], 3],
["p", ["rows"], 1, 1, [rowPayload]],
["s", ["rows", 1, "label"], "edited"],
["m", ["rows"], [1, 0, 2]],
["s", ["rows", 0, "label"], "moved"],
] satisfies Op[]);
const result = applyImmutable(base, operations);
const mutableResult = apply(clone(base), clone(operations));
expect(result).toEqual(mutableResult);
expect(result.text).toBe("cdef!");
expect(result.stable).toBe(base.stable);
expect(result.untouched).toBe(untouched);
expect(result.copy).not.toBe(base.branch);
expect(result.copy).toEqual({ value: 2 });
expect(result.placed).not.toBe(shared);
expect(result.placed).toEqual({ nested: { value: 2 } });
expect(result.left).not.toBe(shared);
expect(result.right).toBe(shared);
expect(result.rows[0]).not.toBe(rowPayload);
expect(result.rows[0]).toEqual({ id: 4, label: "moved" });
expect(base.branch.value).toBe(1);
expect(shared.nested.value).toBe(1);
expect(rowPayload.label).toBe("placed");
});
it("protects root replacement payloads before later object and array edits", () => {
const replacement = freezeDeep({ nested: { value: 1 }, values: [1, 2, 3] });
const result = applyImmutableBatches<typeof replacement>(undefined, [
[["r", replacement]],
[["s", ["nested", "value"], 2]],
[
["p", ["values"], 1, 1, [4, 5]],
["m", ["values"], [3, 0, 1, 2]],
],
]);
expect(result).toEqual({ nested: { value: 2 }, values: [3, 1, 4, 5] });
expect(replacement).toEqual({ nested: { value: 1 }, values: [1, 2, 3] });
const array = freezeDeep([1, 2, 3]);
const arrayResult = applyImmutable<number[]>(array, [
["p", [], 1, 1, [4, 5]],
["m", [], [3, 0, 1, 2]],
["d", [1]],
]);
expect(arrayResult).toEqual([3, 4, 5]);
expect(array).toEqual([1, 2, 3]);
});
it("shares one private copy-on-write scope across batch partitions", () => {
const base = freezeDeep({
text: "abcdef",
meta: { count: 0 },
values: [
{ id: 1, value: 1 },
{ id: 2, value: 2 },
{ id: 3, value: 3 },
],
});
const batches = [
[
["s", ["meta", "count"], 1],
["p", ["values"], 1, 1, [{ id: 4, value: 4 }]],
] satisfies Op[],
[] satisfies Op[],
[
["m", ["values"], [2, 0, 1]],
["s", ["values", 2, "value"], 40],
] satisfies Op[],
[
["t", ["text"], 2],
["a", ["text"], "!"],
] satisfies Op[],
];
const intermediate = applyImmutable(base, batches[0]);
const intermediateSnapshot = clone(intermediate);
let sequential = intermediate;
for (let index = 1; index < batches.length; index++) sequential = applyImmutable(sequential, batches[index]!);
const streamed = applyImmutableBatches(base, batches);
const flattened = applyImmutable(base, batches.flat());
expect(streamed).toEqual(sequential);
expect(streamed).toEqual(flattened);
expect(intermediate).toEqual(intermediateSnapshot);
expect(base.meta.count).toBe(0);
expect(base.values[1]!.id).toBe(2);
});
it("handles tracker-produced batches across arbitrary revision boundaries", () => {
type Document = { text: string; values: { id: number; score: number }[]; revision: number };
const initial: Document = {
text: "start",
values: Array.from({ length: 8 }, (_, id) => ({ id, score: 0 })),
revision: 0,
};
const tracker = track(initial);
const batches: Array<readonly Op[]> = [];
for (let revision = 1; revision <= 40; revision++) {
const change = tracker.beginChange();
change.state.revision = revision;
change.state.text = `${change.state.text.slice(1)}${revision}`;
switch (revision % 5) {
case 0:
change.state.values.reverse();
break;
case 1:
change.state.values.push({ id: 100 + revision, score: revision });
break;
case 2:
change.state.values.shift();
break;
case 3:
change.state.values[revision % change.state.values.length]!.score = revision;
break;
default:
change.state.values.splice(1, 1, { id: 200 + revision, score: revision });
}
const prepared = change.prepare();
batches.push(prepared.ops);
tracker.adopt(prepared);
}
expect(applyImmutableBatches(initial, batches)).toEqual(tracker.value);
});
it("does not expose partial application when validation or iteration fails", () => {
const base = freezeDeep({ nested: { value: 1 } });
const invalidBatches: readonly (readonly Op[])[] = [
[["s", ["nested", "value"], 2]],
[["s", ["constructor", "prototype", "polluted"], true]],
];
expect(() => applyImmutableBatches(base, invalidBatches)).toThrow();
expect(base.nested.value).toBe(1);
expect(({} as { polluted?: boolean }).polluted).toBeUndefined();
function* throwingBatches(): Generator<readonly Op[]> {
yield [["s", ["nested", "value"], 3]];
throw new Error("revision stream failed");
}
expect(() => applyImmutableBatches(base, throwingBatches())).toThrow("revision stream failed");
expect(base.nested.value).toBe(1);
let advancedPastInvalid = false;
let iteratorClosed = false;
function* invalidOperationBatches(): Generator<readonly Op[]> {
try {
yield [["s", ["nested", "value"], 4]];
yield [["s", ["__proto__", "polluted"], true]];
advancedPastInvalid = true;
} finally {
iteratorClosed = true;
}
}
expect(() => applyImmutableBatches(base, invalidOperationBatches())).toThrow(UnsafePathError);
expect(advancedPastInvalid).toBe(false);
expect(iteratorClosed).toBe(true);
expect(base.nested.value).toBe(1);
let reads = 0;
const target = Object.defineProperty({}, "trap", {
enumerable: true,
get() {
reads++;
return {};
},
});
const malformed: Op[] = [["s", "bad-path", 1] as never];
expect(() => applyImmutableBatches(target, [malformed])).toThrow(/path/);
expect(reads).toBe(0);
expect(() => applyImmutable({ values: [] }, [["s", ["values", "missing", "value"], 1]])).toThrow(PathError);
expect(() => applyImmutable({ values: [{}] }, [["s", ["values", "0", "value"], 1]])).toThrow(UnsafePathError);
});
it("allows one immutable batch to fan out without mutating shared payloads", () => {
const payload = freezeDeep({ nested: { value: 1 } });
const operations: readonly Op[] = [
["s", ["placed"], payload],
["s", ["placed", "nested", "value"], 2],
];
const base = freezeDeep({ placed: null as typeof payload | null });
const first = applyImmutable(base, operations);
const second = applyImmutable(base, operations);
expect(first).toEqual(second);
expect(first).not.toBe(second);
expect(first.placed).not.toBe(second.placed);
expect(payload.nested.value).toBe(1);
});
});
describe("immutable application complexity", () => {
it("copies a wide object once rather than once per repeated write", () => {
const width = 20_000;
const base: Record<string, JsonValue> = {};
for (let index = 0; index < width; index++) base[`field${index}`] = index;
const operations: Op[] = Array.from({ length: 1_000 }, (_, index): Op => ["s", [`field${index}`], -index]);
const originalKeys = Object.keys;
let shallowCopies = 0;
Object.keys = ((value: object): string[] => {
shallowCopies++;
return originalKeys(value);
}) as typeof Object.keys;
let result: Record<string, JsonValue>;
try {
result = applyImmutable(base, operations);
} finally {
Object.keys = originalKeys;
}
expect(result.field999).toBe(-999);
expect(base.field999).toBe(999);
expect(shallowCopies).toBe(1);
});
});

View file

@ -95,7 +95,6 @@ function sources() {
"../src/delta/draft.ts",
"../src/delta/diff.ts",
"../src/delta/revision-validator.ts",
"../src/delta/apply-immutable-batch.ts",
"../src/delta/apply-immutable-trusted.ts",
"./delta-traversal.bench.ts",
].map((path) => [