From 87b5daa00ff3701f576ba0f6e70bcd018719014f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:07:29 +0000 Subject: [PATCH] Add explicit format version to B-tree nodes for backward compat Co-Authored-By: Konstantin Wohlwend --- .../augmented-tree-map.test.ts | 31 +++++++++++++++++++ .../data-structures/augmented-tree-map.ts | 15 ++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/apps/bulldozer-js/src/databases/piledriver/data-structures/augmented-tree-map.test.ts b/apps/bulldozer-js/src/databases/piledriver/data-structures/augmented-tree-map.test.ts index 1ffcc3a8e..3e749ec14 100644 --- a/apps/bulldozer-js/src/databases/piledriver/data-structures/augmented-tree-map.test.ts +++ b/apps/bulldozer-js/src/databases/piledriver/data-structures/augmented-tree-map.test.ts @@ -288,6 +288,37 @@ describe("AugmentedTreeMap", () => { expect(await tree.getAugmentation({ gte: 20, lte: 25 })).toBe(135); }); + it("reads legacy nodes persisted before format versioning", async () => { + // Nodes written by an older build have no `version` and no `entryAugmentations`. Simulate such + // an on-disk tree by stripping those fields on read, then confirm reads still work (the tree + // must fall back to recomputing augmentations rather than trusting absent cached ones). + const legacyRef = (ref: PiledriverHeapObject): PiledriverHeapObject => ({ + async get() { + const node: any = await ref.get(); + if (!isNode(node)) return node; + const { version: _version, entryAugmentations: _entryAugmentations, ...legacy } = node as any; + return { ...legacy, children: node.children.map((child: any) => child ? { ...child, ref: legacyRef(child.ref) } : child) }; + }, + [isPiledriverHeapObjectSymbol]: true as const, + } as PiledriverHeapObject); + + const built = await build(100, 8); + const serialized = built.toPiledriverObject(); + const legacy = AugmentedTreeMap.fromPiledriverObject( + { ...serialized, root: serialized.root ? { ...serialized.root, ref: legacyRef(serialized.root.ref) } : null }, + options(8), + ); + + expect(await arrayFrom(legacy.entries({ gte: 20, lt: 25 }))).toEqual([[20, 20], [21, 21], [22, 22], [23, 23], [24, 24]]); + expect(await legacy.getAugmentation({ gte: 20, lte: 25 })).toBe(135); + expect(await legacy.getAugmentation({})).toBe(5050); + + // A mutation over a legacy tree must produce a valid current-format tree. + const updated = await legacy.set(50, -1); + expect(await updated.get(50)).toBe(-1); + expect(await updated.getAugmentation({})).toBe(5050 - 50 + -1); + }); + it("defaults arity when omitted", async () => { let tree = new AugmentedTreeMap({ comparator: (a: number, b: number) => a - b, diff --git a/apps/bulldozer-js/src/databases/piledriver/data-structures/augmented-tree-map.ts b/apps/bulldozer-js/src/databases/piledriver/data-structures/augmented-tree-map.ts index ace924512..ddaef6fd2 100644 --- a/apps/bulldozer-js/src/databases/piledriver/data-structures/augmented-tree-map.ts +++ b/apps/bulldozer-js/src/databases/piledriver/data-structures/augmented-tree-map.ts @@ -30,11 +30,17 @@ type StoredValue = V | PiledriverHeapObject; type Split = { entry: Entry>, right: Child }; // Persisted B-tree node. Children are heap objects, so unchanged subtrees are shared across versions. type Node = { + // On-disk format version for this node. Absent (undefined) on nodes persisted before versioning + // was introduced — those are treated as version 0. Bump `nodeFormatVersion` whenever a persisted + // field is added/changed in a way that makes previously-written data unsafe to trust, and gate + // the reads of that field on the version so old nodes keep deserialising correctly. + version?: number, entries: Entry>[], // Cached per-entry augmentations, parallel to `entries`. When a node is path-copied during a // mutation, unchanged entries (same tuple identity) can reuse their cached augmentation instead // of re-calling extractAugmentation. This avoids creating duplicate heap objects for the same // data, letting the downstream serializer recognise them as already-written (heap cache hits). + // Only trusted when `version >= 1` (see `nodeFormatVersion`). entryAugmentations?: A[], children: Child[], augmentation: A, @@ -71,6 +77,10 @@ const lowerEntryId = Symbol("lower-entry-id"); const upperEntryId = Symbol("upper-entry-id"); const mapEntryId = ""; const defaultArity = 32; +// Current on-disk B-tree node format version. Version 1 introduced cached per-entry augmentations +// (`entryAugmentations`). Nodes without a `version` field were written before versioning (version 0) +// and have their augmentations recomputed on read. See the `version` field on `Node`. +const nodeFormatVersion = 1; // Code-unit comparisons; localeCompare would persist a tree whose order depends on the runtime locale. function compareStrings(a: string, b: string) { @@ -305,6 +315,7 @@ export class AugmentedTreeMultiMap, Value, Augmentation>): ReadonlyMap | undefined { + if ((node.version ?? 0) < 1) return undefined; if (!node.entryAugmentations || node.entryAugmentations.length !== node.entries.length) return undefined; const cache = new Map(); for (let i = 0; i < node.entries.length; i++) { @@ -624,7 +637,7 @@ export class AugmentedTreeMultiMap= 1 ? node.entryAugmentations?.[i] : undefined) ?? await this.options.extractAugmentation(await this.loadValue(node.entries[i][1]), node.entries[i][0].key, node.entries[i][0].id); result = await this.merge(result, entryAug); }