Resolve clashing project identifiers (#1665)

This commit is contained in:
Dmitry Bushev 2021-04-13 15:19:16 +03:00 committed by GitHub
parent 170514b9d2
commit fde4f2d0d6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 206 additions and 15 deletions

View File

@ -5,3 +5,4 @@ akka.loglevel = "ERROR"
akka.test.timefactor = ${?CI_TEST_TIMEFACTOR}
akka.test.single-expect-default = 5s
searcher.db.numThreads = 1
searcher.db.properties.journal_mode = "memory"

View File

@ -9,6 +9,7 @@ import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
@ -92,4 +93,9 @@ public class TruffleFileSystem implements FileSystem<TruffleFile> {
public boolean isRegularFile(TruffleFile file) {
return file.isRegularFile();
}
@Override
public FileTime getCreationTime(TruffleFile file) throws IOException {
return file.getCreationTime();
}
}

View File

@ -3,6 +3,7 @@ package org.enso.filesystem
import scala.jdk.CollectionConverters._
import java.io.{BufferedReader, BufferedWriter, File, IOException}
import java.nio.file.Files
import java.nio.file.attribute.{BasicFileAttributes, FileTime}
import java.util.stream
/** A generic specification of file operations based on an abstract notion
@ -109,6 +110,13 @@ trait FileSystem[F] {
*/
def isRegularFile(file: F): Boolean
/** Get creation time of the file.
*
* @param file the file to check.
* @return the file creation time.
*/
@throws[IOException]
def getCreationTime(file: F): FileTime
}
object FileSystem {
@ -143,6 +151,8 @@ object FileSystem {
def isDirectory: Boolean = fs.isDirectory(file)
def isRegularFile: Boolean = fs.isRegularFile(file)
def getCreationTime: FileTime = fs.getCreationTime(file)
}
/** A [[File]] based implementation of [[FileSystem]].
@ -181,5 +191,10 @@ object FileSystem {
override def isRegularFile(file: File): Boolean =
Files.isRegularFile(file.toPath)
override def getCreationTime(file: File): FileTime =
Files
.readAttributes(file.toPath, classOf[BasicFileAttributes])
.creationTime()
}
}

View File

@ -2,6 +2,7 @@ package org.enso.projectmanager.infrastructure.repository
import java.io.File
import java.nio.file.Path
import java.nio.file.attribute.FileTime
import java.util.UUID
import org.enso.pkg.{Package, PackageManager}
@ -57,7 +58,7 @@ class ProjectFileRepository[
getAll().map(_.filter(predicate))
/** @inheritdoc */
override def getAll(): F[ProjectRepositoryFailure, List[Project]] =
override def getAll(): F[ProjectRepositoryFailure, List[Project]] = {
fileSystem
.list(storageConfig.userProjectsPath)
.map(_.filter(_.isDirectory))
@ -65,7 +66,11 @@ class ProjectFileRepository[
Nil
}
.mapError(th => StorageFailure(th.toString))
.flatMap(s => Traverse[List].traverse(s)(tryLoadProject).map(_.flatten))
.flatMap { dirs =>
Traverse[List].traverse(dirs)(tryLoadProject).map(_.flatten)
}
.flatMap(resolveClashingIds)
}
/** @inheritdoc */
override def findById(
@ -81,23 +86,31 @@ class ProjectFileRepository[
private def tryLoadProject(
directory: File
): F[ProjectRepositoryFailure, Option[Project]] = {
val noop: F[ProjectRepositoryFailure, Option[ProjectMetadata]] =
def noop[A]: F[ProjectRepositoryFailure, Option[A]] =
Applicative[F].pure(None)
for {
pkgOpt <- loadPackage(directory)
metaOpt <- pkgOpt.fold(noop)(_ => loadMetadata(directory))
pkgOpt <- loadPackage(directory)
metaOpt <- pkgOpt.fold(noop[ProjectMetadata])(_ =>
loadMetadata(directory)
)
directoryCreationTime <- pkgOpt.fold(noop[FileTime])(
getDirectoryCreationTime(_)
.map(Some(_))
.recoverWith(_ => noop)
)
} yield for {
pkg <- pkgOpt
meta <- metaOpt
} yield {
Project(
id = meta.id,
name = pkg.name,
kind = meta.kind,
created = meta.created,
engineVersion = pkg.config.ensoVersion,
lastOpened = meta.lastOpened,
path = Some(directory.toString)
id = meta.id,
name = pkg.name,
kind = meta.kind,
created = meta.created,
engineVersion = pkg.config.ensoVersion,
lastOpened = meta.lastOpened,
path = Some(directory.toString),
directoryCreationTime = directoryCreationTime
)
}
}
@ -151,6 +164,13 @@ class ProjectFileRepository[
.blockingOp { PackageManager.Default.fromDirectory(projectPath) }
.mapError(th => StorageFailure(th.toString))
private def getDirectoryCreationTime(
pkg: Package[File]
): F[ProjectRepositoryFailure, FileTime] =
Sync[F]
.blockingOp(pkg.fileSystem.getCreationTime(pkg.root))
.mapError(th => StorageFailure(th.toString))
private def getPackage(
projectPath: File
): F[ProjectRepositoryFailure, Package[File]] =
@ -291,4 +311,46 @@ class ProjectFileRepository[
fileSystem,
gen
)
/** Resolve id clashes and return a list of projects with unique identifiers
* by assigning random ids to clashing projects.
*
* @param projects the list of projects
* @return return the list of projects with unique ids
*/
private def resolveClashingIds(
projects: List[Project]
): F[ProjectRepositoryFailure, List[Project]] = {
val clashing = markProjectsWithClashingIds(projects)
Traverse[List].traverse(clashing) { case (isClashing, project) =>
if (isClashing) {
for {
newId <- gen.randomUUID()
updatedProject = project.copy(id = newId)
_ <- update(updatedProject)
} yield updatedProject
} else {
Applicative[F].pure(project)
}
}
}
/** Take a list of projects and mark the projects that have duplicate ids.
*
* @param projects the list of projects
* @return the list of pairs. Fist element of the pair indicates if the
* project has clashing id.
*/
private def markProjectsWithClashingIds(
projects: List[Project]
): List[(Boolean, Project)] = {
projects.groupBy(_.id).foldRight(List.empty[(Boolean, Project)]) {
case ((_, groupedProjects), acc) =>
// groupBy always returns non-empty list
(groupedProjects.sortBy(_.directoryCreationTime): @unchecked) match {
case project :: clashingProjects =>
(false, project) :: clashingProjects.map((true, _)) ::: acc
}
}
}
}

View File

@ -1,5 +1,6 @@
package org.enso.projectmanager.model
import java.nio.file.attribute.FileTime
import java.time.OffsetDateTime
import java.util.UUID
@ -20,7 +21,8 @@ case class Project(
name: String,
kind: ProjectKind,
created: OffsetDateTime,
engineVersion: EnsoVersion = DefaultEnsoVersion,
lastOpened: Option[OffsetDateTime] = None,
path: Option[String] = None
engineVersion: EnsoVersion = DefaultEnsoVersion,
lastOpened: Option[OffsetDateTime] = None,
path: Option[String] = None,
directoryCreationTime: Option[FileTime] = None
)

View File

@ -59,3 +59,5 @@ akka.loglevel = "ERROR"
akka.test.timefactor = ${?CI_TEST_TIMEFACTOR}
akka.test.single-expect-default = 5s
searcher.db.numThreads = 1
searcher.db.properties.journal_mode = "memory"

View File

@ -445,6 +445,59 @@ class ProjectManagementApiSpec
deleteProject(projectId)
}
"deduplicate project ids" taggedAs Flaky in {
val projectName1 = "Foo"
implicit val client = new WsTestClient(address)
// given
val projectId1 = createProject(projectName1)
val projectDir1 = new File(userProjectDir, projectName1)
val projectDir2 = new File(userProjectDir, "Test")
FileUtils.copyDirectory(projectDir1, projectDir2)
// when
testClock.moveTimeForward()
openProject(projectId1)
val projectOpenTime = testClock.currentTime
//then
client.send(json"""
{ "jsonrpc": "2.0",
"method": "project/list",
"id": 0,
"params": { }
}
""")
val projectId2 = getGeneratedUUID
client.expectJson(json"""
{
"jsonrpc":"2.0",
"id":0,
"result": {
"projects": [
{
"name": "Foo",
"id": $projectId1,
"engineVersion": $engineToInstall,
"lastOpened": $projectOpenTime
},
{
"name": "Foo",
"id": $projectId2,
"engineVersion": $engineToInstall,
"lastOpened": null
}
]
}
}
""")
// teardown
closeProject(projectId1)
deleteProject(projectId1)
deleteProject(projectId2)
}
}
"project/close" must {
@ -615,6 +668,56 @@ class ProjectManagementApiSpec
deleteProject(bazId)
}
"resolve clashing ids" taggedAs Flaky in {
val projectName1 = "Foo"
implicit val client = new WsTestClient(address)
// given
val projectId1 = createProject(projectName1)
val projectDir1 = new File(userProjectDir, projectName1)
val projectDir2 = new File(userProjectDir, "Test")
FileUtils.copyDirectory(projectDir1, projectDir2)
// when
client.send(json"""
{ "jsonrpc": "2.0",
"method": "project/list",
"id": 0,
"params": { }
}
""")
//then
val projectId2 = getGeneratedUUID
client.expectJson(json"""
{
"jsonrpc":"2.0",
"id":0,
"result": {
"projects": [
{
"name": "Foo",
"id": $projectId1,
"engineVersion": $engineToInstall,
"lastOpened": null
},
{
"name": "Foo",
"id": $projectId2,
"engineVersion": $engineToInstall,
"lastOpened": null
}
]
}
}
""")
// teardown
deleteProject(projectId1)
deleteProject(projectId2)
}
}
"project/rename" must {