From 0bed70cf0734db9f2a38efdf593e14efee7b61c8 Mon Sep 17 00:00:00 2001 From: Vedant Madane <6527493+VedantMadane@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:21:15 +0000 Subject: [PATCH] tx_check: pass read tx into freelist load to avoid nested beginTx Check already runs on a read transaction (often from View on another goroutine). Rebuilding an unsynced freelist called beginTx from freepages, which could block when combined with the outer View transaction. Pass the active Tx into loadFreelist so reconstruction uses freepagesWithTx and skips opening a nested read transaction (fixes #877). Signed-off-by: Vedant Madane <6527493+VedantMadane@users.noreply.github.com> --- db.go | 26 ++++++++++++++++++++++---- tx_check.go | 5 +++-- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/db.go b/db.go index 96db07b35..34fbdd4b2 100644 --- a/db.go +++ b/db.go @@ -301,7 +301,7 @@ func Open(path string, mode os.FileMode, options *Options) (db *DB, err error) { } if db.PreLoadFreelist { - db.loadFreelist() + db.loadFreelist(nil) } if db.readOnly { @@ -419,12 +419,23 @@ func (db *DB) getPageSizeFromSecondMeta() (int, bool, error) { // loadFreelist reads the freelist if it is synced, or reconstructs it // by scanning the DB if it is not synced. It assumes there are no // concurrent accesses being made to the freelist. -func (db *DB) loadFreelist() { +// +// When sharedReadTx is non-nil, an unsynced freelist is reconstructed by +// scanning using that transaction instead of opening a nested read-only +// transaction. Tx.check passes the active read transaction so freelist +// reconstruction does not call beginTx while another goroutine may still +// hold the outer read transaction from View, which previously could block +// indefinitely (see https://github.com/etcd-io/bbolt/issues/877). +func (db *DB) loadFreelist(sharedReadTx *Tx) { db.freelistLoad.Do(func() { db.freelist = newFreelist(db.FreelistType) if !db.hasSyncedFreelist() { // Reconstruct free list by scanning the DB. - db.freelist.Init(db.freepages()) + if sharedReadTx != nil { + db.freelist.Init(db.freepagesWithTx(sharedReadTx)) + } else { + db.freelist.Init(db.freepages()) + } } else { // Read free list from freelist page. db.freelist.Read(db.page(db.meta().Freelist())) @@ -1250,6 +1261,13 @@ func (db *DB) freepages() []common.Pgid { panic("freepages: failed to open read only tx") } + return db.freepagesWithTx(tx) +} + +// freepagesWithTx lists page IDs that are not reachable from the bucket tree +// for the given read-only transaction. The transaction must not be used +// concurrently while this function runs. +func (db *DB) freepagesWithTx(tx *Tx) []common.Pgid { reachable := make(map[common.Pgid]*common.Page) nofreed := make(map[common.Pgid]bool) ech := make(chan error) @@ -1267,7 +1285,7 @@ func (db *DB) freepages() []common.Pgid { // TODO: If check bucket reported any corruptions (ech) we shouldn't proceed to freeing the pages. var fids []common.Pgid - for i := common.Pgid(2); i < db.meta().Pgid(); i++ { + for i := common.Pgid(2); i < tx.meta.Pgid(); i++ { if _, ok := reachable[i]; !ok { fids = append(fids, i) } diff --git a/tx_check.go b/tx_check.go index a1e9b33cf..b4513377a 100644 --- a/tx_check.go +++ b/tx_check.go @@ -41,8 +41,9 @@ func (tx *Tx) check(cfg checkConfig, ch chan error) { ch <- panicked{r} } }() - // Force loading free list if opened in ReadOnly mode. - tx.db.loadFreelist() + // Force loading free list if opened in ReadOnly mode. Pass this tx so + // freelist reconstruction does not open a nested read transaction. + tx.db.loadFreelist(tx) // Check if any pages are double freed. freed := make(map[common.Pgid]bool)