github.com/dolthub/dolt/go@v0.40.5-0.20240520175717-68db7794bea6/libraries/doltcore/sqle/dropped_databases.go (about)

     1  // Copyright 2023 Dolthub, Inc.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package sqle
    16  
    17  import (
    18  	"fmt"
    19  	"path/filepath"
    20  	"strings"
    21  	"time"
    22  
    23  	"github.com/dolthub/go-mysql-server/sql"
    24  
    25  	"github.com/dolthub/dolt/go/libraries/doltcore/dbfactory"
    26  	"github.com/dolthub/dolt/go/libraries/utils/errors"
    27  	"github.com/dolthub/dolt/go/libraries/utils/filesys"
    28  )
    29  
    30  // droppedDatabaseDirectoryName is the subdirectory within the data folder where Dolt moves databases after they are
    31  // dropped. The dolt_undrop() stored procedure is then able to restore them from this location.
    32  const droppedDatabaseDirectoryName = ".dolt_dropped_databases"
    33  
    34  // droppedDatabaseManager is responsible for dropping databases and "undropping", or restoring, dropped databases. It
    35  // is given a Filesys where all database directories can be found. When dropping a database, instead of deleting the
    36  // database directory, it will move it to a new ".dolt_dropped_databases" directory where databases can be restored.
    37  type droppedDatabaseManager struct {
    38  	fs filesys.Filesys
    39  }
    40  
    41  // newDroppedDatabaseManager creates a new droppedDatabaseManager instance using the specified |fs| as the location
    42  // where databases can be found. It will create a new ".dolt_dropped_databases" directory at the root of |fs| where
    43  // dropped databases will be moved until they are permanently removed.
    44  func newDroppedDatabaseManager(fs filesys.Filesys) *droppedDatabaseManager {
    45  	return &droppedDatabaseManager{
    46  		fs: fs,
    47  	}
    48  }
    49  
    50  // DropDatabase will move the database directory for the database named |name| at the location |dropDbLoc| to the
    51  // dolt_dropped_database directory where it can later be "undropped" to restore it. If any problems are encountered
    52  // moving the database directory, an error is returned.
    53  func (dd *droppedDatabaseManager) DropDatabase(ctx *sql.Context, name string, dropDbLoc string) error {
    54  	rootDbLoc, err := dd.fs.Abs("")
    55  	if err != nil {
    56  		return err
    57  	}
    58  
    59  	isRootDatabase := false
    60  	// if the database is in the directory itself, we remove '.dolt' directory rather than
    61  	// the whole directory itself because it can have other databases that are nested.
    62  	if rootDbLoc == dropDbLoc {
    63  		doltDirExists, _ := dd.fs.Exists(dbfactory.DoltDir)
    64  		if !doltDirExists {
    65  			return sql.ErrDatabaseNotFound.New(name)
    66  		}
    67  		dropDbLoc = filepath.Join(dropDbLoc, dbfactory.DoltDir)
    68  		isRootDatabase = true
    69  	}
    70  
    71  	if err = dd.initializeDeletedDatabaseDirectory(); err != nil {
    72  		return fmt.Errorf("unable to drop database %s: %w", name, err)
    73  	}
    74  
    75  	// Move the dropped database to the Dolt deleted database directory so it can be restored if needed
    76  	_, file := filepath.Split(dropDbLoc)
    77  	var destinationDirectory string
    78  	if isRootDatabase {
    79  		// For a root database, first create the subdirectory before we copy over the .dolt directory
    80  		newSubdirectory := filepath.Join(droppedDatabaseDirectoryName, name)
    81  		if err := dd.fs.MkDirs(newSubdirectory); err != nil {
    82  			return err
    83  		}
    84  		destinationDirectory = filepath.Join(newSubdirectory, file)
    85  	} else {
    86  		destinationDirectory = filepath.Join(droppedDatabaseDirectoryName, file)
    87  	}
    88  
    89  	// Add the final directory segment and convert any invalid chars so that the physical directory
    90  	// name matches the current logical/SQL name of the database.
    91  	dir, base := filepath.Split(destinationDirectory)
    92  	base = dbfactory.DirToDBName(file)
    93  	destinationDirectory = filepath.Join(dir, base)
    94  
    95  	if err := dd.prepareToMoveDroppedDatabase(ctx, destinationDirectory); err != nil {
    96  		return err
    97  	}
    98  
    99  	return dd.fs.MoveDir(dropDbLoc, destinationDirectory)
   100  }
   101  
   102  // UndropDatabase will restore the database named |name| by moving it from the dolt_dropped_database directory, back
   103  // into the root of the filesystem where database directories are managed. This function returns the new location of
   104  // the database directory and the exact name (case-sensitive) of the database. If any errors are encountered while
   105  // attempting to undrop the database, an error is returned and other return parameters should be ignored.
   106  func (dd *droppedDatabaseManager) UndropDatabase(ctx *sql.Context, name string) (filesys.Filesys, string, error) {
   107  	sourcePath, destinationPath, exactCaseName, err := dd.validateUndropDatabase(ctx, name)
   108  	if err != nil {
   109  		return nil, "", err
   110  	}
   111  
   112  	err = dd.fs.MoveDir(sourcePath, destinationPath)
   113  	if err != nil {
   114  		return nil, "", err
   115  	}
   116  
   117  	newFs, err := dd.fs.WithWorkingDir(exactCaseName)
   118  	if err != nil {
   119  		return nil, "", err
   120  	}
   121  
   122  	return newFs, exactCaseName, nil
   123  }
   124  
   125  // PurgeAllDroppedDatabases permanently removes all dropped databases that are being held in the dolt_dropped_database
   126  // holding directory. Once dropped databases are purged, they can no longer be restored, so this method should be used
   127  // with caution.
   128  func (dd *droppedDatabaseManager) PurgeAllDroppedDatabases(_ *sql.Context) error {
   129  	// If the dropped database holding directory doesn't exist, then there's nothing to purge
   130  	if exists, _ := dd.fs.Exists(droppedDatabaseDirectoryName); !exists {
   131  		return nil
   132  	}
   133  
   134  	var err error
   135  	callback := func(path string, size int64, isDir bool) (stop bool) {
   136  		// Sanity check that the path we're about to delete is under the dropped database holding directory
   137  		if strings.Contains(path, droppedDatabaseDirectoryName) == false {
   138  			err = fmt.Errorf("path of database to purge isn't under dropped database holding directory: %s", path)
   139  			return true
   140  		}
   141  
   142  		// Attempt to permanently delete the dropped database and stop execution if we hit an error
   143  		err = dd.fs.Delete(path, true)
   144  		return err != nil
   145  	}
   146  	iterErr := dd.fs.Iter(droppedDatabaseDirectoryName, false, callback)
   147  	if iterErr != nil {
   148  		return iterErr
   149  	}
   150  
   151  	return err
   152  }
   153  
   154  // initializeDeletedDatabaseDirectory initializes the special directory Dolt uses to store dropped databases until
   155  // they are fully removed. If the directory is already created and set up correctly, then this method is a no-op.
   156  // If the directory doesn't exist yet, it will be created. If there are any problems initializing the directory, an
   157  // error is returned.
   158  func (dd *droppedDatabaseManager) initializeDeletedDatabaseDirectory() error {
   159  	exists, isDir := dd.fs.Exists(droppedDatabaseDirectoryName)
   160  	if exists && !isDir {
   161  		return fmt.Errorf("%s exists, but is not a directory", droppedDatabaseDirectoryName)
   162  	}
   163  
   164  	if exists {
   165  		return nil
   166  	}
   167  
   168  	return dd.fs.MkDirs(droppedDatabaseDirectoryName)
   169  }
   170  
   171  func (dd *droppedDatabaseManager) ListDroppedDatabases(_ *sql.Context) ([]string, error) {
   172  	if err := dd.initializeDeletedDatabaseDirectory(); err != nil {
   173  		return nil, fmt.Errorf("unable to list undroppable database: %w", err)
   174  	}
   175  
   176  	databaseNames := make([]string, 0, 5)
   177  	callback := func(path string, size int64, isDir bool) (stop bool) {
   178  		// When we move a database to the dropped database directory, we normalize the physical directory
   179  		// name to be the same as the logical SQL name, so there's no need to do any name mapping here.
   180  		databaseNames = append(databaseNames, filepath.Base(path))
   181  		return false
   182  	}
   183  
   184  	if err := dd.fs.Iter(droppedDatabaseDirectoryName, false, callback); err != nil {
   185  		return nil, err
   186  	}
   187  
   188  	return databaseNames, nil
   189  }
   190  
   191  // validateUndropDatabase validates that the database |name| is available to be "undropped" and that no existing
   192  // database is already being managed that has the same (case-insensitive) name. If any problems are encountered,
   193  // an error is returned.
   194  func (dd *droppedDatabaseManager) validateUndropDatabase(ctx *sql.Context, name string) (sourcePath, destinationPath, exactCaseName string, err error) {
   195  	availableDatabases, err := dd.ListDroppedDatabases(ctx)
   196  	if err != nil {
   197  		return "", "", "", err
   198  	}
   199  
   200  	found, exactCaseName := hasCaseInsensitiveMatch(availableDatabases, name)
   201  	if !found {
   202  		return "", "", "", fmt.Errorf("no database named '%s' found to undrop. %s",
   203  			name, errors.CreateUndropErrorMessage(availableDatabases))
   204  	}
   205  
   206  	// Check to see if the destination directory for restoring the database already exists (case-insensitive match)
   207  	destinationPath, err = dd.fs.Abs(exactCaseName)
   208  	if err != nil {
   209  		return "", "", "", err
   210  	}
   211  
   212  	if hasCaseInsensitivePath(dd.fs, destinationPath) {
   213  		return "", "", "", fmt.Errorf("unable to undrop database '%s'; "+
   214  			"another database already exists with the same case-insensitive name", exactCaseName)
   215  	}
   216  
   217  	sourcePath = filepath.Join(droppedDatabaseDirectoryName, exactCaseName)
   218  	return sourcePath, destinationPath, exactCaseName, nil
   219  }
   220  
   221  // hasCaseInsensitivePath returns true if the specified path |target| already exists on the filesystem |fs|, with
   222  // a case-insensitive match on the final component of the path. Note that only the final component of the path is
   223  // checked in a case-insensitive match – the other components of the path must be a case-sensitive match.
   224  func hasCaseInsensitivePath(fs filesys.Filesys, target string) bool {
   225  	found := false
   226  	fs.Iter(filepath.Dir(target), false, func(path string, size int64, isDir bool) (stop bool) {
   227  		if strings.ToLower(filepath.Base(path)) == strings.ToLower(filepath.Base(target)) {
   228  			found = true
   229  		}
   230  		return found
   231  	})
   232  	return found
   233  }
   234  
   235  // hasCaseInsensitiveMatch tests to see if any of |candidates| are a case-insensitive match for |target| and if so,
   236  // returns true along with the exact candidate string that matched. If there was not a match, false and the empty
   237  // string are returned.
   238  func hasCaseInsensitiveMatch(candidates []string, target string) (bool, string) {
   239  	found := false
   240  	exactCaseName := ""
   241  	lowercaseName := strings.ToLower(target)
   242  	for _, s := range candidates {
   243  		if lowercaseName == strings.ToLower(s) {
   244  			exactCaseName = s
   245  			found = true
   246  			break
   247  		}
   248  	}
   249  
   250  	return found, exactCaseName
   251  }
   252  
   253  // prepareToMoveDroppedDatabase checks the specified |targetPath| to make sure there is not already a dropped database
   254  // there, and if so, the existing dropped database will be renamed with a unique suffix. If any problems are encountered,
   255  // such as not being able to rename an existing dropped database, this function will return an error.
   256  func (dd *droppedDatabaseManager) prepareToMoveDroppedDatabase(_ *sql.Context, targetPath string) error {
   257  	if exists, _ := dd.fs.Exists(targetPath); !exists {
   258  		// If there's nothing at the desired targetPath, we're all set
   259  		return nil
   260  	}
   261  
   262  	// If there is something already there, pick a new path to move it to
   263  	newPath := fmt.Sprintf("%s.backup.%d", targetPath, time.Now().UnixMilli())
   264  	if exists, _ := dd.fs.Exists(newPath); exists {
   265  		return fmt.Errorf("unable to move existing dropped database out of the way: "+
   266  			"tried to move it to %s", newPath)
   267  	}
   268  	if err := dd.fs.MoveDir(targetPath, newPath); err != nil {
   269  		return fmt.Errorf("unable to move existing dropped database out of the way: %w", err)
   270  	}
   271  
   272  	return nil
   273  }