Skip to content

fix(leveldb): flush by rotating the memtable; open the db atomically - #29661

Open
chrisnojima wants to merge 3 commits into
masterfrom
nojima/HOTPOT-as-leveldb
Open

chrisnojima wants to merge 3 commits into
masterfrom
nojima/HOTPOT-as-leveldb

Conversation

@chrisnojima

@chrisnojima chrisnojima commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Split out of #29637. Based on master, independent of the app-state stack.

Why

Flush() exists to get pending writes onto disk before the app is suspended or killed. The way it did that had real costs, and the db handling around it had several latent correctness bugs. These changes can be reviewed separately from the app-state work.

Master bugs this fixes

  • Flushing writes a junk key and compacts the entire keyspace. Flush() puts a sentinel key (pm:ff:flush-sentinel), calls CompactRange over the whole range, then deletes the key. Every flush writes a bogus key into the "pm" table and pays for a full-keyspace compaction, when all that's needed is getting the memtable onto disk.
  • The lazily opened db handle is racy. The lazy open assigns the db field under the read lock, which is shared, so the assignment races every reader that doesn't go through dbOpenerOnce.
  • OpenTransaction() dereferences an unopened or closed db instead of opening it lazily or returning an error.
  • Close can pull the db out from under a running clean. closeLocked closes goleveldb before stopping the cleaner, so a clean can hold an iterator across Close, which goleveldb documents as unsafe.
  • The cleaner can miss an app-state transition. It runs a separate monitorAppState goroutine with its own cancel channel, so it misses a transition that lands between the state sample and the batch loop.
  • A reopened db gets a dead cleaner. After a close or nuke, setDb only set the field, so the cleaner stayed shut down.

Decision for review: this depends on a patch to our goleveldb fork

