Skip to content

Add move operation to FileDescriptor and refine base typing - #1128

Merged
afonsoafonsoafonso merged 5 commits into
masterfrom
sftp-rename
Jul 22, 2026
Merged

Add move operation to FileDescriptor and refine base typing#1128
afonsoafonsoafonso merged 5 commits into
masterfrom
sftp-rename

Conversation

@bdmendes

@bdmendes bdmendes commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Adds a new move operation to FileDescriptor that behaves similarly to UNIX's mv utility.

  • Moving directories will unsupported file systems (e.g. GCS or S3) will throw, as upload already does.
  • Failing the operation, due to some I/O error, returns None.
  • A successful move returns the new file descriptor.

Most notably, I am interested in using the move method for sftp.

Opportunistically, Self was moved up, requiring implementations to return their own type (which they already did), and using it for the mvLocation method up in the hierarchy.

Does this change relate to existing issues or pull requests?

No.

Does this change require an update to the documentation?

How has this been tested?

Tested the local version via new unit tests.
Tested the sftp version against a local sftp server.

@bdmendes bdmendes self-assigned this Jul 20, 2026
@bdmendes
bdmendes marked this pull request as ready for review July 20, 2026 16:59
Files.move(normalizedPath, destination.normalizedPath, StandardCopyOption.ATOMIC_MOVE)
Some(destination)
} catch {
case NonFatal(_) => None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we log a warning in these cases? Just for visibility.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Please see 6a282f2.

def move(pathString: String): Option[SftpFileDescriptor] = {
val destination = mvLocation(pathString)
if (destination.path == path) Some(this)
else if (destination.parent().mkdirs() && Try(sftp(_.rename(path, destination.path))).isSuccess) Some(destination)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we delete the created directory if the rename fails?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could, but it is difficult. We don't really know how many directories mkDirs created along the way. We could use some is empty + just created heuristic; please let me know if you have another suggestion.

if (copied && delete()) Some(destination.copy(summary = None)) else None
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we take advantage of native server-side copy (we do mention "may be more efficient than a manual upload followed by a delete")? Or is it not possible?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In GCS and S3, there is no move operation, so I don't think it is possible to make those more efficient than just a copy+delete. Locally and in sftp the implementation is specialized.

Try(sftp(_.mkdirs(path))).isSuccess

def move(pathString: String): Option[SftpFileDescriptor] = {
val destination = mvLocation(pathString)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should mvLocation here recover and return None upon a transient error?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mvLocation should in principle not fail. It can, however, as it may fetch the remote to understand if the requested destination is a directory. We could do:

protected final def mvLocation(pathString: String): Option[Self] = {
  val requestedDestination = parent().cd(pathString).asInstanceOf[Self]
  for {
    isDirectory <- Try(requestedDestination.exists && requestedDestination.isDirectory).toOption
  } yield
    if (isDirectory) requestedDestination.child(name).asInstanceOf[Self]
    else requestedDestination
}

And act on it. But then, we should probably review whether exists and isDirectory should also return Option as they are the failure points. Given the current status quo – where exceptions may occur – I'd be inclined to keep this as is. Let me know what you think.

* @return
* the new file descriptor with the updated path if the move was successful, None otherwise.
*/
def move(pathString: String): Option[Self]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are the S3 and GCS's concrete implementations consistent with the Local/SFTP ones and between these ScalaDocs and the PR's description? I think the former currently only return None when upload/delete fails, with the underlying layer (S3Bucket.stream/GCSBucket.push) throwing upon IO errors.

Should we wrap these in some NonFatal/Try handling?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can resort to Using.apply instead of Using.resource for that. But again, I'd prefer, if we care to make these functions actually more transparent, to start from the leaves and make upload a fallible operation.

* @return
* the new file descriptor with the updated path if the move was successful, None otherwise.
*/
def move(pathString: String): Option[Self]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additionally, would it make sense to extract the common logic between the move implementations to the base trait? We could have the concrete implementations implement a kind of moveTo with the backend-specific call and the rest could be extracted and kept in the trait, I believe.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried to do that with mvLocation. Unfortunately the rest of the body is kind of specific, some do move, some do copy+delete, some create intermediate directories... The only clear "single point" candidates are S3 and GCS, but there is no common trait for them at the moment, and it's perhaps an over-abstraction to introduce one.

@afonsoafonsoafonso afonsoafonsoafonso left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lgtm! It's noticeable that this is a bit legacy-ish, I agree with what we discussed offline about not going the extra mile to improve it since we've been going more towards other solutions.

@afonsoafonsoafonso
afonsoafonsoafonso merged commit b21494c into master Jul 22, 2026
10 checks passed
@afonsoafonsoafonso
afonsoafonsoafonso deleted the sftp-rename branch July 22, 2026 13:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants