Skip to content

Commit 33b618b

Browse files
dusk125claude
andcommitted
UPSTREAM: <drop>: backend: refactor defrag to minimize gRPC disruption
Split defrag into three phases: unlocked bulk copy against a bbolt snapshot, locked journal replay, and atomic DB switchover. This reduces the gRPC blocking window from O(db_size) to O(writes_during_copy). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bf6c009 commit 33b618b

6 files changed

Lines changed: 1467 additions & 64 deletions

File tree

server/etcdserver/server.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ type EtcdServer struct {
260260
kv mvcc.WatchableKV
261261
lessor lease.Lessor
262262
bemu sync.RWMutex
263+
defragMu sync.Mutex
263264
be backend.Backend
264265
beHooks *serverstorage.BackendHooks
265266
authStore auth.AuthStore
@@ -975,8 +976,10 @@ func (s *EtcdServer) Cleanup() {
975976
}
976977

977978
func (s *EtcdServer) Defragment() error {
978-
s.bemu.Lock()
979-
defer s.bemu.Unlock()
979+
s.defragMu.Lock()
980+
defer s.defragMu.Unlock()
981+
s.bemu.RLock()
982+
defer s.bemu.RUnlock()
980983
return s.be.Defrag()
981984
}
982985

server/storage/backend/backend.go

Lines changed: 147 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package backend
1616

1717
import (
18+
"errors"
1819
"fmt"
1920
"hash/crc32"
2021
"io"
@@ -28,6 +29,7 @@ import (
2829
"go.uber.org/zap"
2930

3031
bolt "go.etcd.io/bbolt"
32+
bolterrors "go.etcd.io/bbolt/errors"
3133
"go.etcd.io/etcd/client/pkg/v3/verify"
3234
)
3335

@@ -469,20 +471,6 @@ func (b *backend) defrag() error {
469471
isDefragActive.Set(1)
470472
defer isDefragActive.Set(0)
471473

472-
// TODO: make this non-blocking?
473-
// lock batchTx to ensure nobody is using previous tx, and then
474-
// close previous ongoing tx.
475-
b.batchTx.LockOutsideApply()
476-
defer b.batchTx.Unlock()
477-
478-
// lock database after lock tx to avoid deadlock.
479-
b.mu.Lock()
480-
defer b.mu.Unlock()
481-
482-
// block concurrent read requests while resetting tx
483-
b.readTx.Lock()
484-
defer b.readTx.Unlock()
485-
486474
// Create a temporary file to ensure we start with a clean slate.
487475
// Snapshotter.cleanupSnapdir cleans up any of these that are found during startup.
488476
dir := filepath.Dir(b.db.Path())
@@ -500,63 +488,112 @@ func (b *backend) defrag() error {
500488
// return nil, fmt.Errorf(defragOpenFileError)
501489
return temp, nil
502490
}
503-
// Don't load tmp db into memory regardless of opening options
504491
options.Mlock = false
505492
tdbp := temp.Name()
506493
tmpdb, err := bolt.Open(tdbp, 0o600, &options)
507494
if err != nil {
508495
temp.Close()
509496
if rmErr := os.Remove(temp.Name()); rmErr != nil {
510-
b.lg.Error(
511-
"failed to remove temporary file",
497+
b.lg.Error("failed to remove temporary file",
512498
zap.String("path", temp.Name()),
513499
zap.Error(rmErr),
514500
)
515501
}
516-
517502
return err
518503
}
519504

520505
dbp := b.db.Path()
521506
size1, sizeInUse1 := b.Size(), b.SizeInUse()
522-
b.lg.Info(
523-
"defragmenting",
507+
b.lg.Info("defragmenting",
524508
zap.String("path", dbp),
525509
zap.Int64("current-db-size-bytes", size1),
526510
zap.String("current-db-size", humanize.Bytes(uint64(size1))),
527511
zap.Int64("current-db-size-in-use-bytes", sizeInUse1),
528512
zap.String("current-db-size-in-use", humanize.Bytes(uint64(sizeInUse1))),
529513
)
530514

531-
defer func() {
532-
// NOTE: We should exit as soon as possible because that tx
533-
// might be closed. The inflight request might use invalid
534-
// tx and then panic as well. The real panic reason might be
535-
// shadowed by new panic. So, we should fatal here with lock.
536-
if rerr := recover(); rerr != nil {
537-
b.lg.Fatal("unexpected panic during defrag", zap.Any("panic", rerr))
538-
}
539-
}()
515+
// ============================================================
516+
// PHASE 1: Commit pending writes, take a snapshot, install the
517+
// journal, then unlock so writes can continue during the copy.
518+
// ============================================================
519+
b.batchTx.LockOutsideApply()
540520

541-
// Commit/stop and then reset current transactions (including the readTx)
542-
b.batchTx.unsafeCommit(true)
543-
b.batchTx.tx = nil
521+
b.batchTx.commit(false)
522+
523+
b.mu.RLock()
524+
snapTx, snapErr := b.db.Begin(false)
525+
b.mu.RUnlock()
526+
if snapErr != nil {
527+
b.batchTx.Unlock()
528+
tmpdb.Close()
529+
os.Remove(tdbp)
530+
return fmt.Errorf("failed to begin snapshot tx for defrag: %w", snapErr)
531+
}
532+
533+
journal := newDefragJournal()
534+
b.batchTx.defragJournal = journal
535+
b.batchTx.Unlock()
536+
537+
b.lg.Info("defrag: copying data (writes unlocked)")
544538

545539
// gofail: var defragBeforeCopy struct{}
546-
err = defragdb(b.db, tmpdb, defragLimit)
540+
err = defragFromTx(snapTx, tmpdb, defragLimit)
541+
snapTx.Rollback()
542+
547543
if err != nil {
544+
b.batchTx.LockOutsideApply()
545+
b.batchTx.defragJournal = nil
546+
b.batchTx.Unlock()
547+
journal.close()
548548
tmpdb.Close()
549-
if rmErr := os.RemoveAll(tmpdb.Path()); rmErr != nil {
550-
b.lg.Error("failed to remove db.tmp after defragmentation completed", zap.Error(rmErr))
551-
}
549+
os.RemoveAll(tdbp)
550+
return err
551+
}
552+
553+
// ============================================================
554+
// PHASE 2: Lock writes, drain and replay the journal into the
555+
// temp DB. This should be fast since it's only the delta.
556+
// ============================================================
557+
b.lg.Info("defrag: replaying journal")
552558

553-
// restore the bbolt transactions if defragmentation fails
554-
b.batchTx.tx = b.unsafeBegin(true)
555-
b.readTx.tx = b.unsafeBegin(false)
559+
b.batchTx.LockOutsideApply()
560+
b.batchTx.defragJournal = nil
561+
journal.close()
562+
ops := journal.drain()
563+
564+
if len(ops) > 0 {
565+
b.lg.Info("defrag: replaying journal ops", zap.Int("count", len(ops)))
566+
}
556567

568+
err = replayJournal(tmpdb, ops, defragLimit)
569+
if err != nil {
570+
b.batchTx.Unlock()
571+
tmpdb.Close()
572+
os.RemoveAll(tdbp)
557573
return err
558574
}
559575

576+
// ============================================================
577+
// PHASE 3: Atomic switchover — close old db, rename, reopen.
578+
// ============================================================
579+
b.lg.Info("defrag: switching database")
580+
581+
b.mu.Lock()
582+
b.readTx.Lock()
583+
584+
// NOTE: We should exit as soon as possible because that tx
585+
// might be closed. The inflight request might use invalid
586+
// tx and then panic as well. The real panic reason might be
587+
// shadowed by new panic. So, we should fatal here with lock.
588+
defer func() {
589+
if rerr := recover(); rerr != nil {
590+
b.lg.Fatal("unexpected panic during defrag", zap.Any("panic", rerr))
591+
}
592+
}()
593+
594+
b.batchTx.unsafeCommit(true)
595+
b.batchTx.tx = nil
596+
560597
err = b.db.Close()
561598
if err != nil {
562599
b.lg.Fatal("failed to close database", zap.Error(err))
@@ -585,12 +622,15 @@ func (b *backend) defrag() error {
585622
atomic.StoreInt64(&b.size, size)
586623
atomic.StoreInt64(&b.sizeInUse, size-(int64(db.Stats().FreePageN)*int64(db.Info().PageSize)))
587624

625+
b.readTx.Unlock()
626+
b.mu.Unlock()
627+
b.batchTx.Unlock()
628+
588629
took := time.Since(now)
589630
defragSec.Observe(took.Seconds())
590631

591632
size2, sizeInUse2 := b.Size(), b.SizeInUse()
592-
b.lg.Info(
593-
"finished defragmenting directory",
633+
b.lg.Info("finished defragmenting directory",
594634
zap.String("path", dbp),
595635
zap.Int64("current-db-size-bytes-diff", size2-size1),
596636
zap.Int64("current-db-size-bytes", size2),
@@ -603,11 +643,7 @@ func (b *backend) defrag() error {
603643
return nil
604644
}
605645

606-
func defragdb(odb, tmpdb *bolt.DB, limit int) error {
607-
// gofail: var defragdbFail string
608-
// return fmt.Errorf(defragdbFail)
609-
610-
// open a tx on tmpdb for writes
646+
func defragFromTx(srcTx *bolt.Tx, tmpdb *bolt.DB, limit int) error {
611647
tmptx, err := tmpdb.Begin(true)
612648
if err != nil {
613649
return err
@@ -618,18 +654,10 @@ func defragdb(odb, tmpdb *bolt.DB, limit int) error {
618654
}
619655
}()
620656

621-
// open a tx on old db for read
622-
tx, err := odb.Begin(false)
623-
if err != nil {
624-
return err
625-
}
626-
defer tx.Rollback()
627-
628-
c := tx.Cursor()
629-
657+
c := srcTx.Cursor()
630658
count := 0
631659
for next, _ := c.First(); next != nil; next, _ = c.Next() {
632-
b := tx.Bucket(next)
660+
b := srcTx.Bucket(next)
633661
if b == nil {
634662
return fmt.Errorf("backend: cannot defrag bucket %s", next)
635663
}
@@ -665,6 +693,69 @@ func defragdb(odb, tmpdb *bolt.DB, limit int) error {
665693
return tmptx.Commit()
666694
}
667695

696+
func replayJournal(tmpdb *bolt.DB, ops []defragJournalOp, limit int) error {
697+
if len(ops) == 0 {
698+
return nil
699+
}
700+
701+
tx, err := tmpdb.Begin(true)
702+
if err != nil {
703+
return err
704+
}
705+
defer func() {
706+
if err != nil {
707+
tx.Rollback()
708+
}
709+
}()
710+
711+
count := 0
712+
for _, op := range ops {
713+
count++
714+
if count > limit {
715+
if err = tx.Commit(); err != nil {
716+
return err
717+
}
718+
tx, err = tmpdb.Begin(true)
719+
if err != nil {
720+
return err
721+
}
722+
count = 0
723+
}
724+
725+
switch op.opType {
726+
case opCreateBucket:
727+
if _, err = tx.CreateBucketIfNotExists(op.bucketName); err != nil {
728+
return fmt.Errorf("replay: create bucket %s: %w", op.bucketName, err)
729+
}
730+
case opDeleteBucket:
731+
if delErr := tx.DeleteBucket(op.bucketName); delErr != nil && !errors.Is(delErr, bolterrors.ErrBucketNotFound) {
732+
return fmt.Errorf("replay: delete bucket %s: %w", op.bucketName, delErr)
733+
}
734+
case opPut:
735+
b := tx.Bucket(op.bucketName)
736+
if b == nil {
737+
return fmt.Errorf("replay: bucket %s not found for put", op.bucketName)
738+
}
739+
if op.seq {
740+
b.FillPercent = 0.9
741+
}
742+
if err = b.Put(op.key, op.value); err != nil {
743+
return fmt.Errorf("replay: put in bucket %s: %w", op.bucketName, err)
744+
}
745+
case opDelete:
746+
b := tx.Bucket(op.bucketName)
747+
if b == nil {
748+
continue
749+
}
750+
if err = b.Delete(op.key); err != nil {
751+
return fmt.Errorf("replay: delete from bucket %s: %w", op.bucketName, err)
752+
}
753+
}
754+
}
755+
756+
return tx.Commit()
757+
}
758+
668759
func (b *backend) begin(write bool) *bolt.Tx {
669760
b.mu.RLock()
670761
tx := b.unsafeBegin(write)

0 commit comments

Comments
 (0)