Please evaluate this choice explicitly. go.mod moves the github.com/syndtr/goleveldb => github.com/keybase/goleveldb replace from 9881c0c (our fork's master) to e81a996. That commit sits on the fork branch nojima/flush-memdb and is not merged into the fork's master. If this approach is accepted, that branch should get its own PR against the fork and be merged, and go.mod repinned to the merge commit, before this PR lands.

What the fork commit changes (about 40 lines plus tests, in leveldb/db_write.go and leveldb/db_transaction.go):

  1. OpenTransaction releases the write lock when it fails. It takes writeLockC and then returns without releasing it if rotating the memdb or waiting on compaction fails. For example, creating the new journal file fails with ENOSPC on a full phone. After that, every Put/Write, and DB.Close itself, blocks forever. Our Close/Nuke then deadlock too, because they wait on callers stuck holding our read lock. Upstream syndtr/goleveldb master has the same bug. CompactRange releases the lock on the matching error path, so this is an oversight, not a design choice.
  2. New DB.FlushMemdb(). It swaps a non-empty memdb out under the write lock, releases the lock, waits for the memdb compaction to write the table, and returns once every earlier write is in a table. It runs no table compaction. This is the memdb half of CompactRange, in the same order.

Why a fork patch instead of working around it in this repo:

  • Nothing public in goleveldb does "flush the memtable only." Its two public routes both have a problem:
    • CompactRange (what master does): after the memdb flush it always runs a table compaction over the range. Any range that makes it flush the memtable also overlaps the new table, so every flush compacts tables. That cost is what this PR set out to remove.
    • OpenTransaction + Discard (the previous revision of this PR): it does flush the memtable only, but it hits the lock leak above. It also holds the write lock for the whole table write, so every concurrent chat write stalls for that time. The write buffer is 12 MB.
  • Our code can't release goleveldb's lock after the fact. writeLockC is unexported, and DB.Close needs the same lock, so there's no way to recover from outside.
  • We already carry this fork for exactly this kind of change: its only existing keybase commit adds the active-compaction stats.

What it costs: we carry about 40 more lines of divergence from upstream in the fork. The rejected alternative is to keep master's sentinel + full-range CompactRange flush and accept its compaction cost. That needs no fork change.

Fork test coverage (leveldb/db_flush_test.go, each mutation-checked: reverting the fix makes it fail):

  • FlushMemdb adds exactly one table, compacts none, and writes no table when the memdb is empty.
  • Writers get through while the table write is stalled.
  • After an emulated ENOSPC on journal create, FlushMemdb and OpenTransaction both leave the write lock free.

What this changes here

  • Flush() calls db.FlushMemdb() under our read lock. It's still a no-op when the db isn't open, and it never triggers a lazy open. Its errors go through the same handling as every other operation: a full disk starts a forced clean, and a corrupt db is nuked.
  • The db field becomes an atomic.Pointer.
  • doWhileOpenAndNukeIfCorrupted passes the opened db to its action, so callers don't reload it.
  • OpenTransaction() goes through that same lazy-open path. It then waits on goleveldb's write lock outside our read lock. Otherwise a Close or Nuke queued behind it would block every new reader, including the goroutine holding the current transaction, and nothing could proceed.
  • The cleaner drops its monitor goroutine and cancel channel. clean() samples State()/NextUpdate() in the same critical section that takes the running flag.
  • Each clean keeps the db and stop channel it started with, so a Nuke and reopen mid-clean can't send it to the new db.
  • Close stops the cleaner first. Stop() detaches the cleaner from its db and waits for a running clean to exit before goleveldb is closed. The clean's between-batch sleep wakes on stop.
  • start(db) replaces setDb(db) and installs a fresh LRU, so isShutdown goes away.
  • Only Nuke resets the cleaner's position. Close followed by a reopen keeps the same data, so cleaning resumes where it stopped.
  • Status() and clearCache() now read the cache pointer under its lock.

One behaviour change to note in review

The old cancel goroutine was started only on mobile (isMobile). The new in-loop sampling runs everywhere. On desktop MobileAppState stays FOREGROUND and never fires, so a clean runs to completion exactly as before. The difference is that the code path is now shared rather than mobile-only, and the cleaner no longer stores isMobile.

Not changed

A pm:ff:flush-sentinel key can already be sitting in a db, left there if master's Flush was killed between its Put and Delete. This PR leaves it alone: it's a few bytes in the permanent table and nothing reads it. Deleting it would mean a lookup on every open, kept around forever.

Ordering

No file overlap with the stack, so it can merge in any position. Like the other two standalone PRs, it's best landed before or with #29651, since leveldb_cleaner.go has one of the three loops that hardcode a FOREGROUND seed. It also needs the fork branch merged first (see above).

Verification

  • Repo: go build ./..., go vet, and gofmt are clean, and golangci-lint --new-from-rev master reports 0 issues. All leveldb and cleaner tests pass under -race.
  • New tests: a first-use OpenTransaction; OpenTransaction waiting while a Nuke is queued; a clean restarting from the first key after Nuke but resuming after Close and reopen; Close waiting for a running clean; a clean after Close doing nothing; Status racing a reopen. Each failed before its fix and was mutation-checked against it.
  • Not tested: routing Flush() errors through the shared error handler. OpenFile offers no way to inject ENOSPC or corruption into a flush.
  • Fork: the full goleveldb suite passes.

Flush used to write a sentinel key and then CompactRange the whole key
space just to force the memtable out. That rewrites tables that had
nothing to do with the flush. Open a transaction and immediately discard
it instead: goleveldb rotates a non-empty memtable and waits for it to
land in a table, without compacting anything. Concurrent calls serialize
on goleveldb's own write lock, so a call that arrives late still covers
every write made before it.

The lazily-opened db field was assigned under the *read* lock, racing
every other reader. Make it an atomic.Pointer, and have OpenTransaction
return LevelDBOpenClosedError instead of dereferencing a nil db after
Close.

The cleaner no longer runs a separate monitorAppState goroutine with its
own cancel channel; clean() samples State()/NextUpdate() inside the same
critical section that takes the running flag, so a transition can't slip
through between the sample and the batch loop.
Comment thread go/libkb/leveldb.go
l.G().Log.Debug("| Opening LevelDB options: %+v", l.Opts())
l.db, err = leveldb.OpenFile(fn, l.Opts())
db, openErr := leveldb.OpenFile(fn, l.Opts())
err = openErr

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

Comment thread go/libkb/leveldb.go Outdated
// openedDb returns the DB without triggering a lazy open, or nil if it isn't
// open. Callers must hold the read lock.
func (l *LevelDb) openedDb() *leveldb.DB {
return l.db.Load()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need for a 1 line wraper. we call db.Load() directly extensively throughout this diff already..

Comment thread go/libkb/leveldb_test.go

// A lazy open racing a Nuke reopens rather than reporting closed.
for i := 0; i < 8; i++ {
wg.Add(2)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

post go 1.25 use wg.Go. agents should run go fix on diffs to fix these kinds of issues they're not trained on.

…own db

goleveldb's OpenTransaction keeps its write lock when rotating the
memtable or waiting on compaction fails, e.g. a journal create hitting
ENOSPC. Flushing through it could leave every later write, and Close,
blocked for good. It also held the lock for the whole table write.
Flush now calls FlushMemdb from the keybase/goleveldb fork, which
releases the lock before waiting and on every error; the fork also
fixes OpenTransaction itself.

OpenTransaction goes through the lazy open like every other operation
instead of reporting a never-opened db as closed. The open db is handed
to each action rather than reloaded.

A clean keeps the db and stop channel it started with, so a Nuke and
reopen mid-clean can't redirect it to the new db. start() resets
lastKey and always installs a fresh LRU, which retires isShutdown.
Status and clearCache read the cache pointer under its lock.
…lock

Close closed goleveldb before stopping the cleaner, so a running clean
could hold an iterator across Close, which goleveldb says is unsafe.
Stop now runs first, detaches the cleaner from its db, and waits for a
running clean to exit; the clean's between-batch sleep wakes on stop.

OpenTransaction waited on goleveldb's write lock while holding our read
lock. A Close or Nuke queued behind it blocked every new reader,
including a Get from the goroutine holding the current transaction, so
nobody could proceed. It now opens the db lazily, then waits outside
the lock.

Flush goes through the same error handling as other operations, so a
full disk starts a forced clean and a corrupt db is nuked.

Only Nuke resets the cleaner's position; Close and reopen keep the same
data, so cleaning resumes where it stopped.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants