mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-08-28 10:54:59 +00:00
The testing package is full of subtle magic, and one of the most subtle is this: t.Logf, etcetera, all write to a buffer which is then displayed after the test is run. Which means that, if you exit, the buffer is never displayed. This means that, if a test case can fail in a way that causes an instant exit, you don't hit defers, you don't get your log messages, you just get a mysterious exit of the process. We have two cases where backup commands were calling log.Fatal instead of returning an error. The error in question is displayed correctly and informatively if returned, so we return it. We also have one case where we were using os.Exit to avoid a deadlock. Instead, we make the thing that would deadlock conditional on the test not having failed. In the event that the test fails, we now print our failure message correctly, then also report an unclosed cluster. That's fine.
575 lines
16 KiB
Go
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", UsageError)
|
|
} else if cmd.Concurrency <= 0 {
|
|
return fmt.Errorf("%w: concurrency must be at least one", UsageError)
|
|
}
|
|
if cmd.HeaderTimeoutStr != "" {
|
|
if dur, err := time.ParseDuration(cmd.HeaderTimeoutStr); err != nil {
|
|
return fmt.Errorf("%w: could not parse '%s' as a duration: %v", UsageError, 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()
|
|
}
|