github.com/decred/dcrlnd@v0.7.6/kvdb/backend.go (about)

     1  //go:build !js
     2  // +build !js
     3  
     4  package kvdb
     5  
     6  import (
     7  	"context"
     8  	"crypto/sha256"
     9  	"encoding/binary"
    10  	"encoding/hex"
    11  	"fmt"
    12  	"io/ioutil"
    13  	"os"
    14  	"path/filepath"
    15  	"time"
    16  
    17  	_ "github.com/btcsuite/btcwallet/walletdb/bdb" // Import to register backend.
    18  )
    19  
    20  const (
    21  	// DefaultTempDBFileName is the default name of the temporary bolt DB
    22  	// file that we'll use to atomically compact the primary DB file on
    23  	// startup.
    24  	DefaultTempDBFileName = "temp-dont-use.db"
    25  
    26  	// LastCompactionFileNameSuffix is the suffix we append to the file name
    27  	// of a database file to record the timestamp when the last compaction
    28  	// occurred.
    29  	LastCompactionFileNameSuffix = ".last-compacted"
    30  )
    31  
    32  var (
    33  	byteOrder = binary.BigEndian
    34  )
    35  
    36  // fileExists returns true if the file exists, and false otherwise.
    37  func fileExists(path string) bool {
    38  	if _, err := os.Stat(path); err != nil {
    39  		if os.IsNotExist(err) {
    40  			return false
    41  		}
    42  	}
    43  
    44  	return true
    45  }
    46  
    47  // BoltBackendConfig is a struct that holds settings specific to the bolt
    48  // database backend.
    49  type BoltBackendConfig struct {
    50  	// DBPath is the directory path in which the database file should be
    51  	// stored.
    52  	DBPath string
    53  
    54  	// DBFileName is the name of the database file.
    55  	DBFileName string
    56  
    57  	// NoFreelistSync, if true, prevents the database from syncing its
    58  	// freelist to disk, resulting in improved performance at the expense of
    59  	// increased startup time.
    60  	NoFreelistSync bool
    61  
    62  	// AutoCompact specifies if a Bolt based database backend should be
    63  	// automatically compacted on startup (if the minimum age of the
    64  	// database file is reached). This will require additional disk space
    65  	// for the compacted copy of the database but will result in an overall
    66  	// lower database size after the compaction.
    67  	AutoCompact bool
    68  
    69  	// AutoCompactMinAge specifies the minimum time that must have passed
    70  	// since a bolt database file was last compacted for the compaction to
    71  	// be considered again.
    72  	AutoCompactMinAge time.Duration
    73  
    74  	// DBTimeout specifies the timeout value to use when opening the wallet
    75  	// database.
    76  	DBTimeout time.Duration
    77  }
    78  
    79  // GetBoltBackend opens (or creates if doesn't exits) a bbolt backed database
    80  // and returns a kvdb.Backend wrapping it.
    81  func GetBoltBackend(cfg *BoltBackendConfig) (Backend, error) {
    82  	dbFilePath := filepath.Join(cfg.DBPath, cfg.DBFileName)
    83  
    84  	// Is this a new database?
    85  	if !fileExists(dbFilePath) {
    86  		if !fileExists(cfg.DBPath) {
    87  			if err := os.MkdirAll(cfg.DBPath, 0700); err != nil {
    88  				return nil, err
    89  			}
    90  		}
    91  
    92  		return Create(
    93  			BoltBackendName, dbFilePath,
    94  			cfg.NoFreelistSync, cfg.DBTimeout,
    95  		)
    96  	}
    97  
    98  	// This is an existing database. We might want to compact it on startup
    99  	// to free up some space.
   100  	if cfg.AutoCompact {
   101  		if err := compactAndSwap(cfg); err != nil {
   102  			return nil, err
   103  		}
   104  	}
   105  
   106  	return Open(
   107  		BoltBackendName, dbFilePath,
   108  		cfg.NoFreelistSync, cfg.DBTimeout,
   109  	)
   110  }
   111  
   112  // compactAndSwap will attempt to write a new temporary DB file to disk with
   113  // the compacted database content, then atomically swap (via rename) the old
   114  // file for the new file by updating the name of the new file to the old.
   115  func compactAndSwap(cfg *BoltBackendConfig) error {
   116  	sourceName := cfg.DBFileName
   117  
   118  	// If the main DB file isn't set, then we can't proceed.
   119  	if sourceName == "" {
   120  		return fmt.Errorf("cannot compact DB with empty name")
   121  	}
   122  	sourceFilePath := filepath.Join(cfg.DBPath, sourceName)
   123  	tempDestFilePath := filepath.Join(cfg.DBPath, DefaultTempDBFileName)
   124  
   125  	// Let's find out how long ago the last compaction of the source file
   126  	// occurred and possibly skip compacting it again now.
   127  	lastCompactionDate, err := lastCompactionDate(sourceFilePath)
   128  	if err != nil {
   129  		return fmt.Errorf("cannot determine last compaction date of "+
   130  			"source DB file: %v", err)
   131  	}
   132  	compactAge := time.Since(lastCompactionDate)
   133  	if cfg.AutoCompactMinAge != 0 && compactAge <= cfg.AutoCompactMinAge {
   134  		log.Infof("Not compacting database file at %v, it was last "+
   135  			"compacted at %v (%v ago), min age is set to %v",
   136  			sourceFilePath, lastCompactionDate,
   137  			compactAge.Truncate(time.Second), cfg.AutoCompactMinAge)
   138  		return nil
   139  	}
   140  
   141  	log.Infof("Compacting database file at %v", sourceFilePath)
   142  
   143  	// If the old temporary DB file still exists, then we'll delete it
   144  	// before proceeding.
   145  	if _, err := os.Stat(tempDestFilePath); err == nil {
   146  		log.Infof("Found old temp DB @ %v, removing before swap",
   147  			tempDestFilePath)
   148  
   149  		err = os.Remove(tempDestFilePath)
   150  		if err != nil {
   151  			return fmt.Errorf("unable to remove old temp DB file: "+
   152  				"%v", err)
   153  		}
   154  	}
   155  
   156  	// Now that we know the staging area is clear, we'll create the new
   157  	// temporary DB file and close it before we write the new DB to it.
   158  	tempFile, err := os.Create(tempDestFilePath)
   159  	if err != nil {
   160  		return fmt.Errorf("unable to create temp DB file: %v", err)
   161  	}
   162  	if err := tempFile.Close(); err != nil {
   163  		return fmt.Errorf("unable to close file: %v", err)
   164  	}
   165  
   166  	// With the file created, we'll start the compaction and remove the
   167  	// temporary file all together once this method exits.
   168  	defer func() {
   169  		// This will only succeed if the rename below fails. If the
   170  		// compaction is successful, the file won't exist on exit
   171  		// anymore so no need to log an error here.
   172  		_ = os.Remove(tempDestFilePath)
   173  	}()
   174  	c := &compacter{
   175  		srcPath:   sourceFilePath,
   176  		dstPath:   tempDestFilePath,
   177  		dbTimeout: cfg.DBTimeout,
   178  	}
   179  	initialSize, newSize, err := c.execute()
   180  	if err != nil {
   181  		return fmt.Errorf("error during compact: %v", err)
   182  	}
   183  
   184  	log.Infof("DB compaction of %v successful, %d -> %d bytes (gain=%.2fx)",
   185  		sourceFilePath, initialSize, newSize,
   186  		float64(initialSize)/float64(newSize))
   187  
   188  	// We try to store the current timestamp in a file with the suffix
   189  	// .last-compacted so we can figure out how long ago the last compaction
   190  	// was. But since this shouldn't fail the compaction process itself, we
   191  	// only log the error. Worst case if this file cannot be written is that
   192  	// we compact on every startup.
   193  	err = updateLastCompactionDate(sourceFilePath)
   194  	if err != nil {
   195  		log.Warnf("Could not update last compaction timestamp in "+
   196  			"%s%s: %v", sourceFilePath,
   197  			LastCompactionFileNameSuffix, err)
   198  	}
   199  
   200  	log.Infof("Swapping old DB file from %v to %v", tempDestFilePath,
   201  		sourceFilePath)
   202  
   203  	// Finally, we'll attempt to atomically rename the temporary file to
   204  	// the main back up file. If this succeeds, then we'll only have a
   205  	// single file on disk once this method exits.
   206  	return os.Rename(tempDestFilePath, sourceFilePath)
   207  }
   208  
   209  // lastCompactionDate returns the date the given database file was last
   210  // compacted or a zero time.Time if no compaction was recorded before. The
   211  // compaction date is read from a file in the same directory and with the same
   212  // name as the DB file, but with the suffix ".last-compacted".
   213  func lastCompactionDate(dbFile string) (time.Time, error) {
   214  	zeroTime := time.Unix(0, 0)
   215  
   216  	tsFile := fmt.Sprintf("%s%s", dbFile, LastCompactionFileNameSuffix)
   217  	if !fileExists(tsFile) {
   218  		return zeroTime, nil
   219  	}
   220  
   221  	tsBytes, err := ioutil.ReadFile(tsFile)
   222  	if err != nil {
   223  		return zeroTime, err
   224  	}
   225  
   226  	tsNano := byteOrder.Uint64(tsBytes)
   227  	return time.Unix(0, int64(tsNano)), nil
   228  }
   229  
   230  // updateLastCompactionDate stores the current time as a timestamp in a file
   231  // in the same directory and with the same name as the DB file, but with the
   232  // suffix ".last-compacted".
   233  func updateLastCompactionDate(dbFile string) error {
   234  	var tsBytes [8]byte
   235  	byteOrder.PutUint64(tsBytes[:], uint64(time.Now().UnixNano()))
   236  
   237  	tsFile := fmt.Sprintf("%s%s", dbFile, LastCompactionFileNameSuffix)
   238  	return ioutil.WriteFile(tsFile, tsBytes[:], 0600)
   239  }
   240  
   241  // GetTestBackend opens (or creates if doesn't exist) a bbolt or etcd
   242  // backed database (for testing), and returns a kvdb.Backend and a cleanup
   243  // func. Whether to create/open bbolt or embedded etcd database is based
   244  // on the TestBackend constant which is conditionally compiled with build tag.
   245  // The passed path is used to hold all db files, while the name is only used
   246  // for bolt.
   247  func GetTestBackend(path, name string) (Backend, func(), error) {
   248  	empty := func() {}
   249  
   250  	switch {
   251  	case PostgresBackend:
   252  		key := filepath.Join(path, name)
   253  		keyHash := sha256.Sum256([]byte(key))
   254  
   255  		f, err := NewPostgresFixture("test_" + hex.EncodeToString(keyHash[:]))
   256  		if err != nil {
   257  			return nil, func() {}, err
   258  		}
   259  		return f.DB(), func() {
   260  			_ = f.DB().Close()
   261  		}, nil
   262  
   263  	case TestBackend == BoltBackendName:
   264  		db, err := GetBoltBackend(&BoltBackendConfig{
   265  			DBPath:         path,
   266  			DBFileName:     name,
   267  			NoFreelistSync: true,
   268  			DBTimeout:      DefaultDBTimeout,
   269  		})
   270  		if err != nil {
   271  			return nil, nil, err
   272  		}
   273  		return db, empty, nil
   274  
   275  	case TestBackend == EtcdBackendName:
   276  		etcdConfig, cancel, err := StartEtcdTestBackend(path, 0, 0, "")
   277  		if err != nil {
   278  			return nil, empty, err
   279  		}
   280  		backend, err := Open(
   281  			EtcdBackendName, context.TODO(), etcdConfig,
   282  		)
   283  		return backend, cancel, err
   284  
   285  	}
   286  
   287  	return nil, nil, fmt.Errorf("unknown backend")
   288  }