diff --git a/packages/chord/CHANGELOG.md b/packages/chord/CHANGELOG.md index 145418f1b..cf4082fa9 100644 --- a/packages/chord/CHANGELOG.md +++ b/packages/chord/CHANGELOG.md @@ -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 diff --git a/packages/chord/src/delta/README.md b/packages/chord/src/delta/README.md index 0db4a8103..8dbf06f75 100644 --- a/packages/chord/src/delta/README.md +++ b/packages/chord/src/delta/README.md @@ -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. diff --git a/packages/chord/src/delta/tracker.ts b/packages/chord/src/delta/tracker.ts index db5eb6b66..731cc49a8 100644 --- a/packages/chord/src/delta/tracker.ts +++ b/packages/chord/src/delta/tracker.ts @@ -229,9 +229,20 @@ class ChangeImpl implements Change { } } +/** + * 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 implements Tracker { readonly #owner = {}; - readonly #contexts = new Set>(); + #open: OverlayContext | undefined; + #competitors: Set> | undefined; readonly #selfRef: WeakRef>; #value: T; #revision = 0; @@ -252,14 +263,15 @@ class TrackerImpl implements Tracker { beginChange(): Change { 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 { + // 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(context); } catch (error) { @@ -295,39 +307,37 @@ class TrackerImpl implements Tracker { } 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 implements Tracker { } } +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(initial: T): Tracker { return new TrackerImpl(initial); @@ -408,10 +428,10 @@ function createContext( 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)[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); } diff --git a/packages/chord/test/delta-tracker/retention.test.ts b/packages/chord/test/delta-tracker/retention.test.ts index fe39b0dd0..443db1126 100644 --- a/packages/chord/test/delta-tracker/retention.test.ts +++ b/packages/chord/test/delta-tracker/retention.test.ts @@ -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) => { diff --git a/packages/chord/test/delta-tracker/retention.worker.ts b/packages/chord/test/delta-tracker/retention.worker.ts index 23e9efa85..57d111d55 100644 --- a/packages/chord/test/delta-tracker/retention.worker.ts +++ b/packages/chord/test/delta-tracker/retention.worker.ts @@ -39,13 +39,16 @@ function draftProxies(tracker: Tracker<{ payload: { rows: { value: number }[] } return reference; } -async function assertRetained(reference: WeakRef): Promise { +async function assertRetained( + reference: WeakRef, + message = "retained Prepared must retain its immutable revisions", +): Promise { 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; future: WeakRef } { @@ -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): WeakRef => { + 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({ 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({ 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 => { + 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++) { diff --git a/packages/chord/test/delta-tracker/tracker.test.ts b/packages/chord/test/delta-tracker/tracker.test.ts index fddbd8c61..e0e779b4d 100644 --- a/packages/chord/test/delta-tracker/tracker.test.ts +++ b/packages/chord/test/delta-tracker/tracker.test.ts @@ -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 extends original { + 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 | 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 } });