Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf(ftp): reduce number of ftp commands run in install preparation #63

Merged
merged 8 commits into from
May 5, 2024
164 changes: 112 additions & 52 deletions cli/disk/ftp.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ package disk
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log"
"log/slog"
"net/textproto"
"net/url"
"path/filepath"
"path"
"slices"
"strings"
"time"

Expand Down Expand Up @@ -91,57 +94,94 @@ func testFTP(u *url.URL, options ...ftp.DialOption) (*ftp.ServerConn, bool, erro
return c, false, nil
}

func (l *ftpDisk) Exists(path string) (bool, error) {
res, err := l.acquire()
if err != nil {
return false, err
}
func (l *ftpDisk) existsWithLock(res *puddle.Resource[*ftp.ServerConn], p string) (bool, error) {
slog.Debug("checking if file exists", slog.String("path", clean(p)), slog.String("schema", "ftp"))

defer res.Release()
var protocolError *textproto.Error

slog.Debug("checking if file exists", slog.String("path", clean(path)), slog.String("schema", "ftp"))
_, err := res.Value().GetEntry(clean(p))
if err == nil {
return true, nil
}

split := strings.Split(clean(path)[1:], "/")
for _, s := range split[:len(split)-1] {
dir, err := l.readDirLock(res, "")
if err != nil {
return false, err
if errors.As(err, &protocolError) {
switch protocolError.Code {
case ftp.StatusFileUnavailable:
return false, nil
case ftp.StatusNotImplemented:
// GetEntry uses MLST, which might not be supported by the server.
// Even though in this case the error is not coming from the server,
// the ftp library still returns it as a protocol error.
default:
// We won't handle any other kind of error, such as
// * temporary errors (4xx) - should be retried after a while, so we won't deal with the delay
// * connection errors (x2x) - can't really do anything about them
// * authentication errors (x3x) - can't do anything about them
return false, fmt.Errorf("failed to get path info: %w", err)
}
} else {
// This is a non-protocol error, so we can't be sure what it means.
return false, fmt.Errorf("failed to get path info: %w", err)
}

currentDir, _ := res.Value().CurrentDir()
// In case MLST is not supported, we can try to LIST the target path.
// We can be sure that List() will actually execute LIST and not MLSD,
// since MLST was not supported in the previous step.
entries, err := res.Value().List(clean(p))
if err == nil {
if len(entries) > 0 {
// Some server implementations return an empty list for a nonexistent path,
// so we cannot be sure that no error means a directory exists unless it also contains some items.
// For files, when they exist, they will be listed as a single entry.
// TODO: so far the servers (just one) this was happening on also listed . and .. for valid dirs, because it was using `LIST -a`. Is that behaviour consistent that we can rely on it?
return true, nil
}
} else {
if errors.As(err, &protocolError) {
if protocolError.Code == ftp.StatusFileUnavailable {
return false, nil
}
}
// We won't handle any other kind of error, see above.
return false, fmt.Errorf("failed to list path: %w", err)
}

foundDir := false
// If we got here, either the path is an empty directory,
// or it does not exist and the server is a weird implementation.

// List the parent directory to determine if the path exists
dir, err := l.readDirLock(res, path.Dir(clean(p)))
if err == nil {
found := false
for _, entry := range dir {
if entry.IsDir() && entry.Name() == s {
foundDir = true
if entry.Name() == path.Base(clean(p)) {
found = true
break
}
}

if !foundDir {
return false, nil
}
return found, nil
}

slog.Debug("entering directory", slog.String("dir", s), slog.String("cwd", currentDir), slog.String("schema", "ftp"))
if err := res.Value().ChangeDir(s); err != nil {
return false, fmt.Errorf("failed to enter directory: %w", err)
if errors.As(err, &protocolError) {
if protocolError.Code == ftp.StatusFileUnavailable {
return false, nil
}
}

dir, err := l.readDirLock(res, "")
// We won't handle any other kind of error, see above.
return false, fmt.Errorf("failed to list parent path: %w", err)
}

func (l *ftpDisk) Exists(p string) (bool, error) {
res, err := l.acquire()
if err != nil {
return false, fmt.Errorf("failed listing directory: %w", err)
return false, err
}

found := false
for _, entry := range dir {
if entry.Name() == clean(filepath.Base(path)) {
found = true
break
}
}
defer res.Release()

return found, nil
return l.existsWithLock(res, p)
}

func (l *ftpDisk) Read(path string) ([]byte, error) {
Expand Down Expand Up @@ -203,42 +243,55 @@ func (l *ftpDisk) Remove(path string) error {
return nil
}

func (l *ftpDisk) MkDir(path string) error {
func (l *ftpDisk) MkDir(p string) error {
res, err := l.acquire()
if err != nil {
return err
}

defer res.Release()

split := strings.Split(clean(path)[1:], "/")
for _, s := range split {
dir, err := l.readDirLock(res, "")
lastExistingDir := clean(p)
for lastExistingDir != "/" && lastExistingDir != "." {
foundDir, err := l.existsWithLock(res, lastExistingDir)
if err != nil {
return err
}

currentDir, _ := res.Value().CurrentDir()

foundDir := false
for _, entry := range dir {
if entry.IsDir() && entry.Name() == s {
foundDir = true
break
}
if foundDir {
break
}

if !foundDir {
slog.Debug("making directory", slog.String("dir", s), slog.String("cwd", currentDir), slog.String("schema", "ftp"))
if err := res.Value().MakeDir(s); err != nil {
return fmt.Errorf("failed to make directory: %w", err)
}
lastExistingDir = path.Dir(lastExistingDir)
}

remainingDirs := clean(p)

if lastExistingDir != "/" && lastExistingDir != "." {
remainingDirs = strings.TrimPrefix(remainingDirs, lastExistingDir)
}

if len(remainingDirs) == 0 {
// Already exists
return nil
}

if err := res.Value().ChangeDir(lastExistingDir); err != nil {
return fmt.Errorf("failed to enter directory: %w", err)
}

split := strings.Split(clean(remainingDirs)[1:], "/")
for _, s := range split {
slog.Debug("making directory", slog.String("dir", s), slog.String("cwd", lastExistingDir), slog.String("schema", "ftp"))
if err := res.Value().MakeDir(s); err != nil {
return fmt.Errorf("failed to make directory: %w", err)
}

slog.Debug("entering directory", slog.String("dir", s), slog.String("cwd", currentDir), slog.String("schema", "ftp"))
slog.Debug("entering directory", slog.String("dir", s), slog.String("cwd", lastExistingDir), slog.String("schema", "ftp"))
if err := res.Value().ChangeDir(s); err != nil {
return fmt.Errorf("failed to enter directory: %w", err)
}
lastExistingDir = path.Join(lastExistingDir, s)
}

return nil
Expand All @@ -252,7 +305,14 @@ func (l *ftpDisk) ReadDir(path string) ([]Entry, error) {

defer res.Release()

return l.readDirLock(res, path)
entries, err := l.readDirLock(res, path)
if err != nil {
return nil, err
}
entries = slices.DeleteFunc(entries, func(i Entry) bool {
return i.Name() == "." || i.Name() == ".."
})
return entries, nil
}

func (l *ftpDisk) readDirLock(res *puddle.Resource[*ftp.ServerConn], path string) ([]Entry, error) {
Expand Down
Loading
Loading