From 5934d5f28a419f8ff75b77cd67184d4d80205288 Mon Sep 17 00:00:00 2001 From: Christophe Diederichs Date: Thu, 2 Jul 2026 18:09:13 +0100 Subject: [PATCH 1/4] add helper for dynamically setting b-tree degree --- index.js | 9 ++++ test/all.js | 1 + test/degree.js | 110 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 test/degree.js diff --git a/index.js b/index.js index db16800..b0236c4 100644 --- a/index.js +++ b/index.js @@ -124,6 +124,15 @@ class Hyperbee extends EventEmitter { this._setRoot(this._nodeAtSeq(length - 1), true) } + setDegree(t) { + if (!Number.isInteger(t) || t < 2) throw new Error('t must be an integer >= 2') + if (this.context.lock.locked) throw new Error('Cannot change degree while a write is in progress') + + this.context.t = t + this.context.minKeys = t - 1 + this.context.maxKeys = 2 * t - 1 + } + snapshot() { return this._makeView(this.context, this.root, false, 0) } diff --git a/test/all.js b/test/all.js index d862999..b034ca2 100644 --- a/test/all.js +++ b/test/all.js @@ -8,6 +8,7 @@ async function runTests() { test.pause() await import('./basic.js') + await import('./degree.js') await import('./diff.js') await import('./fuzz.js') await import('./tree.js') diff --git a/test/degree.js b/test/degree.js new file mode 100644 index 0000000..53dc5fd --- /dev/null +++ b/test/degree.js @@ -0,0 +1,110 @@ +const test = require('brittle') +const b4a = require('b4a') +const { create } = require('./helpers') + +test('setDegree updates t/minKeys/maxKeys', async function (t) { + const db = await create(t, { t: 4 }) + await db.ready() + + t.is(db.context.t, 4) + t.is(db.context.minKeys, 3) + t.is(db.context.maxKeys, 7) + + db.setDegree(16) + + t.is(db.context.t, 16) + t.is(db.context.minKeys, 15) + t.is(db.context.maxKeys, 31) +}) + +test('setDegree validates input', async function (t) { + const db = await create(t, { t: 4 }) + await db.ready() + + t.exception(() => db.setDegree(1), /t must be an integer/) + t.exception(() => db.setDegree(1.5), /t must be an integer/) + t.exception(() => db.setDegree('4'), /t must be an integer/) + + t.is(db.context.t, 4, 'unchanged after rejected updates') +}) + +test('setDegree throws while a write is in progress', async function (t) { + const db = await create(t, { t: 4 }) + await db.ready() + + const w = db.write() + w.tryPut(b4a.from('a'), b4a.from('1')) + const flushed = w.flush() + + t.exception(() => db.setDegree(16), /Cannot change degree while a write is in progress/) + + await flushed + + db.setDegree(16) + t.is(db.context.t, 16) +}) + +test('setDegree, old and new degree nodes coexist correctly', async function (t) { + const db = await create(t, { t: 2 }) + await db.ready() + + const total = 200 + + for (let i = 0; i < total; i++) { + const w = db.write() + w.tryPut(b4a.from('k' + i), b4a.from('v' + i)) + await w.flush() + } + + db.setDegree(32) + + for (let i = total; i < total * 2; i++) { + const w = db.write() + w.tryPut(b4a.from('k' + i), b4a.from('v' + i)) + await w.flush() + } + + for (let i = 0; i < total * 2; i++) { + const node = await db.get(b4a.from('k' + i)) + t.ok(node, 'key ' + i + ' exists') + t.alike(node.value, b4a.from('v' + i)) + } + + // delete a range that spans keys written under both the old and new degree, + // forcing rebalances against the new minKeys on nodes built under the old one + for (let i = 50; i < total + 50; i++) { + const w = db.write() + w.tryDelete(b4a.from('k' + i)) + await w.flush() + } + + for (let i = 0; i < total * 2; i++) { + const node = await db.get(b4a.from('k' + i)) + const shouldExist = i < 50 || i >= total + 50 + + if (shouldExist) { + t.ok(node, 'key ' + i + ' still exists') + t.alike(node.value, b4a.from('v' + i)) + } else { + t.absent(node, 'key ' + i + ' was deleted') + } + } +}) + +test('setDegree is picked up by a fresh read stream', async function (t) { + const db = await create(t, { t: 2 }) + await db.ready() + + for (let i = 0; i < 50; i++) { + const w = db.write() + w.tryPut(b4a.from('k' + i), b4a.from('v' + i)) + await w.flush() + } + + db.setDegree(8) + + const entries = [] + for await (const entry of db.createReadStream()) entries.push(entry) + + t.is(entries.length, 50) +}) From a120157f43099343b0c600e8f875e4e275e31eda Mon Sep 17 00:00:00 2001 From: Christophe Diederichs Date: Thu, 2 Jul 2026 18:10:56 +0100 Subject: [PATCH 2/4] lint --- index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index b0236c4..9e5e200 100644 --- a/index.js +++ b/index.js @@ -126,7 +126,9 @@ class Hyperbee extends EventEmitter { setDegree(t) { if (!Number.isInteger(t) || t < 2) throw new Error('t must be an integer >= 2') - if (this.context.lock.locked) throw new Error('Cannot change degree while a write is in progress') + if (this.context.lock.locked) { + throw new Error('Cannot change degree while a write is in progress') + } this.context.t = t this.context.minKeys = t - 1 From 9a9e4dd87c44ce99accc7a75d0f1d5a9ddcd2a4a Mon Sep 17 00:00:00 2001 From: Sean Zellmer Date: Wed, 19 Aug 2026 20:09:41 -0500 Subject: [PATCH 3/4] Add test that codifies that contexts are shared between base & snap Maybe an issue if the degree changes which previously was assumed static per instantiation. This existed before the `setDegree()` method, but since its a more direct dynamic update instead of bad behavior, it should be codified or fixed. In this case, there is no corruption that's can be figured but instead means tree nodes can be an unexpected size but still read. Skipped the test for now as its not a behaviour we need to enforce. --- test/degree.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/degree.js b/test/degree.js index 53dc5fd..6df60fc 100644 --- a/test/degree.js +++ b/test/degree.js @@ -44,6 +44,28 @@ test('setDegree throws while a write is in progress', async function (t) { t.is(db.context.t, 16) }) +// Skipping because its more of an example than a behaviour we want to enforce +test.skip("setDegree via a snapshot changes the live tree's degree stats for that context", async function (t) { + const db = await create(t, { t: 8 }) // maxKeys = 15 + await db.ready() + + const total = 300 + for (let i = 0; i < total; i++) { + const w = db.write() + w.tryPut(b4a.from('k' + String(i).padStart(4, '0')), b4a.from('v' + i)) + await w.flush() + } + + // Caller only holds a read-only snapshot, it cannot reorganize the nodes + // Not required but shows that no writes happen. + const snap = db.snapshot() + t.absent(snap.writable, 'snapshot is read-only') + t.is(snap.context, db.context, 'snapshot shares the live context object') + + snap.setDegree(2) + t.is(db.context.maxKeys, 3, 'new degree on snapshot recalculates maxKey on db') +}) + test('setDegree, old and new degree nodes coexist correctly', async function (t) { const db = await create(t, { t: 2 }) await db.ready() From 86da340eed9be641970c9621bbaab8b4755d685f Mon Sep 17 00:00:00 2001 From: Sean Zellmer Date: Wed, 19 Aug 2026 21:52:46 -0500 Subject: [PATCH 4/4] Add test & fix for propagating degree to other contexts --- index.js | 9 +++++++++ test/degree.js | 27 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/index.js b/index.js index 9e5e200..1cc6076 100644 --- a/index.js +++ b/index.js @@ -133,6 +133,15 @@ class Hyperbee extends EventEmitter { this.context.t = t this.context.minKeys = t - 1 this.context.maxKeys = 2 * t - 1 + + // Update all cache contexts + for (const otherContext of this.context.other.values()) { + if (otherContext === this.context) continue + + otherContext.t = t + otherContext.minKeys = t - 1 + otherContext.maxKeys = 2 * t - 1 + } } snapshot() { diff --git a/test/degree.js b/test/degree.js index 6df60fc..bf96f7b 100644 --- a/test/degree.js +++ b/test/degree.js @@ -66,6 +66,33 @@ test.skip("setDegree via a snapshot changes the live tree's degree stats for tha t.is(db.context.maxKeys, 3, 'new degree on snapshot recalculates maxKey on db') }) +test('setDegree propagates updates cached contexts so degree propagates', async function (t) { + const db = await create(t, { t: 4 }) + await db.ready() + + // A second stands in for another writer's context for the multi-core + // (getContextByKey) mechanism. + const other = db.store.get({ name: 'other-core' }) + await other.ready() + t.teardown(() => other.close()) + + // checkout({ key }) routes to context.getContextByKey(key), to cache in + // context.other degree is copied from the live context + const before = db.checkout({ key: other.key }) + t.is(before.context.t, 4, 'cached sub-context created with the pre-change degree') + + db.setDegree(16) + t.is(db.context.t, 16, 'the primary/live context now reports the new degree') + + // Checking out the SAME key again the cached sub-context + const after = db.checkout({ key: other.key }) + + t.is(after.context, before.context, 'checkout reused the cached sub-context object') + t.is(after.context.t, 16, 'degree reverts to the pre-setDegree() value on this cached context') + t.is(after.context.minKeys, 15) + t.is(after.context.maxKeys, 31) +}) + test('setDegree, old and new degree nodes coexist correctly', async function (t) { const db = await create(t, { t: 2 }) await db.ready()