mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(chord): Delta's tracker holds its one open change without a WeakRef
Chord and Durable change a document one transaction at a time, so a tracker registered a WeakRef per change for competitors that never existed on their hot path. The WeakRef targets survived scavenges and V8 could pretenure every overlay context into old space (47.5 MB promoted against 2 MB in a tracker loop). The tracker now keeps its open change in a plain field and moves it into the weak registry only when a second change opens; stale semantics are unchanged. A draft dropped without prepare or abort stays referenced until the tracker's next change or adopt (at most one per tracker). `stored` and `rawNodes` are created on first use.
This commit is contained in:
parent
85c2ee3e76
commit
a92283056d
6 changed files with 227 additions and 38 deletions
|
|
@ -24,6 +24,7 @@
|
|||
|
||||
### Changed
|
||||
|
||||
- The delta tracker keeps its one open change in a plain field and needs a `WeakRef` only for a second change open at the same time (one per change before). A `WeakRef` target survives the scavenges of its job, so a run of changes in one job promoted every change's overlay to old space: 200,000 small changes in one job now promote 2 MB instead of 47.5 MB and take 3.1 µs each instead of 3.8 µs (3.0 instead of 3.5 µs when they span jobs). A draft dropped without `prepare()` or `abort()` now stays referenced by its tracker until the tracker's next `beginChange()` or `adopt()`.
|
||||
- `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
|
||||
|
|
|
|||
|
|
@ -154,6 +154,8 @@ Growing `length` inserts `null`; shrinking it removes elements.
|
|||
|
||||
`tracker.value` is the latest adopted revision. `beginChange()` opens an overlay
|
||||
draft over it and never modifies it. A draft may stay open across `await`.
|
||||
A draft dropped without `prepare()` or `abort()` stays referenced by its
|
||||
tracker until the tracker's next `beginChange()` or `adopt()`.
|
||||
`prepare()` materializes the candidate and ops and does not change authority.
|
||||
`adopt()` checks the preparation and swaps the root pointer.
|
||||
|
||||
|
|
|
|||
|
|
@ -229,9 +229,20 @@ class ChangeImpl<T extends object> implements Change<T> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open drafts are tracked so that adopting one change makes its open competitors stale at once. Prepared contexts need
|
||||
* no tracking: `prepare()` releases their overlay, and `adopt()` rejects them by revision.
|
||||
*
|
||||
* The most recent open context is held strongly in `#open`: with one transaction at a time per tracker, the common case,
|
||||
* that is the only one, and it costs no `WeakRef` (each `WeakRef` target survives the scavenges of the current job, and
|
||||
* V8 then pretenures the context allocation site). A second `beginChange()` while one is open moves the older context
|
||||
* into `#competitors` through a `WeakRef`, so a draft the caller drops can be collected. A dropped draft that was never
|
||||
* settled stays held by `#open` until the tracker's next `beginChange()` or `adopt()`: at most one per tracker.
|
||||
*/
|
||||
class TrackerImpl<T extends object> implements Tracker<T> {
|
||||
readonly #owner = {};
|
||||
readonly #contexts = new Set<WeakRef<OverlayContext>>();
|
||||
#open: OverlayContext | undefined;
|
||||
#competitors: Set<WeakRef<OverlayContext>> | undefined;
|
||||
readonly #selfRef: WeakRef<TrackerImpl<object>>;
|
||||
#value: T;
|
||||
#revision = 0;
|
||||
|
|
@ -252,14 +263,15 @@ class TrackerImpl<T extends object> implements Tracker<T> {
|
|||
|
||||
beginChange(): Change<T> {
|
||||
const context = createContext(this.#selfRef, this.#owner, this.#revision, this.#value, false, this.#value);
|
||||
this.#register(context);
|
||||
if (this.#open !== undefined) this.#demote(this.#open);
|
||||
this.#open = context;
|
||||
return new ChangeImpl(context);
|
||||
}
|
||||
|
||||
prepareReplace(value: T): Prepared<T> {
|
||||
// Not tracked: materialization below releases the context before this returns, as `prepare()` does.
|
||||
const context = createContext(this.#selfRef, this.#owner, this.#revision, value, true, this.#value);
|
||||
context.status.value = "prepared";
|
||||
this.#register(context);
|
||||
try {
|
||||
return materializePrepared<T>(context);
|
||||
} catch (error) {
|
||||
|
|
@ -295,39 +307,37 @@ class TrackerImpl<T extends object> implements Tracker<T> {
|
|||
}
|
||||
|
||||
releaseContext(context: OverlayContext): void {
|
||||
if (context.registryRef !== undefined) this.#contexts.delete(context.registryRef);
|
||||
if (this.#open === context) this.#open = undefined;
|
||||
else if (context.registryRef !== undefined) this.#competitors?.delete(context.registryRef);
|
||||
context.registryRef = undefined;
|
||||
}
|
||||
|
||||
#register(context: OverlayContext): void {
|
||||
#demote(context: OverlayContext): void {
|
||||
const reference = new WeakRef(context);
|
||||
context.registryRef = reference;
|
||||
this.#contexts.add(reference);
|
||||
this.#competitors ??= new Set();
|
||||
this.#competitors.add(reference);
|
||||
this.#pruneBudget -= 1;
|
||||
if (this.#pruneBudget === 0) {
|
||||
this.#prune();
|
||||
this.#pruneBudget = Math.max(256, this.#contexts.size);
|
||||
}
|
||||
}
|
||||
|
||||
#prune(): void {
|
||||
for (const reference of this.#contexts) {
|
||||
if (reference.deref() === undefined) this.#contexts.delete(reference);
|
||||
for (const entry of this.#competitors) {
|
||||
if (entry.deref() === undefined) this.#competitors.delete(entry);
|
||||
}
|
||||
this.#pruneBudget = Math.max(256, this.#competitors.size);
|
||||
}
|
||||
}
|
||||
|
||||
#invalidate(winner: OverlayContext): void {
|
||||
for (const reference of this.#contexts) {
|
||||
const context = reference.deref();
|
||||
if (context === undefined || context === winner) continue;
|
||||
if (context.status.value === "open" || context.status.value === "prepared") context.status.value = "stale";
|
||||
// Adoption must not walk every node touched by a competing draft. Drop the
|
||||
// context's strong overlay references in O(1); externally retained proxies
|
||||
// still see the stale status and are reclaimed with their holders.
|
||||
releaseOverlayReferences(context);
|
||||
}
|
||||
this.#contexts.clear();
|
||||
const open = this.#open;
|
||||
const competitors = this.#competitors;
|
||||
this.#open = undefined;
|
||||
this.#competitors = undefined;
|
||||
this.#pruneBudget = 256;
|
||||
if (open !== undefined) staleCompetitor(open, winner);
|
||||
if (competitors === undefined) return;
|
||||
for (const reference of competitors) {
|
||||
const context = reference.deref();
|
||||
if (context !== undefined) staleCompetitor(context, winner);
|
||||
}
|
||||
}
|
||||
|
||||
replacementBase(context: OverlayContext): T | undefined {
|
||||
|
|
@ -335,6 +345,16 @@ class TrackerImpl<T extends object> implements Tracker<T> {
|
|||
}
|
||||
}
|
||||
|
||||
function staleCompetitor(context: OverlayContext, winner: OverlayContext): void {
|
||||
if (context === winner) return;
|
||||
if (context.status.value === "open" || context.status.value === "prepared") context.status.value = "stale";
|
||||
// Adoption must not walk every node touched by a competing draft. Drop the
|
||||
// context's strong overlay references in O(1); externally retained proxies
|
||||
// still see the stale status and are reclaimed with their holders.
|
||||
context.registryRef = undefined;
|
||||
releaseOverlayReferences(context);
|
||||
}
|
||||
|
||||
/** Take immutable ownership of an alias-free strict-JSON root in O(1). */
|
||||
export function track<T extends object>(initial: T): Tracker<T> {
|
||||
return new TrackerImpl(initial);
|
||||
|
|
@ -408,10 +428,10 @@ function createContext<T extends object>(
|
|||
status: { value: "open" },
|
||||
root: undefined,
|
||||
bases: [],
|
||||
stored: [],
|
||||
stored: undefined,
|
||||
dirty: [],
|
||||
nodes: [],
|
||||
rawNodes: new WeakMap(),
|
||||
rawNodes: undefined,
|
||||
ops: undefined,
|
||||
replacement,
|
||||
replacementNoop: false,
|
||||
|
|
@ -483,7 +503,7 @@ function createNode(
|
|||
parentSource: InsertSource | undefined = undefined,
|
||||
parentPlacement = false,
|
||||
): OverlayNode {
|
||||
const existing = context.rawNodes!.get(base);
|
||||
const existing = context.rawNodes?.get(base);
|
||||
if (existing !== undefined) return existing;
|
||||
const target: object = Array.isArray(base) ? [] : {};
|
||||
const baseIndex = context.bases!.length;
|
||||
|
|
@ -502,7 +522,12 @@ function createNode(
|
|||
if (parentSource !== undefined) node.parentSource = parentSource;
|
||||
(target as Record<PropertyKey, unknown>)[NODE] = node;
|
||||
node.proxy = new Proxy(target, Array.isArray(base) ? sharedArrayHandler : sharedObjectHandler);
|
||||
context.rawNodes!.set(base, node);
|
||||
// The root is never reached again through a child value (tracked values are alias-free), so a change that touches
|
||||
// only the root needs no map.
|
||||
if (parent !== undefined) {
|
||||
context.rawNodes ??= new WeakMap();
|
||||
context.rawNodes.set(base, node);
|
||||
}
|
||||
context.nodes.push(node);
|
||||
return node;
|
||||
}
|
||||
|
|
@ -517,8 +542,10 @@ function nodeParent(node: OverlayNode): OverlayNode | undefined {
|
|||
|
||||
function storeValue(context: OverlayContext, value: Stored): StoredRef {
|
||||
if (!isContainer(value)) return value;
|
||||
const index = context.stored!.length;
|
||||
context.stored!.push(value);
|
||||
// Created on the first container write: primitive writes never store.
|
||||
context.stored ??= [];
|
||||
const index = context.stored.length;
|
||||
context.stored.push(value);
|
||||
return { index };
|
||||
}
|
||||
|
||||
|
|
@ -1121,12 +1148,12 @@ function insertPiece(node: OverlayNode, items: Stored[], start = 0): InsertPiece
|
|||
|
||||
function insertPlacementPiece(node: OverlayNode, values: unknown[], start = 0): InsertPiece[] {
|
||||
if (values.length === start) return [];
|
||||
const storedLength = node.context.stored!.length;
|
||||
const storedLength = node.context.stored?.length ?? 0;
|
||||
try {
|
||||
for (let index = start; index < values.length; index++)
|
||||
values[index] = storeValue(node.context, clonePlacement(values[index]));
|
||||
} catch (error) {
|
||||
node.context.stored!.length = storedLength;
|
||||
if (node.context.stored !== undefined) node.context.stored.length = storedLength;
|
||||
throw error;
|
||||
}
|
||||
return [
|
||||
|
|
@ -1611,7 +1638,7 @@ function cloneNode(node: OverlayNode): Container {
|
|||
|
||||
function cloneStored(value: Stored, context: OverlayContext): Stored {
|
||||
if (!isContainer(value)) return value;
|
||||
const node = context.rawNodes!.get(value);
|
||||
const node = context.rawNodes?.get(value);
|
||||
return node === undefined || (!node.dirty && !node.subtreeDirty) ? value : cloneNode(node);
|
||||
}
|
||||
|
||||
|
|
@ -1636,7 +1663,7 @@ function clonePlacementNode(node: OverlayNode): Container {
|
|||
|
||||
function clonePlacementStored(value: Stored, context: OverlayContext): Stored {
|
||||
if (!isContainer(value)) return copyJson(value);
|
||||
const node = context.rawNodes!.get(value);
|
||||
const node = context.rawNodes?.get(value);
|
||||
return node === undefined ? copyJson(value) : clonePlacementNode(node);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ describe("delta tracker retention across GC jobs", () => {
|
|||
"same-job-folded-ops-cleanup",
|
||||
"lifecycle-churn",
|
||||
"adopted-changes-flat",
|
||||
"abandoned-draft-released",
|
||||
"tracker-with-open-draft",
|
||||
])(
|
||||
"validates %s retention semantics",
|
||||
(scenario) => {
|
||||
|
|
|
|||
|
|
@ -39,13 +39,16 @@ function draftProxies(tracker: Tracker<{ payload: { rows: { value: number }[] }
|
|||
return reference;
|
||||
}
|
||||
|
||||
async function assertRetained(reference: WeakRef<object>): Promise<void> {
|
||||
async function assertRetained(
|
||||
reference: WeakRef<object>,
|
||||
message = "retained Prepared must retain its immutable revisions",
|
||||
): Promise<void> {
|
||||
assert.ok(global.gc, "worker requires --expose-gc");
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
await setImmediate();
|
||||
global.gc();
|
||||
}
|
||||
assert.notEqual(reference.deref(), undefined, "retained Prepared must retain its immutable revisions");
|
||||
assert.notEqual(reference.deref(), undefined, message);
|
||||
}
|
||||
|
||||
function settledLifecycle(): { tracker: WeakRef<object>; future: WeakRef<object> } {
|
||||
|
|
@ -261,8 +264,8 @@ if (scenario === "settled-lifecycle") {
|
|||
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.
|
||||
// Each change's context is tracked by the tracker until it is settled; adoption must drop every one, 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: "" }] });
|
||||
|
|
@ -289,6 +292,46 @@ if (scenario === "settled-lifecycle") {
|
|||
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 === "abandoned-draft-released") {
|
||||
// The tracker holds its most recent open draft strongly: one dropped without settling stays until the tracker's next
|
||||
// change or adoption, which releases it.
|
||||
type Root = { value: number; payload: { rows: { value: number }[] } | null };
|
||||
const abandon = (tracker: Tracker<Root>): WeakRef<object> => {
|
||||
const change = tracker.beginChange();
|
||||
change.state.payload = { rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) };
|
||||
return new WeakRef(change.state.payload.rows[0]!);
|
||||
};
|
||||
const byChange = track<Root>({ value: 0, payload: null });
|
||||
const first = abandon(byChange);
|
||||
await assertRetained(first, "the tracker holds its latest open draft");
|
||||
const next = byChange.beginChange();
|
||||
await collect(first);
|
||||
// A chain of abandoned drafts keeps only the latest.
|
||||
next.abort();
|
||||
const chain = Array.from({ length: 20 }, () => abandon(byChange));
|
||||
for (const reference of chain.slice(0, -1)) await collect(reference);
|
||||
await assertRetained(chain.at(-1)!, "the tracker holds its latest open draft");
|
||||
byChange.adopt(byChange.prepareReplace({ value: 1, payload: null }));
|
||||
await collect(chain.at(-1)!);
|
||||
|
||||
const byAdopt = track<Root>({ value: 0, payload: null });
|
||||
const dropped = abandon(byAdopt);
|
||||
byAdopt.adopt(byAdopt.prepareReplace({ value: 1, payload: null }));
|
||||
await collect(dropped);
|
||||
assert.deepEqual(byAdopt.value, { value: 1, payload: null });
|
||||
} else if (scenario === "tracker-with-open-draft") {
|
||||
const open = (): WeakRef<object> => {
|
||||
const tracker = track({ value: 0, rows: Array.from({ length: 50_000 }, (_, value) => ({ value })) });
|
||||
const change = tracker.beginChange();
|
||||
change.state.rows[0]!.value = -1;
|
||||
retainedSettledChanges.push(change);
|
||||
const competitor = tracker.beginChange();
|
||||
competitor.state.value = 1;
|
||||
retainedSettledChanges.push(competitor);
|
||||
return new WeakRef(tracker);
|
||||
};
|
||||
await collect(open());
|
||||
assert.equal(retainedSettledChanges.length, 2);
|
||||
} else if (scenario === "lifecycle-churn") {
|
||||
const tracker = track({ value: 0 });
|
||||
for (let index = 0; index < 100_000; index++) {
|
||||
|
|
|
|||
|
|
@ -347,6 +347,120 @@ describe("delta tracker transactional overlay lifecycle", () => {
|
|||
});
|
||||
});
|
||||
|
||||
/** Runs `body` with `WeakRef` construction counted; returns the count. */
|
||||
function countWeakRefs(body: () => void): number {
|
||||
const original = globalThis.WeakRef;
|
||||
let created = 0;
|
||||
globalThis.WeakRef = class<T extends WeakKey> extends original<T> {
|
||||
constructor(target: T) {
|
||||
super(target);
|
||||
created += 1;
|
||||
}
|
||||
};
|
||||
try {
|
||||
body();
|
||||
} finally {
|
||||
globalThis.WeakRef = original;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
describe("delta tracker competing changes", () => {
|
||||
it("tracks one change at a time without a WeakRef", () => {
|
||||
const tracker = track({ value: 0, child: { rows: [0] } });
|
||||
const created = countWeakRefs(() => {
|
||||
for (let index = 1; index <= 100; index++) {
|
||||
const change = tracker.beginChange();
|
||||
change.state.value = index;
|
||||
change.state.child.rows.push(index);
|
||||
change.state.child = { rows: [index] };
|
||||
tracker.adopt(change.prepare());
|
||||
tracker.beginChange().abort();
|
||||
const aborted = tracker.beginChange();
|
||||
aborted.state.value = -1;
|
||||
aborted.prepare().abort();
|
||||
tracker.adopt(tracker.prepareReplace({ value: index, child: { rows: [index] } }));
|
||||
}
|
||||
});
|
||||
expect(created).toBe(0);
|
||||
expect(tracker.value).toEqual({ value: 100, child: { rows: [100] } });
|
||||
expect(tracker.revision).toBe(200);
|
||||
});
|
||||
|
||||
it("makes the older of two open drafts stale when the newer is adopted", () => {
|
||||
const tracker = track({ value: 0, child: { value: 0 } });
|
||||
const older = tracker.beginChange();
|
||||
const held = older.state.child;
|
||||
held.value = 1;
|
||||
const newer = tracker.beginChange();
|
||||
newer.state.value = 2;
|
||||
tracker.adopt(newer.prepare());
|
||||
expect(tracker.value).toEqual({ value: 2, child: { value: 0 } });
|
||||
expect(() => held.value).toThrow(/settled/);
|
||||
expect(() => older.state).toThrow(/settled/);
|
||||
expect(() => older.prepare()).toThrow(/settled/);
|
||||
older.abort();
|
||||
});
|
||||
|
||||
it("makes the newer of two open drafts stale when the older is adopted", () => {
|
||||
const tracker = track({ value: 0, child: { value: 0 } });
|
||||
const older = tracker.beginChange();
|
||||
older.state.value = 1;
|
||||
const newer = tracker.beginChange();
|
||||
const held = newer.state.child;
|
||||
held.value = 2;
|
||||
tracker.adopt(older.prepare());
|
||||
expect(tracker.value).toEqual({ value: 1, child: { value: 0 } });
|
||||
expect(() => held.value).toThrow(/settled/);
|
||||
expect(() => newer.prepare()).toThrow(/settled/);
|
||||
// The tracker moved on: the next change is tracked as the only one again.
|
||||
const next = tracker.beginChange();
|
||||
next.state.value = 3;
|
||||
tracker.adopt(next.prepare());
|
||||
expect(tracker.value).toEqual({ value: 3, child: { value: 0 } });
|
||||
});
|
||||
|
||||
it("makes every other of three drafts stale, open or prepared, whichever is adopted", () => {
|
||||
for (const winner of [0, 1, 2]) {
|
||||
const tracker = track({ value: 0, rows: [{ value: 0 }] });
|
||||
const changes = [tracker.beginChange(), tracker.beginChange(), tracker.beginChange()];
|
||||
const held = changes.map((change) => change.state.rows[0]!);
|
||||
held.forEach((row, index) => {
|
||||
row.value = index + 1;
|
||||
});
|
||||
const prepared = changes[(winner + 1) % 3]!.prepare();
|
||||
tracker.adopt(changes[winner]!.prepare());
|
||||
expect(tracker.value).toEqual({ value: 0, rows: [{ value: winner + 1 }] });
|
||||
expect(() => tracker.adopt(prepared)).toThrow(/stale/);
|
||||
const open = changes[(winner + 2) % 3]!;
|
||||
expect(() => held[(winner + 2) % 3]!.value).toThrow(/settled/);
|
||||
expect(() => open.prepare()).toThrow(/settled/);
|
||||
expect(prepared.value).toEqual({ value: 0, rows: [{ value: ((winner + 1) % 3) + 1 }] });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps an abandoned draft a competitor of the next change, through one WeakRef", () => {
|
||||
const tracker = track({ value: 0, child: { value: 0 } });
|
||||
let abandonedChild: { value: number } | undefined;
|
||||
let abandoned: ReturnType<typeof tracker.beginChange> | undefined;
|
||||
const created = countWeakRefs(() => {
|
||||
abandoned = tracker.beginChange();
|
||||
abandonedChild = abandoned.state.child;
|
||||
abandonedChild.value = 1;
|
||||
const next = tracker.beginChange();
|
||||
next.state.value = 2;
|
||||
tracker.adopt(next.prepare());
|
||||
const after = tracker.beginChange();
|
||||
after.state.value = 3;
|
||||
tracker.adopt(after.prepare());
|
||||
});
|
||||
expect(created).toBe(1);
|
||||
expect(tracker.value).toEqual({ value: 3, child: { value: 0 } });
|
||||
expect(() => abandonedChild!.value).toThrow(/settled/);
|
||||
expect(() => abandoned!.prepare()).toThrow(/settled/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delta tracker policy view", () => {
|
||||
it("supports native reads, descriptors, keys, iteration, map, and JSON", () => {
|
||||
const tracker = track({ values: [1, 2, 3], object: { a: 1 } as { a: number; b?: number } });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue