diff options
author | John Cai <jcai@gitlab.com> | 2022-04-19 20:40:19 +0300 |
---|---|---|
committer | John Cai <jcai@gitlab.com> | 2022-04-21 05:42:13 +0300 |
commit | 1a7197e3b2171626a95fbab2b28ecbe43ea79aea (patch) | |
tree | 4aa72be3f59fcf4050d8fefffd73148838301b1e | |
parent | 6ff441e3d1303f9507c0ca2bf599d2fe8f5798d8 (diff) |
safe: Add new LockingDirectory
Sometimes it's useful to be able to lock a directory before modifying
it. This is similar to LockingFileWriter, except it just gives the
ability to lock a directory so another process cannot also lock the
directory.
This will be immediately useful in RestoreCustomHooks where we are about
ot add transactional voting to it. With transactional voting, the
semantics are that the `Prepared` vote is made once the data that is
about to be changed is locked.
This will enable us to lock the custom hooks directory.
Changelog: added
-rw-r--r-- | internal/safe/locking_directory.go | 95 | ||||
-rw-r--r-- | internal/safe/locking_directory_test.go | 78 |
2 files changed, 173 insertions, 0 deletions
diff --git a/internal/safe/locking_directory.go b/internal/safe/locking_directory.go new file mode 100644 index 000000000..b15483bce --- /dev/null +++ b/internal/safe/locking_directory.go @@ -0,0 +1,95 @@ +package safe + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" +) + +type lockingDirectoryState int + +const ( + lockingDirectoryStateUnlocked lockingDirectoryState = iota + lockingDirectoryStateLocked +) + +// LockingDirectory allows locking and unlocking a directory for safe access and +// modification. +type LockingDirectory struct { + state lockingDirectoryState + path string +} + +// NewLockingDirectory creates a new LockingDirectory. +func NewLockingDirectory(path string) (*LockingDirectory, error) { + fi, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("creating new locking directory: %w", err) + } + + if !fi.IsDir() { + return nil, errors.New("not a directory") + } + + ld := &LockingDirectory{ + state: lockingDirectoryStateUnlocked, + path: path, + } + + return ld, nil +} + +// Lock locks the directory and prevents a second process with a +// LockingDirectory from also locking the directory. +func (ld *LockingDirectory) Lock() error { + if ld.state != lockingDirectoryStateUnlocked { + return errors.New("locking directory not lockable") + } + + lock, err := os.OpenFile(ld.lockPath(), os.O_CREATE|os.O_EXCL|os.O_RDONLY, 0o400) + if err != nil { + if os.IsExist(err) { + return ErrFileAlreadyLocked + } + + return fmt.Errorf("creating lock file: %w", err) + } + _ = lock.Close() + + ld.state = lockingDirectoryStateLocked + + return nil +} + +// IsLocked returns whether or not the directory has been locked. +func (ld *LockingDirectory) IsLocked() bool { + return ld.state == lockingDirectoryStateLocked +} + +// Unlock unlocks the directory. +func (ld *LockingDirectory) Unlock() error { + if ld.state != lockingDirectoryStateLocked { + return errors.New("locking directory not locked") + } + + if err := os.Remove(ld.lockPath()); err != nil { + // A previous call might have returned an error + // but still removed the file. + if errors.Is(err, fs.ErrNotExist) { + ld.state = lockingDirectoryStateUnlocked + return nil + } + + return fmt.Errorf("unlocking directory: %w", err) + } + + ld.state = lockingDirectoryStateUnlocked + + return nil +} + +func (ld *LockingDirectory) lockPath() string { + return filepath.Join(ld.path, ".lock") +} diff --git a/internal/safe/locking_directory_test.go b/internal/safe/locking_directory_test.go new file mode 100644 index 000000000..e9280c3dd --- /dev/null +++ b/internal/safe/locking_directory_test.go @@ -0,0 +1,78 @@ +package safe_test + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gitlab.com/gitlab-org/gitaly/v14/internal/safe" + "gitlab.com/gitlab-org/gitaly/v14/internal/testhelper" +) + +func TestLockingDirectory(t *testing.T) { + t.Parallel() + + t.Run("normal lifecycle", func(t *testing.T) { + path := testhelper.TempDir(t) + lockingDir, err := safe.NewLockingDirectory(path) + require.NoError(t, err) + require.NoError(t, lockingDir.Lock()) + secondLockingDir, err := safe.NewLockingDirectory(path) + require.NoError(t, err) + require.NoError(t, os.WriteFile( + filepath.Join(path, "somefile"), + []byte("data"), + 0o644), + ) + assert.ErrorIs(t, secondLockingDir.Lock(), safe.ErrFileAlreadyLocked) + require.NoError(t, lockingDir.Unlock()) + }) + + t.Run("multiple locks fail", func(t *testing.T) { + path := testhelper.TempDir(t) + lockingDir, err := safe.NewLockingDirectory(path) + require.NoError(t, err) + require.NoError(t, lockingDir.Lock()) + assert.Equal( + t, + errors.New("locking directory not lockable"), + lockingDir.Lock(), + ) + }) + + t.Run("unlock without lock fails", func(t *testing.T) { + path := testhelper.TempDir(t) + lockingDir, err := safe.NewLockingDirectory(path) + require.NoError(t, err) + assert.Equal( + t, + errors.New("locking directory not locked"), + lockingDir.Unlock(), + ) + }) + + t.Run("multiple unlocks fail", func(t *testing.T) { + path := testhelper.TempDir(t) + lockingDir, err := safe.NewLockingDirectory(path) + require.NoError(t, err) + require.NoError(t, lockingDir.Lock()) + require.NoError(t, lockingDir.Unlock()) + assert.Equal( + t, + errors.New("locking directory not locked"), + lockingDir.Unlock(), + ) + }) + + t.Run("fails if directory is missing", func(t *testing.T) { + path := testhelper.TempDir(t) + require.NoError(t, os.RemoveAll(path)) + + _, err := safe.NewLockingDirectory(path) + assert.True(t, errors.Is(err, fs.ErrNotExist)) + }) +} |