featurebase/ctl/backup.go
Travis Turner d2856bfeee
Linters! (#2314)
* Add (commented out) linters that we should introduce

I went through the available linters and added (commented out) the ones
I think we should work on in the near term. In other words, fix them,
then uncomment them so they are enabled in CI.

* linter: errchkjson

* linter: ineffassign

* linter: gosimple

* linter: errname
2023-03-10 15:13:15 -06:00

575 lines
16 KiB
Go

// Copyright 2022 Molecula Corp. (DBA FeatureBase).
// SPDX-License-Identifier: Apache-2.0
package ctl
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"time"
pilosa "github.com/featurebasedb/featurebase/v3"
"github.com/featurebasedb/featurebase/v3/authn"
"github.com/featurebasedb/featurebase/v3/disco"
"github.com/featurebasedb/featurebase/v3/encoding/proto"
"github.com/featurebasedb/featurebase/v3/logger"
"github.com/featurebasedb/featurebase/v3/server"
"github.com/ricochet2200/go-disk-usage/du"
"golang.org/x/sync/errgroup"
)
// TODO(rdp): add refresh token to this as well
// BackupCommand represents a command for backing up a FeatureBase node.
type BackupCommand struct { // nolint: maligned
tlsConfig *tls.Config
// Destination host and port.
Host string `json:"host"`
// Optional Index filter
Index string `json:"index"`
// Path to write the backup to.
OutputDir string
// If true, skips file sync.
NoSync bool
// Number of concurrent backup goroutines running at a time.
Concurrency int
// Amount of time after first failed request to continue retrying.
RetryPeriod time.Duration `json:"retry-period"`
// Response Header Timeout for HTTP Requests
HeaderTimeoutStr string
HeaderTimeout time.Duration `json:"header-timeout"`
// Host:port on which to listen for pprof.
Pprof string `json:"pprof"`
// Reusable client.
client *pilosa.InternalClient
// Standard input/output
logDest logger.Logger
TLS server.TLSConfig
AuthToken string
IgnoreSpaceCheck bool
}
// Logger returns the command's associated Logger to maintain CommandWithTLSSupport interface compatibility
func (cmd *BackupCommand) Logger() logger.Logger {
return cmd.logDest
}
// NewBackupCommand returns a new instance of BackupCommand.
func NewBackupCommand(logdest logger.Logger) *BackupCommand {
return &BackupCommand{
logDest: logdest,
Concurrency: 1,
RetryPeriod: time.Minute,
HeaderTimeout: time.Second * 3,
Pprof: "localhost:0",
}
}
// Run executes the main program execution.
func (cmd *BackupCommand) Run(ctx context.Context) (err error) {
logger := cmd.Logger()
close, err := startProfilingServer(cmd.Pprof, logger)
if err != nil {
return fmt.Errorf("starting profiling server: %w", err)
}
defer close()
// Validate arguments.
if cmd.OutputDir == "" {
return fmt.Errorf("%w: -o flag required", ErrUsage)
} else if cmd.Concurrency <= 0 {
return fmt.Errorf("%w: concurrency must be at least one", ErrUsage)
}
if cmd.HeaderTimeoutStr != "" {
if dur, err := time.ParseDuration(cmd.HeaderTimeoutStr); err != nil {
return fmt.Errorf("%w: could not parse '%s' as a duration: %v", ErrUsage, cmd.HeaderTimeoutStr, err)
} else {
cmd.HeaderTimeout = dur
}
}
// Parse TLS configuration for node-specific clients.
tls := cmd.TLSConfiguration()
if cmd.tlsConfig, err = server.GetTLSConfig(&tls, cmd.Logger()); err != nil {
return fmt.Errorf("parsing tls config: %w", err)
}
// Create a client to the server.
client, err := commandClient(cmd, pilosa.WithClientRetryPeriod(cmd.RetryPeriod), pilosa.ClientResponseHeaderTimeoutOption(cmd.HeaderTimeout))
if err != nil {
return fmt.Errorf("creating client: %w", err)
}
cmd.client = client
if cmd.AuthToken != "" {
ctx = authn.WithAccessToken(ctx, "Bearer "+cmd.AuthToken)
}
// Determine the field type in order to correctly handle the input data.
indexes, err := cmd.client.Schema(ctx)
if err != nil {
return fmt.Errorf("getting schema: %w", err)
}
if cmd.Index != "" {
for _, idx := range indexes {
if idx.Name == cmd.Index {
indexes = make([]*pilosa.IndexInfo, 0)
indexes = append(indexes, idx)
break
}
}
if len(indexes) <= 0 {
return fmt.Errorf("index not found to back up")
}
}
schema := &pilosa.Schema{Indexes: indexes}
// Ensure output directory doesn't exist; then create output directory.
if _, err := os.Stat(cmd.OutputDir); !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("output directory already exists")
} else if err := os.MkdirAll(cmd.OutputDir, 0o750); err != nil {
return err
}
if !cmd.IgnoreSpaceCheck {
// Ensure there is enough free space
if err := cmd.checkFreeSpace(ctx); err != nil {
return fmt.Errorf("not enough disk space available: %w", err)
}
}
// Backup schema.
if err := cmd.backupSchema(ctx, schema); err != nil {
return fmt.Errorf("cannot back up schema: %w", err)
} else if err := cmd.backupIDAllocData(ctx); err != nil {
return fmt.Errorf("cannot back up id alloc data: %w", err)
}
// Backup data for each index.
for _, ii := range schema.Indexes {
if err := cmd.backupIndexData(ctx, ii); err != nil {
return err
}
}
// Backup translation data. This has to happen separately, because
// otherwise a field which uses foreign key translation can reasonably
// contain values which got created for the foreign index after we
// backed up that index.
for _, ii := range schema.Indexes {
if err := cmd.backupIndexTranslation(ctx, ii); err != nil {
return err
}
}
// Wait for the OS to persist all directories.
err = cmd.syncDirectories(ctx)
if err != nil {
return fmt.Errorf("syncing directories: %w", err)
}
return nil
}
// backupSchema writes the schema to the archive.
func (cmd *BackupCommand) backupSchema(ctx context.Context, schema *pilosa.Schema) error {
logger := cmd.Logger()
logger.Printf("backing up schema")
buf, err := json.MarshalIndent(schema, "", "\t")
if err != nil {
return fmt.Errorf("marshaling schema: %w", err)
}
if err := os.WriteFile(filepath.Join(cmd.OutputDir, "schema"), buf, 0o600); err != nil {
return fmt.Errorf("writing schema: %w", err)
}
return nil
}
func (cmd *BackupCommand) backupIDAllocData(ctx context.Context) error {
logger := cmd.Logger()
logger.Printf("backing up id alloc data")
rc, err := cmd.client.IDAllocDataReader(ctx)
if err != nil {
return fmt.Errorf("fetching id alloc data reader: %w", err)
}
defer rc.Close()
f, err := os.Create(filepath.Join(cmd.OutputDir, "idalloc"))
if err != nil {
return err
}
defer f.Close()
if _, err := io.Copy(f, rc); err != nil {
return err
} else if err := cmd.syncFile(f); err != nil {
return err
}
return f.Close()
}
// backupIndexTranslation backs up both field and index-wide key translation for
// the given index. it has to run after the index's data has been backed up,
// but also after the data for any index which might have a foreign-key
// relation to this index has been backed up.
func (cmd *BackupCommand) backupIndexTranslation(ctx context.Context, ii *pilosa.IndexInfo) error {
logger := cmd.Logger()
logger.Printf("backing up index translation: %q", ii.Name)
if ii.Options.Keys {
if err := cmd.backupIndexTranslateData(ctx, ii.Name); err != nil {
return err
}
}
// Back up field translation data.
for _, fi := range ii.Fields {
if !fi.Options.Keys {
continue
}
if err := cmd.backupFieldTranslateData(ctx, ii.Name, fi.Name); err != nil {
return fmt.Errorf("cannot backup field translation data for field %q on index %q: %w", fi.Name, ii.Name, err)
}
}
return nil
}
// backupIndexData backs up all shard data for a given index.
func (cmd *BackupCommand) backupIndexData(ctx context.Context, ii *pilosa.IndexInfo) error {
logger := cmd.Logger()
logger.Printf("backing up index data: %q", ii.Name)
shards, err := cmd.client.AvailableShards(ctx, ii.Name)
if err != nil {
return fmt.Errorf("cannot find available shards for index %q: %w", ii.Name, err)
}
// Back up all bitmap data for the index.
ch := make(chan uint64, len(shards))
for _, shard := range shards {
ch <- shard
}
close(ch)
g, ctx := errgroup.WithContext(ctx)
for i := 0; i < cmd.Concurrency; i++ {
g.Go(func() error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case shard, ok := <-ch:
if !ok {
return nil
} else if err := cmd.backupShard(ctx, ii.Name, shard); err != nil {
return fmt.Errorf("cannot backup shard %d on index %q: %w", shard, ii.Name, err)
}
}
}
})
}
return g.Wait()
}
// checkFreeSpace checks if there is enough space in the output directory to backup data
func (cmd *BackupCommand) checkFreeSpace(ctx context.Context) (err error) {
freeSpace := du.NewDiskUsage(cmd.OutputDir).Free()
var usage pilosa.DiskUsage
if cmd.Index == "" {
usage, err = cmd.client.GetDiskUsage(ctx)
} else {
usage, err = cmd.client.GetIndexUsage(ctx, cmd.Index)
}
if err != nil {
return fmt.Errorf("getting size of data to be backed up: %s", err)
}
if freeSpace < uint64(usage.Usage) {
return fmt.Errorf("not enough disk space available, free: %v, index usage: %v", freeSpace, usage.Usage)
}
return nil
}
// backupShard backs up a single shard from a single index.
func (cmd *BackupCommand) backupShard(ctx context.Context, indexName string, shard uint64) (err error) {
nodes, err := cmd.client.FragmentNodes(ctx, indexName, shard)
if err != nil {
return fmt.Errorf("cannot determine fragment nodes: %w", err)
} else if len(nodes) == 0 {
return fmt.Errorf("no nodes available")
}
for _, node := range nodes {
if e := cmd.backupShardNode(ctx, indexName, shard, node); e == nil {
break
} else if err == nil {
err = e // save first error, try next node
}
}
for _, node := range nodes {
if e := cmd.backupShardDataframe(ctx, indexName, shard, node); e == nil {
return nil // backup ok, exit
} else if err == nil {
err = e // save first error, try next node
}
}
return err
}
// backupShardNode backs up a single shard from a single index on a specific node.
func (cmd *BackupCommand) backupShardNode(ctx context.Context, indexName string, shard uint64, node *disco.Node) error {
logger := cmd.Logger()
logger.Printf("backing up shard: index=%q id=%d", indexName, shard)
client := pilosa.NewInternalClientFromURI(&node.URI,
pilosa.GetHTTPClient(cmd.tlsConfig, pilosa.ClientResponseHeaderTimeoutOption(cmd.HeaderTimeout)),
pilosa.WithClientRetryPeriod(cmd.RetryPeriod),
pilosa.WithSerializer(proto.Serializer{}))
rc, err := client.ShardReader(ctx, indexName, shard)
if err != nil {
return fmt.Errorf("fetching shard reader: %w", err)
}
defer rc.Close()
filename := filepath.Join(cmd.OutputDir, "indexes", indexName, "shards", fmt.Sprintf("%04d", shard))
if err := os.MkdirAll(filepath.Dir(filename), 0o750); err != nil {
return err
}
f, err := os.Create(filename)
if err != nil {
return err
}
defer f.Close()
if _, err := io.Copy(f, rc); err != nil {
return err
} else if err := cmd.syncFile(f); err != nil {
return err
}
return f.Close()
}
func (cmd *BackupCommand) backupIndexTranslateData(ctx context.Context, name string) error {
partitionN := disco.DefaultPartitionN
ch := make(chan int, partitionN)
for partitionID := 0; partitionID < partitionN; partitionID++ {
ch <- partitionID
}
close(ch)
g, ctx := errgroup.WithContext(ctx)
for i := 0; i < cmd.Concurrency; i++ {
g.Go(func() error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case partitionID, ok := <-ch:
if !ok {
return nil
} else if err := cmd.backupIndexPartitionTranslateData(ctx, name, partitionID); err != nil {
return fmt.Errorf("cannot backup index translation data for partition %d on %q: %w", partitionID, name, err)
}
}
}
})
}
return g.Wait()
}
func (cmd *BackupCommand) backupIndexPartitionTranslateData(ctx context.Context, name string, partitionID int) error {
logger := cmd.Logger()
logger.Printf("backing up index translation data: %s/%d", name, partitionID)
rc, err := cmd.client.IndexTranslateDataReader(ctx, name, partitionID)
if err != nil {
return fmt.Errorf("fetching translate data reader: %w", err)
}
defer rc.Close()
filename := filepath.Join(cmd.OutputDir, "indexes", name, "translate", fmt.Sprintf("%04d", partitionID))
if err := os.MkdirAll(filepath.Dir(filename), 0o750); err != nil {
return err
}
f, err := os.Create(filename)
if err != nil {
return err
}
defer f.Close()
if _, err := io.Copy(f, rc); err != nil {
return err
} else if err := cmd.syncFile(f); err != nil {
return err
}
return f.Close()
}
func (cmd *BackupCommand) backupFieldTranslateData(ctx context.Context, indexName, fieldName string) error {
logger := cmd.Logger()
logger.Printf("backing up field translation data: %s/%s", indexName, fieldName)
rc, err := cmd.client.FieldTranslateDataReader(ctx, indexName, fieldName)
if err != nil {
return fmt.Errorf("fetching translate data reader: %w", err)
}
defer rc.Close()
filename := filepath.Join(cmd.OutputDir, "indexes", indexName, "fields", fieldName, "translate")
if err := os.MkdirAll(filepath.Dir(filename), 0o750); err != nil {
return err
}
f, err := os.Create(filename)
if err != nil {
return err
}
defer f.Close()
if _, err := io.Copy(f, rc); err != nil {
return err
} else if err := cmd.syncFile(f); err != nil {
return err
}
return f.Close()
}
func (cmd *BackupCommand) syncFile(f *os.File) error {
if cmd.NoSync {
return nil
}
return f.Sync()
}
func (cmd *BackupCommand) TLSHost() string { return cmd.Host }
func (cmd *BackupCommand) TLSConfiguration() server.TLSConfig { return cmd.TLS }
// syncDirectories fsyncs all directories required for the backup to be persisted to the filesystem.
func (cmd *BackupCommand) syncDirectories(ctx context.Context) error {
if cmd.NoSync {
return nil
}
syncChan := make(chan string, cmd.Concurrency)
syncChan <- filepath.Dir(cmd.OutputDir)
g, ctx := errgroup.WithContext(ctx)
for i := 0; i < cmd.Concurrency; i++ {
g.Go(func() error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case path, ok := <-syncChan:
if !ok {
return nil
} else if err := cmd.syncDir(path); err != nil {
return fmt.Errorf("cannot sync directory %q: %w", path, err)
}
}
}
})
}
err := filepath.Walk(cmd.OutputDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
select {
case <-ctx.Done():
return ctx.Err()
case syncChan <- path:
}
}
return nil
})
close(syncChan)
if err != nil {
return fmt.Errorf("walking output directory tree: %w", err)
}
return g.Wait()
}
func (cmd *BackupCommand) syncDir(path string) error {
logger := cmd.Logger()
logger.Printf("syncing directory: %s", path)
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("opening directory for sync: %w", err)
}
defer f.Close()
err = f.Sync()
if err != nil {
return fmt.Errorf("syncing directory: %w", err)
}
return f.Close()
}
func (cmd *BackupCommand) backupShardDataframe(ctx context.Context, indexName string, shard uint64, node *disco.Node) error {
logger := cmd.Logger()
logger.Printf("backing up dataframe shard: index=%q shard=%d", indexName, shard)
client := pilosa.NewInternalClientFromURI(&node.URI,
pilosa.GetHTTPClient(cmd.tlsConfig, pilosa.ClientResponseHeaderTimeoutOption(cmd.HeaderTimeout)),
)
resp, err := client.GetDataframeShard(ctx, indexName, shard)
// no error if doesn't exist
if err != nil {
return fmt.Errorf("getting dataframe: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
// no error if not present server maynot have it turned on
return nil
}
filename := filepath.Join(cmd.OutputDir, "indexes", indexName, "dataframe", fmt.Sprintf("%04d", shard))
if err := os.MkdirAll(filepath.Dir(filename), 0o750); err != nil {
return err
}
f, err := os.Create(filename)
if err != nil {
return err
}
defer f.Close()
if _, err := io.Copy(f, resp.Body); err != nil {
return err
} else if err := cmd.syncFile(f); err != nil {
return err
}
return f.Close()
}