perf(chord): cheaper container copies in Delta's tracker and applyImmutable

Tracker revisions, applyImmutable() and applyImmutableBatches() copy each container on a changed path with
spread (Object.assign() onto a null-prototype object) instead of one Object.defineProperty() per key: same own
data properties, prototype and own __proto__ member, about 25 times faster per container. The copy-count test
counts key listings through a proxy instead of spying on Object.keys.

Adds a tracker GC regression test: 10,000 adopted changes retain only the document, not a context registration
per change. Follow-up to #9265.
This commit is contained in:
Mario Zechner 2026-10-24 16:56:53 +00:00
parent f321e5bdd2
commit d0aa218cb1
6 changed files with 72 additions and 30 deletions

View file

@ -17,6 +17,10 @@
- `ownedSlice(text, start, end?)` in `@earendil-works/chord/delta`: a slice of a string that does not keep the whole string alive ([#9265](https://github.com/earendil-works/pi/issues/9265))
- `appendText(draft, key, text)` in `@earendil-works/chord/delta`: append to a string in a draft at O(text) cost, recorded as an append op without comparing the old and new strings (a plain `draft[key] += text` makes `prepare()` read the whole string, O(length) per change); `StringKeys<T>` names the keys it accepts ([#9265](https://github.com/earendil-works/pi/issues/9265))
### Changed
- `applyImmutable()`, `applyImmutableBatches()` and tracker revisions copy each container on a changed path with spread (or `Object.assign()` onto a null-prototype object) instead of one `Object.defineProperty()` per key, about 25 times faster per container ([#9265](https://github.com/earendil-works/pi/issues/9265))
### Fixed
- Fixed append ops from the delta tracker and `diffRevisions()` keeping the whole string they were cut from alive (V8 represents a slice as a view of its source), so a delta chain, a watch backlog or a replica built from appends to a growing text held one copy of the text per commit ([#9265](https://github.com/earendil-works/pi/issues/9265))

View file

@ -86,12 +86,9 @@ function copyPath(root: JsonValue, path: Path, length: number, owned: WeakSet<ob
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;
// Spread and `Object.assign()` onto a null-prototype object define own data properties, `__proto__` included, as
// `defineData()` per key does, at a fraction of its cost.
return Object.getPrototypeOf(value) === null ? Object.assign(Object.create(null), value) : { ...value };
}
function read(target: JsonContainer, key: Seg): JsonValue {

View file

@ -490,19 +490,9 @@ function copyContainers(root: JsonValue, path: Path, owned: WeakSet<object>): Js
const copy = (value: JsonValue): JsonValue[] | Record<string, JsonValue> => {
if (Array.isArray(value)) return value.slice();
if (!isObj(value)) throw new PathError(path);
const result = Object.create(Object.getPrototypeOf(value) === null ? null : Object.prototype) as Record<
string,
JsonValue
>;
for (const key of Object.keys(value)) {
Object.defineProperty(result, key, {
value: (value as Record<string, JsonValue>)[key],
writable: true,
enumerable: true,
configurable: true,
});
}
return result;
// Spread and `Object.assign()` onto a null-prototype object define own data properties, `__proto__` included,
// as `Object.defineProperty()` per key does, at a fraction of its cost.
return Object.getPrototypeOf(value) === null ? Object.assign(Object.create(null), value) : { ...value };
};
if (!isObj(root)) throw new PathError(path);
let copiedRoot: JsonValue[] | Record<string, JsonValue>;

View file

@ -246,6 +246,30 @@ describe("checked immutable operation application", () => {
});
});
describe("immutable container copies", () => {
it("keep an own __proto__ member as data and the prototype of the copied object", () => {
const plain = JSON.parse('{"__proto__":{"polluted":true},"n":1}') as Record<string, JsonValue>;
const bare = Object.assign(Object.create(null) as Record<string, JsonValue>, { n: 1 });
for (const base of [plain, bare]) {
const checked = applyImmutable(base, [["s", ["n"], 2]]);
// Tracker revisions copy through the trusted path.
const tracker = track(base);
const change = tracker.beginChange();
change.state.n = 2;
tracker.adopt(change.prepare());
for (const copy of [checked, tracker.value]) {
expect(copy).not.toBe(base);
expect(Object.getPrototypeOf(copy)).toBe(Object.getPrototypeOf(base));
expect(Object.keys(copy)).toEqual(Object.keys(base));
expect(copy.n).toBe(2);
if (base === plain)
expect(Object.getOwnPropertyDescriptor(copy, "__proto__")?.value).toEqual({ polluted: true });
}
}
expect(({} as { polluted?: boolean }).polluted).toBeUndefined();
});
});
describe("immutable application complexity", () => {
it("appends to an array not yet copied in one copy, sharing everything else", () => {
const entries = Array.from({ length: 1_000 }, (_, index) => ({ index }));
@ -291,18 +315,15 @@ describe("immutable application complexity", () => {
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;
// A copy lists the object's keys; the proxy counts the listings.
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;
}
const counted = new Proxy(base, {
ownKeys(target) {
shallowCopies++;
return Reflect.ownKeys(target);
},
});
const result = applyImmutable(counted, operations);
expect(result.field999).toBe(-999);
expect(base.field999).toBe(999);

View file

@ -18,6 +18,7 @@ describe("delta tracker retention across GC jobs", () => {
"same-job-fast-cleanup",
"same-job-folded-ops-cleanup",
"lifecycle-churn",
"adopted-changes-flat",
])(
"validates %s retention semantics",
(scenario) => {

View file

@ -260,6 +260,35 @@ if (scenario === "settled-lifecycle") {
const retainedMiB = (process.memoryUsage().heapUsed - readyHeap) / (1024 * 1024);
assert.ok(retainedMiB < 8, `same-job folded operations retained ${retainedMiB.toFixed(2)} MiB`);
assert.equal(tracker.value.field0, 100);
} else if (scenario === "adopted-changes-flat") {
// Each change registers its context with the tracker through a WeakRef; adoption must drop every registration, so
// a long run of small changes keeps only the document, not a record per change.
const gc = global.gc;
assert.ok(gc, "worker requires --expose-gc");
const tracker = track({ entries: [{ id: 0, text: "" }] });
const commit = (): void => {
const change = tracker.beginChange();
change.state.entries[0]!.text += " tok";
tracker.adopt(change.prepare());
};
const settledHeap = async (): Promise<number> => {
for (let round = 0; round < 5; round++) {
await setImmediate();
gc();
}
return process.memoryUsage().heapUsed;
};
for (let index = 0; index < 1_000; index++) commit();
const readyHeap = await settledHeap();
for (let index = 0; index < 10_000; index++) {
commit();
// Let WeakRef targets go, as between the jobs of real commits.
if (index % 100 === 0) await setImmediate();
}
// The text itself grows 4 bytes per change.
const perChange = ((await settledHeap()) - readyHeap) / 10_000 - 4;
assert.ok(perChange < 48, `each adopted change retained ${perChange.toFixed(1)} bytes`);
assert.equal(tracker.value.entries[0]!.text.length, 4 * 11_000);
} else if (scenario === "lifecycle-churn") {
const tracker = track({ value: 0 });
for (let index = 0; index < 100_000; index++) {