From 39d03b1d1582b5a2c41e4b85c1dfa0b461eb84b7 Mon Sep 17 00:00:00 2001 From: Michael Ellis Date: Fri, 10 Jul 2026 14:09:54 +1000 Subject: [PATCH] fix: preserve same-transaction changes when moving a sub-bucket A sub-bucket modified in the same transaction as a MoveBucket had its pending changes dropped, because the materialized bucket was removed from the source cache and never spilled under the destination. Signed-off-by: Michael Ellis --- bucket.go | 11 +++++++- movebucket_test.go | 65 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/bucket.go b/bucket.go index fec2d6c95..7c570578e 100644 --- a/bucket.go +++ b/bucket.go @@ -391,7 +391,10 @@ func (b *Bucket) MoveBucket(key []byte, dstBucket *Bucket) (err error) { return errors.ErrIncompatibleValue } - // remove the sub-bucket from the source bucket + // remove the sub-bucket from the source bucket. If it was materialized + // earlier in this transaction it may hold pending changes in memory, so keep + // a reference to carry over to the destination below. + movedChild := b.buckets[string(newKey)] delete(b.buckets, string(newKey)) c.node().del(newKey) @@ -399,6 +402,12 @@ func (b *Bucket) MoveBucket(key []byte, dstBucket *Bucket) (err error) { newValue := cloneBytes(v) curDst.node().put(newKey, newKey, newValue, 0, common.BucketLeafFlag) + // carry the materialized sub-bucket over so its pending changes are spilled + // under the destination at commit; otherwise they would be silently lost. + if movedChild != nil && dstBucket.buckets != nil { + dstBucket.buckets[string(newKey)] = movedChild + } + return nil } diff --git a/movebucket_test.go b/movebucket_test.go index 9c09825e6..155a27b96 100644 --- a/movebucket_test.go +++ b/movebucket_test.go @@ -396,3 +396,68 @@ func populateSampleDataInBucket(t testing.TB, bk *bbolt.Bucket, n int) { require.NoError(t, err) } } + +// TestTx_MoveBucket_PendingChanges verifies that changes made to a sub-bucket in +// the same transaction as a MoveBucket are preserved rather than silently dropped. +func TestTx_MoveBucket_PendingChanges(t *testing.T) { + t.Run("modify committed bucket, then move in same tx", func(t *testing.T) { + db := btesting.MustCreateDB(t) + + // tx1: create src/dst and a child with a committed key. + require.NoError(t, db.Update(func(tx *bbolt.Tx) error { + src, err := tx.CreateBucket([]byte("src")) + require.NoError(t, err) + if _, err := tx.CreateBucket([]byte("dst")); err != nil { + return err + } + child, err := src.CreateBucket([]byte("child")) + require.NoError(t, err) + return child.Put([]byte("committed"), []byte("v1")) + })) + + // tx2: add a key to the child, then move it, in the same transaction. + require.NoError(t, db.Update(func(tx *bbolt.Tx) error { + src := tx.Bucket([]byte("src")) + require.NoError(t, src.Bucket([]byte("child")).Put([]byte("pending"), []byte("v2"))) + return tx.MoveBucket([]byte("child"), src, tx.Bucket([]byte("dst"))) + })) + + require.NoError(t, db.View(func(tx *bbolt.Tx) error { + child := tx.Bucket([]byte("dst")).Bucket([]byte("child")) + require.NotNil(t, child) + require.Equal(t, []byte("v1"), child.Get([]byte("committed"))) + require.Equal(t, []byte("v2"), child.Get([]byte("pending"))) + return nil + })) + }) + + t.Run("create, populate and move in the same tx", func(t *testing.T) { + db := btesting.MustCreateDB(t) + + require.NoError(t, db.Update(func(tx *bbolt.Tx) error { + src, err := tx.CreateBucket([]byte("src")) + require.NoError(t, err) + dst, err := tx.CreateBucket([]byte("dst")) + require.NoError(t, err) + + child, err := src.CreateBucket([]byte("child")) + require.NoError(t, err) + require.NoError(t, child.Put([]byte("k"), []byte("v"))) + // a nested sub-bucket with its own data + grand, err := child.CreateBucket([]byte("grand")) + require.NoError(t, err) + require.NoError(t, grand.Put([]byte("gk"), []byte("gv"))) + + return src.MoveBucket([]byte("child"), dst) + })) + + require.NoError(t, db.View(func(tx *bbolt.Tx) error { + require.Nil(t, tx.Bucket([]byte("src")).Bucket([]byte("child"))) + child := tx.Bucket([]byte("dst")).Bucket([]byte("child")) + require.NotNil(t, child) + require.Equal(t, []byte("v"), child.Get([]byte("k"))) + require.Equal(t, []byte("gv"), child.Bucket([]byte("grand")).Get([]byte("gk"))) + return nil + })) + }) +}