From 2552b5cb5f64163f7dbe183404f3951041c3ec72 Mon Sep 17 00:00:00 2001 From: frcroth Date: Mon, 10 Mar 2025 17:05:46 +0100 Subject: [PATCH 01/17] Handle simplest case, one layer with symlink to deleted layer --- app/controllers/DatasetController.scala | 6 - .../WKRemoteDataStoreController.scala | 27 +++- app/models/dataset/Dataset.scala | 68 +++++--- app/models/dataset/DatasetService.scala | 14 +- conf/webknossos.latest.routes | 1 + .../datastore/helpers/DatasetDeleter.scala | 153 ++++++++++++++++-- .../datastore/helpers/MagLinkInfo.scala | 31 ++++ .../services/BinaryDataService.scala | 3 +- .../services/BinaryDataServiceHolder.scala | 6 +- .../services/DSRemoteWebknossosClient.scala | 8 +- .../services/uploading/UploadService.scala | 11 +- .../EditableMappingService.scala | 2 +- .../volume/VolumeTracingService.scala | 2 +- 13 files changed, 271 insertions(+), 61 deletions(-) create mode 100644 webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/MagLinkInfo.scala diff --git a/app/controllers/DatasetController.scala b/app/controllers/DatasetController.scala index 5b93a8e98a7..f0fb033b764 100755 --- a/app/controllers/DatasetController.scala +++ b/app/controllers/DatasetController.scala @@ -71,12 +71,6 @@ object SegmentAnythingMaskParameters { implicit val jsonFormat: Format[SegmentAnythingMaskParameters] = Json.format[SegmentAnythingMaskParameters] } -case class MagLinkInfo(mag: DatasetMagInfo, linkedMags: Seq[DatasetMagInfo]) - -object MagLinkInfo { - implicit val jsonFormat: Format[MagLinkInfo] = Json.format[MagLinkInfo] -} - class DatasetController @Inject()(userService: UserService, userDAO: UserDAO, datasetService: DatasetService, diff --git a/app/controllers/WKRemoteDataStoreController.scala b/app/controllers/WKRemoteDataStoreController.scala index 7634d7d1669..e68ba683178 100644 --- a/app/controllers/WKRemoteDataStoreController.scala +++ b/app/controllers/WKRemoteDataStoreController.scala @@ -5,15 +5,12 @@ import com.scalableminds.util.objectid.ObjectId import com.scalableminds.util.time.Instant import com.scalableminds.util.tools.Fox import com.scalableminds.webknossos.datastore.controllers.JobExportProperties +import com.scalableminds.webknossos.datastore.helpers.{LayerMagLinkInfo, MagLinkInfo} import com.scalableminds.webknossos.datastore.models.UnfinishedUpload import com.scalableminds.webknossos.datastore.models.datasource.DataSourceId import com.scalableminds.webknossos.datastore.models.datasource.inbox.{InboxDataSourceLike => InboxDataSource} import com.scalableminds.webknossos.datastore.services.{DataSourcePathInfo, DataStoreStatus} -import com.scalableminds.webknossos.datastore.services.uploading.{ - LinkedLayerIdentifier, - ReserveAdditionalInformation, - ReserveUploadInformation -} +import com.scalableminds.webknossos.datastore.services.uploading.{LinkedLayerIdentifier, ReserveAdditionalInformation, ReserveUploadInformation} import com.typesafe.scalalogging.LazyLogging import mail.{MailchimpClient, MailchimpTag} import models.analytics.{AnalyticsService, UploadDatasetEvent} @@ -33,8 +30,8 @@ import play.api.mvc.{Action, AnyContent, PlayBodyParsers} import security.{WebknossosBearerTokenAuthenticatorService, WkSilhouetteEnvironment} import telemetry.SlackNotificationService import utils.WkConf -import scala.concurrent.duration.DurationInt +import scala.concurrent.duration.DurationInt import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} @@ -47,6 +44,7 @@ class WKRemoteDataStoreController @Inject()( organizationDAO: OrganizationDAO, usedStorageService: UsedStorageService, datasetDAO: DatasetDAO, + datasetLayerDAO: DatasetLayerDAO, userDAO: UserDAO, folderDAO: FolderDAO, teamDAO: TeamDAO, @@ -235,6 +233,23 @@ class WKRemoteDataStoreController @Inject()( } } + def getPaths(name: String, key: String, organizationId: String, directoryName: String): Action[AnyContent] = + Action.async { implicit request => + dataStoreService.validateAccess(name, key) { _ => + for { + organization <- organizationDAO.findOne(organizationId)(GlobalAccessContext) + dataset <- datasetDAO.findOneByDirectoryNameAndOrganization(directoryName, organization._id)( + GlobalAccessContext) + layers <- datasetLayerDAO.findAllForDataset(dataset._id) + magsAndLinkedMags <- Fox.serialCombined(layers)(l => datasetService.getPathsForDataLayer(dataset._id, l.name)) + magLinkInfos = magsAndLinkedMags.map(_.map { case (mag, linkedMags) => MagLinkInfo(mag, linkedMags) }) + layersAndMagLinkInfos = layers.zip(magLinkInfos).map { + case (layer, magLinkInfo) => LayerMagLinkInfo(layer.name, magLinkInfo) + } + } yield Ok(Json.toJson(layersAndMagLinkInfos)) + } + } + def deleteDataset(name: String, key: String): Action[JsValue] = Action.async(parse.json) { implicit request => dataStoreService.validateAccess(name, key) { _ => for { diff --git a/app/models/dataset/Dataset.scala b/app/models/dataset/Dataset.scala index 75a0634b3f1..80fc09e8134 100755 --- a/app/models/dataset/Dataset.scala +++ b/app/models/dataset/Dataset.scala @@ -5,6 +5,7 @@ import com.scalableminds.util.geometry.{BoundingBox, Vec3Double, Vec3Int} import com.scalableminds.util.objectid.ObjectId import com.scalableminds.util.time.Instant import com.scalableminds.util.tools.{Fox, FoxImplicits, JsonHelper} +import com.scalableminds.webknossos.datastore.helpers.DatasourceMagInfo import com.scalableminds.webknossos.datastore.models.{LengthUnit, VoxelSize} import com.scalableminds.webknossos.datastore.models.datasource.DatasetViewConfiguration.DatasetViewConfiguration import com.scalableminds.webknossos.datastore.models.datasource.LayerViewConfiguration.LayerViewConfiguration @@ -31,6 +32,7 @@ import play.api.i18n.{Messages, MessagesProvider} import play.api.libs.json._ import play.utils.UriEncoding import slick.dbio.DBIO +import slick.jdbc.GetResult import slick.jdbc.PostgresProfile.api._ import slick.jdbc.TransactionIsolation.Serializable import slick.lifted.Rep @@ -709,6 +711,12 @@ class DatasetDAO @Inject()(sqlClient: SqlClient, datasetLayerDAO: DatasetLayerDA } } +case class MagWithPaths(layerName: String, + mag: Vec3Int, + path: Option[String], + realPath: Option[String], + hasLocalData: Boolean) + case class DatasetMagInfo(datasetId: ObjectId, dataLayerName: String, mag: Vec3Int, @@ -716,15 +724,15 @@ case class DatasetMagInfo(datasetId: ObjectId, realPath: Option[String], hasLocalData: Boolean) -case class MagWithPaths(layerName: String, - mag: Vec3Int, - path: Option[String], - realPath: Option[String], - hasLocalData: Boolean) +case class DataSourceMagRow(_dataset: String, + dataLayerName: String, + mag: String, + path: Option[String], + realPath: Option[String], + hasLocalData: Boolean, + _organization: String, + directoryName: String) -object DatasetMagInfo { - implicit val jsonFormat: Format[DatasetMagInfo] = Json.format[DatasetMagInfo] -} class DatasetMagsDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext) extends SQLDAO[MagWithPaths, DatasetMagsRow, DatasetMags](sqlClient) { protected val collection = DatasetMags @@ -782,26 +790,44 @@ class DatasetMagsDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionConte ) } yield () - def findPathsForDatasetAndDatalayer(datasetId: ObjectId, dataLayerName: String): Fox[List[DatasetMagInfo]] = + implicit def GetResultDataSourceMagRow: GetResult[DataSourceMagRow] = + GetResult( + r => + DataSourceMagRow(r.nextString(), + r.nextString(), + r.nextString(), + r.nextStringOption(), + r.nextStringOption(), + r.nextBoolean(), + r.nextString(), + r.nextString())) + + def findPathsForDatasetAndDatalayer(datasetId: ObjectId, dataLayerName: String): Fox[List[DatasourceMagInfo]] = for { - rows <- run(q"""SELECT $columns + rows <- run(q"""SELECT $columns, _organization, directoryName FROM webknossos.dataset_mags + INNER JOIN webknossos.datasets ON webknossos.dataset_mags._dataset = webknossos.datasets._id WHERE _dataset = $datasetId - AND dataLayerName = $dataLayerName""".as[DatasetMagsRow]) - magInfos <- Fox.combined(rows.toList.map(parse)) - datasetMagInfos = magInfos.map(magInfo => - DatasetMagInfo(datasetId, magInfo.layerName, magInfo.mag, magInfo.path, magInfo.realPath, magInfo.hasLocalData)) - } yield datasetMagInfos + AND dataLayerName = $dataLayerName""".as[DataSourceMagRow]) //TODO: Remove duplication + mags <- Fox.serialCombined(rows.toList)(r => parseMag(r.mag)) + dataSources = rows.map(row => DataSourceId(row._organization, row.directoryName)) + magInfos = rows.toList.zip(mags).zip(dataSources).map { + case ((row, mag), dataSource) => + DatasourceMagInfo(dataSource, row.dataLayerName, mag, row.path, row.realPath, row.hasLocalData) + } + } yield magInfos - def findAllByRealPath(realPath: String): Fox[List[DatasetMagInfo]] = + def findAllByRealPath(realPath: String): Fox[List[DatasourceMagInfo]] = for { - rows <- run(q"""SELECT $columns + rows <- run(q"""SELECT $columns, _organization, directoryName FROM webknossos.dataset_mags - WHERE realPath = $realPath""".as[DatasetMagsRow]) + INNER JOIN webknossos.datasets ON webknossos.dataset_mags._dataset = webknossos.datasets._id + WHERE realPath = $realPath""".as[DataSourceMagRow]) mags <- Fox.serialCombined(rows.toList)(r => parseMag(r.mag)) - magInfos = rows.toList.zip(mags).map { - case (row, mag) => - DatasetMagInfo(ObjectId(row._Dataset), row.datalayername, mag, row.path, row.realpath, row.haslocaldata) + dataSources = rows.map(row => DataSourceId(row.directoryName, row._organization)) + magInfos = rows.toList.zip(mags).zip(dataSources).map { + case ((row, mag), dataSource) => + DatasourceMagInfo(dataSource, row.dataLayerName, mag, row.path, row.realPath, row.hasLocalData) } } yield magInfos diff --git a/app/models/dataset/DatasetService.scala b/app/models/dataset/DatasetService.scala index 9d63955379d..73ebdb52f67 100644 --- a/app/models/dataset/DatasetService.scala +++ b/app/models/dataset/DatasetService.scala @@ -4,15 +4,9 @@ import com.scalableminds.util.accesscontext.{DBAccessContext, GlobalAccessContex import com.scalableminds.util.objectid.ObjectId import com.scalableminds.util.time.Instant import com.scalableminds.util.tools.{Fox, FoxImplicits} -import com.scalableminds.webknossos.datastore.models.datasource.inbox.{ - UnusableDataSource, - InboxDataSourceLike => InboxDataSource -} -import com.scalableminds.webknossos.datastore.models.datasource.{ - DataSourceId, - GenericDataSource, - DataLayerLike => DataLayer -} +import com.scalableminds.webknossos.datastore.helpers.DatasourceMagInfo +import com.scalableminds.webknossos.datastore.models.datasource.inbox.{UnusableDataSource, InboxDataSourceLike => InboxDataSource} +import com.scalableminds.webknossos.datastore.models.datasource.{DataSourceId, GenericDataSource, DataLayerLike => DataLayer} import com.scalableminds.webknossos.datastore.rpc.RPC import com.scalableminds.webknossos.datastore.services.DataSourcePathInfo import com.typesafe.scalalogging.LazyLogging @@ -356,7 +350,7 @@ class DatasetService @Inject()(organizationDAO: OrganizationDAO, _ <- Fox.serialCombined(pathInfos)(updateRealPath) } yield () - def getPathsForDataLayer(datasetId: ObjectId, layerName: String): Fox[List[(DatasetMagInfo, List[DatasetMagInfo])]] = + def getPathsForDataLayer(datasetId: ObjectId, layerName: String): Fox[List[(DatasourceMagInfo, List[DatasourceMagInfo])]] = for { magInfos <- datasetMagsDAO.findPathsForDatasetAndDatalayer(datasetId, layerName) magInfosAndLinkedMags <- Fox.serialCombined(magInfos)(magInfo => diff --git a/conf/webknossos.latest.routes b/conf/webknossos.latest.routes index a3f6d3f1a17..9c4ace584de 100644 --- a/conf/webknossos.latest.routes +++ b/conf/webknossos.latest.routes @@ -109,6 +109,7 @@ GET /datastores PUT /datastores/:name/datasource controllers.WKRemoteDataStoreController.updateOne(name: String, key: String) PUT /datastores/:name/datasources controllers.WKRemoteDataStoreController.updateAll(name: String, key: String) PUT /datastores/:name/datasources/paths controllers.WKRemoteDataStoreController.updatePaths(name: String, key: String) +GET /datastores/:name/datasources/:organizationId/:directoryName/paths controllers.WKRemoteDataStoreController.getPaths(name: String, key: String, organizationId: String, directoryName: String) PATCH /datastores/:name/status controllers.WKRemoteDataStoreController.statusUpdate(name: String, key: String) POST /datastores/:name/reserveUpload controllers.WKRemoteDataStoreController.reserveDatasetUpload(name: String, key: String, token: String) GET /datastores/:name/getUnfinishedUploadsForUser controllers.WKRemoteDataStoreController.getUnfinishedUploadsForUser(name: String, key: String, token: String, organizationName: String) diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala index 7df8f247ed0..35ddec35290 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala @@ -1,5 +1,8 @@ package com.scalableminds.webknossos.datastore.helpers +import com.scalableminds.util.objectid.ObjectId import com.scalableminds.util.tools.Fox +import com.scalableminds.webknossos.datastore.models.datasource.DataSourceId +import com.scalableminds.webknossos.datastore.services.{DSRemoteWebknossosClient, RemoteWebknossosClient} import com.typesafe.scalalogging.LazyLogging import net.liftweb.common.Full @@ -31,23 +34,153 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { case e: Exception => Fox.failure(s"Deleting dataset failed: ${e.toString}", Full(e)) } + def moveToTrash(organizationId: String, + datasetName: String, + dataSourcePath: Path, + reason: Option[String] = None): Fox[Unit] = + if (Files.exists(dataSourcePath)) { + val trashPath: Path = dataBaseDir.resolve(organizationId).resolve(trashDir) + val targetPath = trashPath.resolve(datasetName) + new File(trashPath.toString).mkdirs() + + logger.info(s"Deleting dataset by moving it from $dataSourcePath to $targetPath${if (reason.isDefined) + s" because ${reason.getOrElse("")}" + else "..."}") + deleteWithRetry(dataSourcePath, targetPath) + } else { + Fox.successful(logger.info( + s"Dataset deletion requested for dataset at $dataSourcePath, but it does not exist. Skipping deletion on disk.")) + } + val dataSourcePath = if (isInConversion) dataBaseDir.resolve(organizationId).resolve(forConversionDir).resolve(datasetName) else dataBaseDir.resolve(organizationId).resolve(datasetName) - if (Files.exists(dataSourcePath)) { - val trashPath: Path = dataBaseDir.resolve(organizationId).resolve(trashDir) - val targetPath = trashPath.resolve(datasetName) - new File(trashPath.toString).mkdirs() + for { + _ <- moveSymlinks(organizationId, datasetName) + _ <- moveToTrash(organizationId, datasetName, dataSourcePath, reason) + } yield () + } - logger.info( - s"Deleting dataset by moving it from $dataSourcePath to $targetPath${if (reason.isDefined) s" because ${reason.getOrElse("")}" - else "..."}") - deleteWithRetry(dataSourcePath, targetPath) + def remoteWKClient: Option[DSRemoteWebknossosClient] + + private def moveSymlinks(organizationId: String, datasetName: String)(implicit ec: ExecutionContext) = + for { + dataSourceId <- Fox.successful(DataSourceId(datasetName, organizationId)) + layersAndLinkedMags <- Fox.runOptional(remoteWKClient)(_.fetchPaths(dataSourceId)) + _ = layersAndLinkedMags match { + case Some(value) => + (value.map(lmli => handleLayerSymlinks(dataSourceId, lmli.layerName, lmli.magLinkInfos.toList))) + case None => None + } + } yield () + + private def layerMayBeMoved(dataSourceId: DataSourceId, + layerName: String, + linkedMags: List[MagLinkInfo]): Option[(DataSourceId, String)] = { + val allMagsLocal = linkedMags.forall(_.mag.hasLocalData) + val allLinkedDatasetLayers = linkedMags.map(_.linkedMags.map(lm => (lm.dataSourceId, lm.dataLayerName))) + // Get combinations of datasetId, layerName that link to EVERY mag + val linkedToByAllMags = allLinkedDatasetLayers.reduce((a, b) => a.intersect(b)) + if (allMagsLocal) { + linkedToByAllMags.headOption } else { - Fox.successful(logger.info( - s"Dataset deletion requested for dataset at $dataSourcePath, but it does not exist. Skipping deletion on disk.")) + None } + } + private def moveLayer(sourceDataSource: DataSourceId, + sourceLayer: String, + moveToDataSource: DataSourceId, + moveToDataLayer: String, + layerMags: List[MagLinkInfo]) = { + // Move layer physically + val layerPath = + dataBaseDir.resolve(sourceDataSource.organizationId).resolve(sourceDataSource.directoryName).resolve(sourceLayer) + val targetPath = dataBaseDir + .resolve(moveToDataSource.organizationId) + .resolve(moveToDataSource.directoryName) + .resolve(moveToDataLayer) + if (Files.exists(targetPath) && Files.isSymbolicLink(targetPath)) { + Files.delete(targetPath) + } + Files.move(layerPath, targetPath) + + // All symlinks are now broken, we need to recreate them + // For every mag that links to this layer, create a symlink to the new location + // TODO: Note that this may create more symlinks than before? Handle self-streaming. + // TODO: If there are more symlinked layers, they should also be handled as layer symlinks, not mags! + layerMags.foreach { magLinkInfo => + val mag = magLinkInfo.mag + val newMagPath = targetPath.resolve(mag.mag.toString) // TODO: Does this work? + magLinkInfo.linkedMags.foreach { linkedMag => + val linkedMagPath = dataBaseDir + .resolve(linkedMag.dataSourceId.organizationId) + .resolve(linkedMag.dataSourceId.directoryName) + .resolve(linkedMag.dataLayerName) + .resolve(linkedMag.mag.toString) + // Remove old symlink + if (Files.exists(linkedMagPath) && Files.isSymbolicLink(linkedMagPath)) { + Files.delete(linkedMagPath) + Files.createSymbolicLink(linkedMagPath, newMagPath) + } else { + // Hmmm.. + // One reason this would happen is for the newly moved layer (since it is not a symlink anymore) + } + } + } + } + + private def handleLayerSymlinks(dataSourceId: DataSourceId, layerName: String, linkedMags: List[MagLinkInfo]) = { + // TODO exception handling + val moveLayerTo = layerMayBeMoved(dataSourceId, layerName, linkedMags) + moveLayerTo match { + case Some((moveToDataset, moveToDataLayer)) => + logger.info( + s"Found complete symlinks to layer; Moving layer $layerName from $dataSourceId to $moveToDataset/$moveToDataLayer") + moveLayer(dataSourceId, layerName, moveToDataset, moveToDataLayer, linkedMags) + // Move all linked mags to dataset + // Move all symlinks to this dataset to link to the moved dataset + case None => + logger.info(s"Found incomplete symlinks to layer; Moving mags from $dataSourceId to other datasets") + linkedMags.foreach { magLinkInfo => + val magToDelete = magLinkInfo.mag + if (magLinkInfo.linkedMags.nonEmpty) { + if (magToDelete.hasLocalData) { + // Move mag to a different dataset + val magPath = dataBaseDir + .resolve(dataSourceId.organizationId) + .resolve(dataSourceId.directoryName) + .resolve(layerName) + .resolve(magToDelete.mag.toString) + val target = magLinkInfo.linkedMags.head + val targetPath = dataBaseDir + .resolve(target.dataSourceId.organizationId) + .resolve(target.dataSourceId.directoryName) + .resolve(target.dataLayerName) + .resolve(target.mag.toString) + Files.move(magPath, targetPath) + + // Move all symlinks to this mag to link to the moved mag + magLinkInfo.linkedMags.tail.foreach { linkedMag => + val linkedMagPath = dataBaseDir + .resolve(linkedMag.dataSourceId.organizationId) + .resolve(linkedMag.dataSourceId.directoryName) + .resolve(linkedMag.dataLayerName) + .resolve(linkedMag.mag.toString) + if (Files.exists(linkedMagPath) && Files.isSymbolicLink(linkedMagPath)) { // TODO: we probably need to update datasource.json files + Files.delete(linkedMagPath) + Files.createSymbolicLink(linkedMagPath, targetPath) + } else { + // Hmmm.. + } + } + } else { + // TODO In this case we need to find out what the this mag actually links to + } + + } + } + } } } diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/MagLinkInfo.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/MagLinkInfo.scala new file mode 100644 index 00000000000..3fcd03593af --- /dev/null +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/MagLinkInfo.scala @@ -0,0 +1,31 @@ +package com.scalableminds.webknossos.datastore.helpers + +import com.scalableminds.util.geometry.Vec3Int +import com.scalableminds.util.objectid.ObjectId +import com.scalableminds.webknossos.datastore.models.datasource.DataSourceId +import play.api.libs.json.{Format, Json} + + + +case class DatasourceMagInfo(dataSourceId: DataSourceId, + dataLayerName: String, + mag: Vec3Int, + path: Option[String], + realPath: Option[String], + hasLocalData: Boolean) + +object DatasourceMagInfo { + implicit val jsonFormat: Format[DatasourceMagInfo] = Json.format[DatasourceMagInfo] +} + +case class MagLinkInfo(mag: DatasourceMagInfo, linkedMags: Seq[DatasourceMagInfo]) + +object MagLinkInfo { + implicit val jsonFormat: Format[MagLinkInfo] = Json.format[MagLinkInfo] +} + +case class LayerMagLinkInfo(layerName: String, magLinkInfos: Seq[MagLinkInfo]) + +object LayerMagLinkInfo { + implicit val jsonFormat: Format[LayerMagLinkInfo] = Json.format[LayerMagLinkInfo] +} diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/BinaryDataService.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/BinaryDataService.scala index 93aaf088716..7a0d3347c13 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/BinaryDataService.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/BinaryDataService.scala @@ -22,7 +22,8 @@ class BinaryDataService(val dataBaseDir: Path, val agglomerateServiceOpt: Option[AgglomerateService], remoteSourceDescriptorServiceOpt: Option[RemoteSourceDescriptorService], sharedChunkContentsCache: Option[AlfuCache[String, MultiArray]], - datasetErrorLoggingService: Option[DatasetErrorLoggingService])(implicit ec: ExecutionContext) + datasetErrorLoggingService: Option[DatasetErrorLoggingService], + val remoteWKClient: Option[DSRemoteWebknossosClient])(implicit ec: ExecutionContext) extends FoxImplicits with DatasetDeleter with LazyLogging { diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/BinaryDataServiceHolder.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/BinaryDataServiceHolder.scala index 09c34a3bc08..0c5c9dab5e1 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/BinaryDataServiceHolder.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/BinaryDataServiceHolder.scala @@ -23,7 +23,8 @@ class BinaryDataServiceHolder @Inject()( config: DataStoreConfig, agglomerateService: AgglomerateService, remoteSourceDescriptorService: RemoteSourceDescriptorService, - datasetErrorLoggingService: DatasetErrorLoggingService)(implicit ec: ExecutionContext) + datasetErrorLoggingService: DatasetErrorLoggingService, + remoteWebknossosClient: DSRemoteWebknossosClient)(implicit ec: ExecutionContext) extends LazyLogging { private lazy val sharedChunkContentsCache: AlfuCache[String, MultiArray] = { @@ -46,7 +47,8 @@ class BinaryDataServiceHolder @Inject()( Some(agglomerateService), Some(remoteSourceDescriptorService), Some(sharedChunkContentsCache), - Some(datasetErrorLoggingService) + Some(datasetErrorLoggingService), + Some(remoteWebknossosClient) ) } diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/DSRemoteWebknossosClient.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/DSRemoteWebknossosClient.scala index 88d3bb7467a..b1d1a80322d 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/DSRemoteWebknossosClient.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/DSRemoteWebknossosClient.scala @@ -9,7 +9,7 @@ import com.scalableminds.util.geometry.Vec3Int import com.scalableminds.util.tools.{Fox, FoxImplicits} import com.scalableminds.webknossos.datastore.DataStoreConfig import com.scalableminds.webknossos.datastore.controllers.JobExportProperties -import com.scalableminds.webknossos.datastore.helpers.IntervalScheduler +import com.scalableminds.webknossos.datastore.helpers.{IntervalScheduler, LayerMagLinkInfo} import com.scalableminds.webknossos.datastore.models.UnfinishedUpload import com.scalableminds.webknossos.datastore.models.annotation.AnnotationSource import com.scalableminds.webknossos.datastore.models.datasource.DataSourceId @@ -120,6 +120,12 @@ class DSRemoteWebknossosClient @Inject()( .silent .putJson(dataSourcePaths) + def fetchPaths(dataSourceId: DataSourceId): Fox[List[LayerMagLinkInfo]] = + rpc( + s"$webknossosUri/api/datastores/$dataStoreName/datasources/${dataSourceId.organizationId}/${dataSourceId.directoryName}/paths") + .addQueryString("key" -> dataStoreKey) + .getWithJsonResponse[List[LayerMagLinkInfo]] + def reserveDataSourceUpload(info: ReserveUploadInformation)( implicit tc: TokenContext): Fox[ReserveAdditionalInformation] = for { diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/uploading/UploadService.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/uploading/UploadService.scala index 98898719f33..03911c21287 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/uploading/UploadService.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/uploading/UploadService.scala @@ -17,7 +17,11 @@ import com.scalableminds.webknossos.datastore.helpers.{DatasetDeleter, Directory import com.scalableminds.webknossos.datastore.models.UnfinishedUpload import com.scalableminds.webknossos.datastore.models.datasource.GenericDataSource.FILENAME_DATASOURCE_PROPERTIES_JSON import com.scalableminds.webknossos.datastore.models.datasource._ -import com.scalableminds.webknossos.datastore.services.{DataSourceRepository, DataSourceService} +import com.scalableminds.webknossos.datastore.services.{ + DSRemoteWebknossosClient, + DataSourceRepository, + DataSourceService +} import com.scalableminds.webknossos.datastore.storage.DataStoreRedisStore import com.typesafe.scalalogging.LazyLogging import net.liftweb.common.Box.tryo @@ -106,7 +110,8 @@ class UploadService @Inject()(dataSourceRepository: DataSourceRepository, dataSourceService: DataSourceService, runningUploadMetadataStore: DataStoreRedisStore, exploreLocalLayerService: ExploreLocalLayerService, - datasetSymlinkService: DatasetSymlinkService)(implicit ec: ExecutionContext) + datasetSymlinkService: DatasetSymlinkService, + val remoteWebknossosClient: DSRemoteWebknossosClient)(implicit ec: ExecutionContext) extends DatasetDeleter with DirectoryConstants with FoxImplicits @@ -144,6 +149,8 @@ class UploadService @Inject()(dataSourceRepository: DataSourceRepository, override def dataBaseDir: Path = dataSourceService.dataBaseDir + override def remoteWKClient: Option[DSRemoteWebknossosClient] = Some(remoteWebknossosClient) + def isKnownUploadByFileId(uploadFileId: String): Fox[Boolean] = isKnownUpload(extractDatasetUploadId(uploadFileId)) def isKnownUpload(uploadId: String): Fox[Boolean] = diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/editablemapping/EditableMappingService.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/editablemapping/EditableMappingService.scala index c4b0801474a..128ab129ceb 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/editablemapping/EditableMappingService.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/editablemapping/EditableMappingService.scala @@ -103,7 +103,7 @@ class EditableMappingService @Inject()( val defaultSegmentToAgglomerateChunkSize: Int = 64 * 1024 // max. 1 MiB chunks (two 8-byte numbers per element) - val binaryDataService = new BinaryDataService(Paths.get(""), None, None, None, None) + val binaryDataService = new BinaryDataService(Paths.get(""), None, None, None, None, None) adHocMeshServiceHolder.tracingStoreAdHocMeshConfig = (binaryDataService, 30 seconds, 1) private val adHocMeshService: AdHocMeshService = adHocMeshServiceHolder.tracingStoreAdHocMeshService diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingService.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingService.scala index a688e210e70..653205736c2 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingService.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingService.scala @@ -69,7 +69,7 @@ class VolumeTracingService @Inject()( /* We want to reuse the bucket loading methods from binaryDataService for the volume tracings, however, it does not actually load anything from disk, unlike its “normal” instance in the datastore (only from the volume tracing store) */ - private val binaryDataService = new BinaryDataService(Paths.get(""), None, None, None, None) + private val binaryDataService = new BinaryDataService(Paths.get(""), None, None, None, None, None) adHocMeshServiceHolder.tracingStoreAdHocMeshConfig = (binaryDataService, 30 seconds, 1) val adHocMeshService: AdHocMeshService = adHocMeshServiceHolder.tracingStoreAdHocMeshService From 7edc95a4e3dee72579d8000ddba41c127e816231 Mon Sep 17 00:00:00 2001 From: frcroth Date: Wed, 12 Mar 2025 11:56:06 +0100 Subject: [PATCH 02/17] Handle other layers that also link to removed dataset --- app/models/dataset/Dataset.scala | 2 +- app/models/dataset/DatasetService.scala | 17 +- .../datastore/helpers/DatasetDeleter.scala | 158 +++++++++++------- 3 files changed, 107 insertions(+), 70 deletions(-) diff --git a/app/models/dataset/Dataset.scala b/app/models/dataset/Dataset.scala index 80fc09e8134..f6e908238a0 100755 --- a/app/models/dataset/Dataset.scala +++ b/app/models/dataset/Dataset.scala @@ -810,7 +810,7 @@ class DatasetMagsDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionConte WHERE _dataset = $datasetId AND dataLayerName = $dataLayerName""".as[DataSourceMagRow]) //TODO: Remove duplication mags <- Fox.serialCombined(rows.toList)(r => parseMag(r.mag)) - dataSources = rows.map(row => DataSourceId(row._organization, row.directoryName)) + dataSources = rows.map(row => DataSourceId(row.directoryName, row._organization)) magInfos = rows.toList.zip(mags).zip(dataSources).map { case ((row, mag), dataSource) => DatasourceMagInfo(dataSource, row.dataLayerName, mag, row.path, row.realPath, row.hasLocalData) diff --git a/app/models/dataset/DatasetService.scala b/app/models/dataset/DatasetService.scala index 73ebdb52f67..616880654f0 100644 --- a/app/models/dataset/DatasetService.scala +++ b/app/models/dataset/DatasetService.scala @@ -5,8 +5,15 @@ import com.scalableminds.util.objectid.ObjectId import com.scalableminds.util.time.Instant import com.scalableminds.util.tools.{Fox, FoxImplicits} import com.scalableminds.webknossos.datastore.helpers.DatasourceMagInfo -import com.scalableminds.webknossos.datastore.models.datasource.inbox.{UnusableDataSource, InboxDataSourceLike => InboxDataSource} -import com.scalableminds.webknossos.datastore.models.datasource.{DataSourceId, GenericDataSource, DataLayerLike => DataLayer} +import com.scalableminds.webknossos.datastore.models.datasource.inbox.{ + UnusableDataSource, + InboxDataSourceLike => InboxDataSource +} +import com.scalableminds.webknossos.datastore.models.datasource.{ + DataSourceId, + GenericDataSource, + DataLayerLike => DataLayer +} import com.scalableminds.webknossos.datastore.rpc.RPC import com.scalableminds.webknossos.datastore.services.DataSourcePathInfo import com.typesafe.scalalogging.LazyLogging @@ -350,7 +357,8 @@ class DatasetService @Inject()(organizationDAO: OrganizationDAO, _ <- Fox.serialCombined(pathInfos)(updateRealPath) } yield () - def getPathsForDataLayer(datasetId: ObjectId, layerName: String): Fox[List[(DatasourceMagInfo, List[DatasourceMagInfo])]] = + def getPathsForDataLayer(datasetId: ObjectId, + layerName: String): Fox[List[(DatasourceMagInfo, List[DatasourceMagInfo])]] = for { magInfos <- datasetMagsDAO.findPathsForDatasetAndDatalayer(datasetId, layerName) magInfosAndLinkedMags <- Fox.serialCombined(magInfos)(magInfo => @@ -358,7 +366,8 @@ class DatasetService @Inject()(organizationDAO: OrganizationDAO, case Some(realPath) => for { pathInfos <- datasetMagsDAO.findAllByRealPath(realPath) - } yield (magInfo, pathInfos.filter(!_.equals(magInfo))) + filteredPathInfos = pathInfos.filter(_.dataSourceId != magInfo.dataSourceId) + } yield (magInfo, filteredPathInfos) case None => Fox.successful((magInfo, List())) }) } yield magInfosAndLinkedMags diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala index 35ddec35290..f2fba0c9164 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala @@ -1,10 +1,10 @@ package com.scalableminds.webknossos.datastore.helpers -import com.scalableminds.util.objectid.ObjectId import com.scalableminds.util.tools.Fox import com.scalableminds.webknossos.datastore.models.datasource.DataSourceId -import com.scalableminds.webknossos.datastore.services.{DSRemoteWebknossosClient, RemoteWebknossosClient} +import com.scalableminds.webknossos.datastore.services.DSRemoteWebknossosClient import com.typesafe.scalalogging.LazyLogging -import net.liftweb.common.Full +import net.liftweb.common.Box.tryo +import net.liftweb.common.{Box, Full} import java.io.File import java.nio.file.{Files, Path} @@ -57,7 +57,7 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { else dataBaseDir.resolve(organizationId).resolve(datasetName) for { - _ <- moveSymlinks(organizationId, datasetName) + _ <- moveSymlinks(organizationId, datasetName) ?~> "Failed to remake symlinks" _ <- moveToTrash(organizationId, datasetName, dataSourcePath, reason) } yield () } @@ -68,22 +68,21 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { for { dataSourceId <- Fox.successful(DataSourceId(datasetName, organizationId)) layersAndLinkedMags <- Fox.runOptional(remoteWKClient)(_.fetchPaths(dataSourceId)) - _ = layersAndLinkedMags match { + exceptionBoxes = layersAndLinkedMags match { case Some(value) => (value.map(lmli => handleLayerSymlinks(dataSourceId, lmli.layerName, lmli.magLinkInfos.toList))) - case None => None + case None => Seq(tryo {}) } + _ <- Fox.sequence(exceptionBoxes.toList.map(Fox.box2Fox)) } yield () - private def layerMayBeMoved(dataSourceId: DataSourceId, - layerName: String, - linkedMags: List[MagLinkInfo]): Option[(DataSourceId, String)] = { + private def getFullyLinkedLayers(linkedMags: List[MagLinkInfo]): Option[Seq[(DataSourceId, String)]] = { val allMagsLocal = linkedMags.forall(_.mag.hasLocalData) val allLinkedDatasetLayers = linkedMags.map(_.linkedMags.map(lm => (lm.dataSourceId, lm.dataLayerName))) // Get combinations of datasetId, layerName that link to EVERY mag val linkedToByAllMags = allLinkedDatasetLayers.reduce((a, b) => a.intersect(b)) - if (allMagsLocal) { - linkedToByAllMags.headOption + if (allMagsLocal && linkedToByAllMags.nonEmpty) { + Some(linkedToByAllMags) } else { None } @@ -91,25 +90,52 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { private def moveLayer(sourceDataSource: DataSourceId, sourceLayer: String, - moveToDataSource: DataSourceId, - moveToDataLayer: String, - layerMags: List[MagLinkInfo]) = { - // Move layer physically + fullLayerLinks: Seq[(DataSourceId, String)], + layerMags: List[MagLinkInfo]): Unit = { + // Move layer on disk val layerPath = dataBaseDir.resolve(sourceDataSource.organizationId).resolve(sourceDataSource.directoryName).resolve(sourceLayer) + // Select one of the fully linked layers as target to move layer to + // Selection of the first one is arbitrary, is there anything to distinguish between them? + val target = fullLayerLinks.head + val moveToDataSource = target._1 + val moveToDataLayer = target._2 val targetPath = dataBaseDir .resolve(moveToDataSource.organizationId) .resolve(moveToDataSource.directoryName) .resolve(moveToDataLayer) + logger.info( + s"Found complete symlinks to layer; Moving layer $sourceLayer from $sourceDataSource to $moveToDataSource/$moveToDataLayer") if (Files.exists(targetPath) && Files.isSymbolicLink(targetPath)) { Files.delete(targetPath) } Files.move(layerPath, targetPath) // All symlinks are now broken, we need to recreate them - // For every mag that links to this layer, create a symlink to the new location + // There may be more layers that are "fully linked", where we need to add only one symlink + + fullLayerLinks.tail.foreach { linkedLayer => + val linkedLayerPath = + dataBaseDir.resolve(linkedLayer._1.organizationId).resolve(linkedLayer._1.directoryName).resolve(linkedLayer._2) + if (Files.exists(linkedLayerPath) || Files.isSymbolicLink(linkedLayerPath)) { + // Two cases exist here: 1. The layer is a regular directory where each mag is a symlink + // 2. The layer is a symlink to the other layer itself. + // We can handle both by deleting the layer and creating a new symlink. + Files.delete(linkedLayerPath) + + val absoluteTargetPath = targetPath.toAbsolutePath + val relativeTargetPath = linkedLayerPath.getParent.toAbsolutePath.relativize(absoluteTargetPath) + Files.createSymbolicLink(linkedLayerPath, relativeTargetPath) + } else { + // This should not happen, since we got the info from WK that a layer exists here! + logger.warn(s"Trying to recreate symlink at layer $linkedLayerPath, but it does not exist!") + } + } + + // For every mag that linked to this layer, we need to update the symlink + // We need to discard the already handled mags (fully linked layers) // TODO: Note that this may create more symlinks than before? Handle self-streaming. - // TODO: If there are more symlinked layers, they should also be handled as layer symlinks, not mags! + layerMags.foreach { magLinkInfo => val mag = magLinkInfo.mag val newMagPath = targetPath.resolve(mag.mag.toString) // TODO: Does this work? @@ -121,66 +147,68 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { .resolve(linkedMag.mag.toString) // Remove old symlink if (Files.exists(linkedMagPath) && Files.isSymbolicLink(linkedMagPath)) { + // Here we check for symlinks, so we do not recreate symlinks for fully linked layers (which do not have symlinks for mags) Files.delete(linkedMagPath) Files.createSymbolicLink(linkedMagPath, newMagPath) } else { // Hmmm.. - // One reason this would happen is for the newly moved layer (since it is not a symlink anymore) + // TODO: Could this happen? + logger.warn( + s"Trying to recreate symlink at mag $linkedMagPath, but it does not exist or is not a symlink! (exists= ${Files + .exists(linkedMagPath)}symlink=${Files.isSymbolicLink(linkedMagPath)})") } } } } - private def handleLayerSymlinks(dataSourceId: DataSourceId, layerName: String, linkedMags: List[MagLinkInfo]) = { - // TODO exception handling - val moveLayerTo = layerMayBeMoved(dataSourceId, layerName, linkedMags) - moveLayerTo match { - case Some((moveToDataset, moveToDataLayer)) => - logger.info( - s"Found complete symlinks to layer; Moving layer $layerName from $dataSourceId to $moveToDataset/$moveToDataLayer") - moveLayer(dataSourceId, layerName, moveToDataset, moveToDataLayer, linkedMags) - // Move all linked mags to dataset - // Move all symlinks to this dataset to link to the moved dataset - case None => - logger.info(s"Found incomplete symlinks to layer; Moving mags from $dataSourceId to other datasets") - linkedMags.foreach { magLinkInfo => - val magToDelete = magLinkInfo.mag - if (magLinkInfo.linkedMags.nonEmpty) { - if (magToDelete.hasLocalData) { - // Move mag to a different dataset - val magPath = dataBaseDir - .resolve(dataSourceId.organizationId) - .resolve(dataSourceId.directoryName) - .resolve(layerName) - .resolve(magToDelete.mag.toString) - val target = magLinkInfo.linkedMags.head - val targetPath = dataBaseDir - .resolve(target.dataSourceId.organizationId) - .resolve(target.dataSourceId.directoryName) - .resolve(target.dataLayerName) - .resolve(target.mag.toString) - Files.move(magPath, targetPath) - - // Move all symlinks to this mag to link to the moved mag - magLinkInfo.linkedMags.tail.foreach { linkedMag => - val linkedMagPath = dataBaseDir - .resolve(linkedMag.dataSourceId.organizationId) - .resolve(linkedMag.dataSourceId.directoryName) - .resolve(linkedMag.dataLayerName) - .resolve(linkedMag.mag.toString) - if (Files.exists(linkedMagPath) && Files.isSymbolicLink(linkedMagPath)) { // TODO: we probably need to update datasource.json files - Files.delete(linkedMagPath) - Files.createSymbolicLink(linkedMagPath, targetPath) - } else { - // Hmmm.. + private def handleLayerSymlinks(dataSourceId: DataSourceId, + layerName: String, + linkedMags: List[MagLinkInfo]): Box[Unit] = + tryo { + val fullyLinkedLayersOpt = getFullyLinkedLayers(linkedMags) + fullyLinkedLayersOpt match { + case Some(fullLayerLinks) => + moveLayer(dataSourceId, layerName, fullLayerLinks, linkedMags) + case None => + logger.info(s"Found incomplete symlinks to layer; Moving mags from $dataSourceId to other datasets") + linkedMags.foreach { magLinkInfo => + val magToDelete = magLinkInfo.mag + if (magLinkInfo.linkedMags.nonEmpty) { + if (magToDelete.hasLocalData) { + // Move mag to a different dataset + val magPath = dataBaseDir + .resolve(dataSourceId.organizationId) + .resolve(dataSourceId.directoryName) + .resolve(layerName) + .resolve(magToDelete.mag.toString) + val target = magLinkInfo.linkedMags.head + val targetPath = dataBaseDir + .resolve(target.dataSourceId.organizationId) + .resolve(target.dataSourceId.directoryName) + .resolve(target.dataLayerName) + .resolve(target.mag.toString) + Files.move(magPath, targetPath) + + // Move all symlinks to this mag to link to the moved mag + magLinkInfo.linkedMags.tail.foreach { linkedMag => + val linkedMagPath = dataBaseDir + .resolve(linkedMag.dataSourceId.organizationId) + .resolve(linkedMag.dataSourceId.directoryName) + .resolve(linkedMag.dataLayerName) + .resolve(linkedMag.mag.toString) + if (Files.exists(linkedMagPath) && Files.isSymbolicLink(linkedMagPath)) { // TODO: we probably need to update datasource.json files + Files.delete(linkedMagPath) + Files.createSymbolicLink(linkedMagPath, targetPath) + } else { + // Hmmm.. + } } + } else { + // TODO In this case we need to find out what the this mag actually links to } - } else { - // TODO In this case we need to find out what the this mag actually links to - } + } } - } + } } - } } From 9f8b0fefe0e462d0f8955aa84df17c17f7e21546 Mon Sep 17 00:00:00 2001 From: frcroth Date: Wed, 12 Mar 2025 13:56:58 +0100 Subject: [PATCH 03/17] Move and relink mags --- .../datastore/helpers/DatasetDeleter.scala | 75 ++++++++++++------- 1 file changed, 47 insertions(+), 28 deletions(-) diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala index f2fba0c9164..ada3fe02882 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala @@ -79,7 +79,7 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { private def getFullyLinkedLayers(linkedMags: List[MagLinkInfo]): Option[Seq[(DataSourceId, String)]] = { val allMagsLocal = linkedMags.forall(_.mag.hasLocalData) val allLinkedDatasetLayers = linkedMags.map(_.linkedMags.map(lm => (lm.dataSourceId, lm.dataLayerName))) - // Get combinations of datasetId, layerName that link to EVERY mag + // Get combinations of datasourceId, layerName that link to EVERY mag val linkedToByAllMags = allLinkedDatasetLayers.reduce((a, b) => a.intersect(b)) if (allMagsLocal && linkedToByAllMags.nonEmpty) { Some(linkedToByAllMags) @@ -88,6 +88,25 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { } } + private def relativeLayerTargetPath(targetPath: Path, layerPath: Path): Path = { + val absoluteTargetPath = targetPath.toAbsolutePath + val relativeTargetPath = layerPath.getParent.toAbsolutePath.relativize(absoluteTargetPath) + relativeTargetPath + } + + private def relativeMagTargetPath(targetPath: Path, magPath: Path): Path = { + val absoluteTargetPath = targetPath.toAbsolutePath + val relativeTargetPath = magPath.getParent.getParent.toAbsolutePath.relativize(absoluteTargetPath) + relativeTargetPath + } + + private def getMagPath(basePath: Path, magInfo: DatasourceMagInfo): Path = + basePath + .resolve(magInfo.dataSourceId.organizationId) + .resolve(magInfo.dataSourceId.directoryName) + .resolve(magInfo.dataLayerName) + .resolve(magInfo.mag.toMagLiteral(true)) + private def moveLayer(sourceDataSource: DataSourceId, sourceLayer: String, fullLayerLinks: Seq[(DataSourceId, String)], @@ -123,9 +142,7 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { // We can handle both by deleting the layer and creating a new symlink. Files.delete(linkedLayerPath) - val absoluteTargetPath = targetPath.toAbsolutePath - val relativeTargetPath = linkedLayerPath.getParent.toAbsolutePath.relativize(absoluteTargetPath) - Files.createSymbolicLink(linkedLayerPath, relativeTargetPath) + Files.createSymbolicLink(linkedLayerPath, relativeLayerTargetPath(targetPath, linkedLayerPath)) } else { // This should not happen, since we got the info from WK that a layer exists here! logger.warn(s"Trying to recreate symlink at layer $linkedLayerPath, but it does not exist!") @@ -138,21 +155,16 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { layerMags.foreach { magLinkInfo => val mag = magLinkInfo.mag - val newMagPath = targetPath.resolve(mag.mag.toString) // TODO: Does this work? + val newMagPath = targetPath.resolve(mag.mag.toMagLiteral(true)) // TODO: Does this work? magLinkInfo.linkedMags.foreach { linkedMag => - val linkedMagPath = dataBaseDir - .resolve(linkedMag.dataSourceId.organizationId) - .resolve(linkedMag.dataSourceId.directoryName) - .resolve(linkedMag.dataLayerName) - .resolve(linkedMag.mag.toString) + val linkedMagPath = getMagPath(dataBaseDir, linkedMag) // Remove old symlink if (Files.exists(linkedMagPath) && Files.isSymbolicLink(linkedMagPath)) { - // Here we check for symlinks, so we do not recreate symlinks for fully linked layers (which do not have symlinks for mags) + // Here we do not delete mags if they are not symlinks, so we do not recreate + // symlinks for fully linked layers (which do not have symlinks for mags). Files.delete(linkedMagPath) - Files.createSymbolicLink(linkedMagPath, newMagPath) + Files.createSymbolicLink(linkedMagPath, relativeMagTargetPath(newMagPath, linkedMagPath)) } else { - // Hmmm.. - // TODO: Could this happen? logger.warn( s"Trying to recreate symlink at mag $linkedMagPath, but it does not exist or is not a symlink! (exists= ${Files .exists(linkedMagPath)}symlink=${Files.isSymbolicLink(linkedMagPath)})") @@ -180,30 +192,37 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { .resolve(dataSourceId.organizationId) .resolve(dataSourceId.directoryName) .resolve(layerName) - .resolve(magToDelete.mag.toString) + .resolve(magToDelete.mag.toMagLiteral(true)) + // Select an arbitrary linked mag to move to val target = magLinkInfo.linkedMags.head - val targetPath = dataBaseDir - .resolve(target.dataSourceId.organizationId) - .resolve(target.dataSourceId.directoryName) - .resolve(target.dataLayerName) - .resolve(target.mag.toString) + val targetPath = getMagPath(dataBaseDir, target) + if (Files.exists(targetPath) && Files.isSymbolicLink(targetPath)) { + Files.delete(targetPath) + } Files.move(magPath, targetPath) // Move all symlinks to this mag to link to the moved mag magLinkInfo.linkedMags.tail.foreach { linkedMag => - val linkedMagPath = dataBaseDir - .resolve(linkedMag.dataSourceId.organizationId) - .resolve(linkedMag.dataSourceId.directoryName) - .resolve(linkedMag.dataLayerName) - .resolve(linkedMag.mag.toString) - if (Files.exists(linkedMagPath) && Files.isSymbolicLink(linkedMagPath)) { // TODO: we probably need to update datasource.json files + val linkedMagPath = getMagPath(dataBaseDir, linkedMag) + if (Files.exists(linkedMagPath) || Files.isSymbolicLink(linkedMagPath)) { Files.delete(linkedMagPath) - Files.createSymbolicLink(linkedMagPath, targetPath) + Files.createSymbolicLink(linkedMagPath, relativeMagTargetPath(targetPath, linkedMagPath)) } else { - // Hmmm.. + logger.warn(s"Trying to recreate symlink at mag $linkedMagPath, but it does not exist!") } } } else { + // The mag has no local data but there are links to it... + // Mags without local data are either + // 1. remote and thus they have no mags that can be linked to (but also we do not need to delete anything more here) + // 2. are links themselves to other mags. In this case, there can't be any links to this here since they + // would be resolved to the other mag. + // 3. locally explored datasets. In this case the path is not resolved in WK, as the path property is + // directly written into DB. + + // So we only need to handle case 3. TODO + // The problem is that we would need to write back to WK. + // TODO In this case we need to find out what the this mag actually links to } From b718bed0a68bdda7c818616dc3db8ae14a91df4f Mon Sep 17 00:00:00 2001 From: frcroth Date: Mon, 17 Mar 2025 15:02:28 +0100 Subject: [PATCH 04/17] Rewrite datasource properties for locally explored layers --- .../datastore/helpers/DatasetDeleter.scala | 91 +++++++++++++++---- 1 file changed, 75 insertions(+), 16 deletions(-) diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala index ada3fe02882..f713d0ce4d9 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala @@ -1,6 +1,12 @@ package com.scalableminds.webknossos.datastore.helpers -import com.scalableminds.util.tools.Fox -import com.scalableminds.webknossos.datastore.models.datasource.DataSourceId +import com.scalableminds.util.tools.{Fox, JsonHelper} +import com.scalableminds.webknossos.datastore.models.datasource.{ + DataLayer, + DataLayerWithMagLocators, + DataSource, + DataSourceId, + GenericDataSource +} import com.scalableminds.webknossos.datastore.services.DSRemoteWebknossosClient import com.typesafe.scalalogging.LazyLogging import net.liftweb.common.Box.tryo @@ -74,6 +80,12 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { case None => Seq(tryo {}) } _ <- Fox.sequence(exceptionBoxes.toList.map(Fox.box2Fox)) + affectedDataSources = layersAndLinkedMags + .map(layersAndMags => layersAndMags.map(_.magLinkInfos.map(m => m.linkedMags.map(_.dataSourceId)))) + .getOrElse(List()) + .flatten + .flatten + _ <- updateDatasourceProperties(affectedDataSources) } yield () private def getFullyLinkedLayers(linkedMags: List[MagLinkInfo]): Option[Seq[(DataSourceId, String)]] = { @@ -107,6 +119,37 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { .resolve(magInfo.dataLayerName) .resolve(magInfo.mag.toMagLiteral(true)) + private def updateDatasourceProperties(dataSourceIds: List[DataSourceId])( + implicit ec: ExecutionContext): Fox[List[Unit]] = + // We need to update locally explored datasets, since they now may have symlinks where previously they only had the path property set. + Fox.serialCombined(dataSourceIds)(dataSourceId => { + val propertiesPath = dataBaseDir + .resolve(dataSourceId.organizationId) + .resolve(dataSourceId.directoryName) + .resolve(GenericDataSource.FILENAME_DATASOURCE_PROPERTIES_JSON) + if (Files.exists(propertiesPath)) { + JsonHelper.validatedJsonFromFile[DataSource](propertiesPath, dataBaseDir) match { + case Full(dataSource) => + val updatedDataSource = dataSource.copy(dataLayers = dataSource.dataLayers.map { + case dl: DataLayerWithMagLocators => + if (dl.mags.forall(_.path.exists(_.startsWith("file://")))) { + // Setting path to None means using resolution of layer/mag directories to access data + dl.mapped(magMapping = _.copy(path = None)) + } else { + dl + } + case dl => dl + }) + // Write properties back + Files.delete(propertiesPath) + JsonHelper.jsonToFile(propertiesPath, updatedDataSource) + case _ => Full(()) + } + } else { + Full(()) + } + }) + private def moveLayer(sourceDataSource: DataSourceId, sourceLayer: String, fullLayerLinks: Seq[(DataSourceId, String)], @@ -144,14 +187,19 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { Files.createSymbolicLink(linkedLayerPath, relativeLayerTargetPath(targetPath, linkedLayerPath)) } else { - // This should not happen, since we got the info from WK that a layer exists here! - logger.warn(s"Trying to recreate symlink at layer $linkedLayerPath, but it does not exist!") + if (!Files.exists(linkedLayerPath)) { + // This happens when the layer is a locally explored dataset, where the path is directly written into the properties + // and no layer directory actually exists. + Files.createSymbolicLink(linkedLayerPath, relativeLayerTargetPath(targetPath, linkedLayerPath)) + } else { + // This should not happen, since we got the info from WK that a layer exists here + logger.warn(s"Trying to recreate symlink at layer $linkedLayerPath, but it does not exist!") + } } } // For every mag that linked to this layer, we need to update the symlink // We need to discard the already handled mags (fully linked layers) - // TODO: Note that this may create more symlinks than before? Handle self-streaming. layerMags.foreach { magLinkInfo => val mag = magLinkInfo.mag @@ -165,12 +213,19 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { Files.delete(linkedMagPath) Files.createSymbolicLink(linkedMagPath, relativeMagTargetPath(newMagPath, linkedMagPath)) } else { - logger.warn( - s"Trying to recreate symlink at mag $linkedMagPath, but it does not exist or is not a symlink! (exists= ${Files - .exists(linkedMagPath)}symlink=${Files.isSymbolicLink(linkedMagPath)})") + if (linkedMag.path == linkedMag.realPath) { + // In this case, this mag belongs to a locally-explored layer (using file:// protocol). + // We need to create a new symlink in this case + Files.createSymbolicLink(linkedMagPath, relativeMagTargetPath(newMagPath, linkedMagPath)) + } else { + logger.warn( + s"Trying to recreate symlink at mag $linkedMagPath, but it does not exist or is not a symlink! (exists= ${Files + .exists(linkedMagPath)}symlink=${Files.isSymbolicLink(linkedMagPath)})") + } } } } + } private def handleLayerSymlinks(dataSourceId: DataSourceId, @@ -208,7 +263,14 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { Files.delete(linkedMagPath) Files.createSymbolicLink(linkedMagPath, relativeMagTargetPath(targetPath, linkedMagPath)) } else { - logger.warn(s"Trying to recreate symlink at mag $linkedMagPath, but it does not exist!") + if (!Files.exists(linkedMagPath) && linkedMag.path == linkedMag.realPath) { + // This is the case for locally explored datasets + // Since locally explored datasets are always fully linked layers when explored, this case can + // only happen if one of the mags was manually edited in the properties file. + Files.createSymbolicLink(linkedMagPath, relativeMagTargetPath(targetPath, linkedMagPath)) + } else { + logger.warn(s"Trying to recreate symlink at mag $linkedMagPath, but it does not exist!") + } } } } else { @@ -217,13 +279,10 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { // 1. remote and thus they have no mags that can be linked to (but also we do not need to delete anything more here) // 2. are links themselves to other mags. In this case, there can't be any links to this here since they // would be resolved to the other mag. - // 3. locally explored datasets. In this case the path is not resolved in WK, as the path property is - // directly written into DB. - - // So we only need to handle case 3. TODO - // The problem is that we would need to write back to WK. - - // TODO In this case we need to find out what the this mag actually links to + // 3. locally explored datasets. They don't have layer directories that could have symlinks to them, so + // this is also not a problem. + // So this should not happen. + logger.warn(s"Trying to move mag $magToDelete, but it has no local data!") } } From 2be93a5fe6400a80913ed8d425e94d31e5cc7fa5 Mon Sep 17 00:00:00 2001 From: frcroth Date: Wed, 19 Mar 2025 11:31:14 +0100 Subject: [PATCH 05/17] Fix handling of mag, update changelog --- CHANGELOG.unreleased.md | 1 + .../WKRemoteDataStoreController.scala | 6 +- conf/application.conf | 2 +- docs/datasets/settings.md | 2 + .../datastore/helpers/DatasetDeleter.scala | 93 ++++++++----------- .../datastore/helpers/MagLinkInfo.scala | 7 +- .../services/BinaryDataServiceHolder.scala | 11 +-- 7 files changed, 55 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index fd992882d1f..f6a57960983 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -16,6 +16,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released - When using a zarr link to a wk-served data layer as another layer’s source, the user’s token is used to access the data. [#8322](https://github.com/scalableminds/webknossos/pull/8322/) - Compound annotations (created when viewing all annotations of a task) no longer permanently store data in the FossilDB. [#8422](https://github.com/scalableminds/webknossos/pull/8422) - When creating multiple tasks at once (bulk task creation), they now all need to have the same task type. [#8405](https://github.com/scalableminds/webknossos/pull/8405) +- When deleting a dataset / layer, layers that are referenced in other datasets are moved there instead of being deleted. [#8437](https://github.com/scalableminds/webknossos/pull/8437/) ### Fixed - Fixed a bug that would lock a non-existing mapping to an empty segmentation layer under certain conditions. [#8401](https://github.com/scalableminds/webknossos/pull/8401) diff --git a/app/controllers/WKRemoteDataStoreController.scala b/app/controllers/WKRemoteDataStoreController.scala index e68ba683178..16ed036be7a 100644 --- a/app/controllers/WKRemoteDataStoreController.scala +++ b/app/controllers/WKRemoteDataStoreController.scala @@ -10,7 +10,11 @@ import com.scalableminds.webknossos.datastore.models.UnfinishedUpload import com.scalableminds.webknossos.datastore.models.datasource.DataSourceId import com.scalableminds.webknossos.datastore.models.datasource.inbox.{InboxDataSourceLike => InboxDataSource} import com.scalableminds.webknossos.datastore.services.{DataSourcePathInfo, DataStoreStatus} -import com.scalableminds.webknossos.datastore.services.uploading.{LinkedLayerIdentifier, ReserveAdditionalInformation, ReserveUploadInformation} +import com.scalableminds.webknossos.datastore.services.uploading.{ + LinkedLayerIdentifier, + ReserveAdditionalInformation, + ReserveUploadInformation +} import com.typesafe.scalalogging.LazyLogging import mail.{MailchimpClient, MailchimpTag} import models.analytics.{AnalyticsService, UploadDatasetEvent} diff --git a/conf/application.conf b/conf/application.conf index f62ca861b1b..ddb16db6cfe 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -196,7 +196,7 @@ datastore { pingInterval = 10 minutes } baseDirectory = "binaryData" - localDirectoryWhitelist = [] # list of local absolute directory paths where image data may be explored and served from + localDirectoryWhitelist = ["/home/felix/scm/webknossos/binaryData/sample_organization"] # list of local absolute directory paths where image data may be explored and served from watchFileSystem { enabled = true interval = 1 minute diff --git a/docs/datasets/settings.md b/docs/datasets/settings.md index be676fafc65..e75d95606c1 100644 --- a/docs/datasets/settings.md +++ b/docs/datasets/settings.md @@ -71,4 +71,6 @@ You don't have to set complete *View Configurations* in either option, as WEBKNO Offers an option to delete a dataset and completely remove it from WEBKNOSSOS. Be careful, this cannot be undone! +When other datasets reference layers from this dataset, WEBKNOSSOS will try to move these layers to the dataset they are referenced in, so that it can still be accessed. + ![Dataset Editing: Delete Tab](../images/delete_tab.jpeg) diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala index f713d0ce4d9..378c6e2f382 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala @@ -1,7 +1,6 @@ package com.scalableminds.webknossos.datastore.helpers import com.scalableminds.util.tools.{Fox, JsonHelper} import com.scalableminds.webknossos.datastore.models.datasource.{ - DataLayer, DataLayerWithMagLocators, DataSource, DataSourceId, @@ -43,7 +42,7 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { def moveToTrash(organizationId: String, datasetName: String, dataSourcePath: Path, - reason: Option[String] = None): Fox[Unit] = + reason: Option[String]): Fox[Unit] = if (Files.exists(dataSourcePath)) { val trashPath: Path = dataBaseDir.resolve(organizationId).resolve(trashDir) val targetPath = trashPath.resolve(datasetName) @@ -70,17 +69,20 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { def remoteWKClient: Option[DSRemoteWebknossosClient] + // Handle references to layers and mags that are deleted + private def moveSymlinks(organizationId: String, datasetName: String)(implicit ec: ExecutionContext) = for { dataSourceId <- Fox.successful(DataSourceId(datasetName, organizationId)) - layersAndLinkedMags <- Fox.runOptional(remoteWKClient)(_.fetchPaths(dataSourceId)) - exceptionBoxes = layersAndLinkedMags match { - case Some(value) => - (value.map(lmli => handleLayerSymlinks(dataSourceId, lmli.layerName, lmli.magLinkInfos.toList))) + layersAndLinkedMagsOpt <- Fox.runOptional(remoteWKClient)(_.fetchPaths(dataSourceId)) + exceptionBoxes = layersAndLinkedMagsOpt match { + case Some(layersAndLinkedMags) => + layersAndLinkedMags.map(layerMagLinkInfo => + handleLayerSymlinks(dataSourceId, layerMagLinkInfo.layerName, layerMagLinkInfo.magLinkInfos.toList)) case None => Seq(tryo {}) } - _ <- Fox.sequence(exceptionBoxes.toList.map(Fox.box2Fox)) - affectedDataSources = layersAndLinkedMags + _ <- Fox.combined(exceptionBoxes.toList.map(Fox.box2Fox)) ?~> "Failed to move symlinks" + affectedDataSources = layersAndLinkedMagsOpt .map(layersAndMags => layersAndMags.map(_.magLinkInfos.map(m => m.linkedMags.map(_.dataSourceId)))) .getOrElse(List()) .flatten @@ -100,15 +102,9 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { } } - private def relativeLayerTargetPath(targetPath: Path, layerPath: Path): Path = { - val absoluteTargetPath = targetPath.toAbsolutePath - val relativeTargetPath = layerPath.getParent.toAbsolutePath.relativize(absoluteTargetPath) - relativeTargetPath - } - - private def relativeMagTargetPath(targetPath: Path, magPath: Path): Path = { + private def relativizeSymlinkPath(targetPath: Path, originPath: Path): Path = { val absoluteTargetPath = targetPath.toAbsolutePath - val relativeTargetPath = magPath.getParent.getParent.toAbsolutePath.relativize(absoluteTargetPath) + val relativeTargetPath = originPath.getParent.toAbsolutePath.relativize(absoluteTargetPath) relativeTargetPath } @@ -121,7 +117,8 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { private def updateDatasourceProperties(dataSourceIds: List[DataSourceId])( implicit ec: ExecutionContext): Fox[List[Unit]] = - // We need to update locally explored datasets, since they now may have symlinks where previously they only had the path property set. + // We need to update locally explored datasets, since they now may have symlinks where previously they only had the + // path property set. Fox.serialCombined(dataSourceIds)(dataSourceId => { val propertiesPath = dataBaseDir .resolve(dataSourceId.organizationId) @@ -150,6 +147,23 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { } }) + private def updateMagSymlinks(targetMagPath: Path, linkedMag: DatasourceMagInfo): Unit = { + val linkedMagPath = getMagPath(dataBaseDir, linkedMag) + if (Files.exists(linkedMagPath) || Files.isSymbolicLink(linkedMagPath)) { + Files.delete(linkedMagPath) + Files.createSymbolicLink(linkedMagPath, relativizeSymlinkPath(targetMagPath, linkedMagPath)) + } else { + if (!Files.exists(linkedMagPath) && linkedMag.path == linkedMag.realPath) { + // This is the case for locally explored datasets + // Since locally explored datasets are always fully linked layers when explored, this case can + // only happen if one of the mags was manually edited in the properties file. + Files.createSymbolicLink(linkedMagPath, relativizeSymlinkPath(targetMagPath, linkedMagPath)) + } else { + logger.warn(s"Trying to recreate symlink at mag $linkedMagPath, but it does not exist!") + } + } + } + private def moveLayer(sourceDataSource: DataSourceId, sourceLayer: String, fullLayerLinks: Seq[(DataSourceId, String)], @@ -185,12 +199,12 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { // We can handle both by deleting the layer and creating a new symlink. Files.delete(linkedLayerPath) - Files.createSymbolicLink(linkedLayerPath, relativeLayerTargetPath(targetPath, linkedLayerPath)) + Files.createSymbolicLink(linkedLayerPath, relativizeSymlinkPath(targetPath, linkedLayerPath)) } else { if (!Files.exists(linkedLayerPath)) { // This happens when the layer is a locally explored dataset, where the path is directly written into the properties // and no layer directory actually exists. - Files.createSymbolicLink(linkedLayerPath, relativeLayerTargetPath(targetPath, linkedLayerPath)) + Files.createSymbolicLink(linkedLayerPath, relativizeSymlinkPath(targetPath, linkedLayerPath)) } else { // This should not happen, since we got the info from WK that a layer exists here logger.warn(s"Trying to recreate symlink at layer $linkedLayerPath, but it does not exist!") @@ -203,27 +217,12 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { layerMags.foreach { magLinkInfo => val mag = magLinkInfo.mag - val newMagPath = targetPath.resolve(mag.mag.toMagLiteral(true)) // TODO: Does this work? - magLinkInfo.linkedMags.foreach { linkedMag => - val linkedMagPath = getMagPath(dataBaseDir, linkedMag) - // Remove old symlink - if (Files.exists(linkedMagPath) && Files.isSymbolicLink(linkedMagPath)) { - // Here we do not delete mags if they are not symlinks, so we do not recreate - // symlinks for fully linked layers (which do not have symlinks for mags). - Files.delete(linkedMagPath) - Files.createSymbolicLink(linkedMagPath, relativeMagTargetPath(newMagPath, linkedMagPath)) - } else { - if (linkedMag.path == linkedMag.realPath) { - // In this case, this mag belongs to a locally-explored layer (using file:// protocol). - // We need to create a new symlink in this case - Files.createSymbolicLink(linkedMagPath, relativeMagTargetPath(newMagPath, linkedMagPath)) - } else { - logger.warn( - s"Trying to recreate symlink at mag $linkedMagPath, but it does not exist or is not a symlink! (exists= ${Files - .exists(linkedMagPath)}symlink=${Files.isSymbolicLink(linkedMagPath)})") - } + val newMagPath = targetPath.resolve(mag.mag.toMagLiteral(true)) + magLinkInfo.linkedMags + .filter(linkedMag => !fullLayerLinks.contains((linkedMag.dataSourceId, linkedMag.dataLayerName))) // Filter out mags that are fully linked layers, we already handled them + .foreach { linkedMag => + updateMagSymlinks(newMagPath, linkedMag) } - } } } @@ -258,20 +257,7 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { // Move all symlinks to this mag to link to the moved mag magLinkInfo.linkedMags.tail.foreach { linkedMag => - val linkedMagPath = getMagPath(dataBaseDir, linkedMag) - if (Files.exists(linkedMagPath) || Files.isSymbolicLink(linkedMagPath)) { - Files.delete(linkedMagPath) - Files.createSymbolicLink(linkedMagPath, relativeMagTargetPath(targetPath, linkedMagPath)) - } else { - if (!Files.exists(linkedMagPath) && linkedMag.path == linkedMag.realPath) { - // This is the case for locally explored datasets - // Since locally explored datasets are always fully linked layers when explored, this case can - // only happen if one of the mags was manually edited in the properties file. - Files.createSymbolicLink(linkedMagPath, relativeMagTargetPath(targetPath, linkedMagPath)) - } else { - logger.warn(s"Trying to recreate symlink at mag $linkedMagPath, but it does not exist!") - } - } + updateMagSymlinks(targetPath, linkedMag) } } else { // The mag has no local data but there are links to it... @@ -284,7 +270,6 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { // So this should not happen. logger.warn(s"Trying to move mag $magToDelete, but it has no local data!") } - } } } diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/MagLinkInfo.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/MagLinkInfo.scala index 3fcd03593af..976c81177ef 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/MagLinkInfo.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/MagLinkInfo.scala @@ -1,18 +1,15 @@ package com.scalableminds.webknossos.datastore.helpers import com.scalableminds.util.geometry.Vec3Int -import com.scalableminds.util.objectid.ObjectId import com.scalableminds.webknossos.datastore.models.datasource.DataSourceId import play.api.libs.json.{Format, Json} - - case class DatasourceMagInfo(dataSourceId: DataSourceId, dataLayerName: String, mag: Vec3Int, path: Option[String], - realPath: Option[String], - hasLocalData: Boolean) + realPath: Option[String], + hasLocalData: Boolean) object DatasourceMagInfo { implicit val jsonFormat: Format[DatasourceMagInfo] = Json.format[DatasourceMagInfo] diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/BinaryDataServiceHolder.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/BinaryDataServiceHolder.scala index 0c5c9dab5e1..f6a452fd172 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/BinaryDataServiceHolder.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/BinaryDataServiceHolder.scala @@ -19,12 +19,11 @@ import scala.concurrent.ExecutionContext * The DataStore one is singleton-ized via this holder. */ -class BinaryDataServiceHolder @Inject()( - config: DataStoreConfig, - agglomerateService: AgglomerateService, - remoteSourceDescriptorService: RemoteSourceDescriptorService, - datasetErrorLoggingService: DatasetErrorLoggingService, - remoteWebknossosClient: DSRemoteWebknossosClient)(implicit ec: ExecutionContext) +class BinaryDataServiceHolder @Inject()(config: DataStoreConfig, + agglomerateService: AgglomerateService, + remoteSourceDescriptorService: RemoteSourceDescriptorService, + datasetErrorLoggingService: DatasetErrorLoggingService, + remoteWebknossosClient: DSRemoteWebknossosClient)(implicit ec: ExecutionContext) extends LazyLogging { private lazy val sharedChunkContentsCache: AlfuCache[String, MultiArray] = { From f6edac4e222acd74922da88b958e58d761686e04 Mon Sep 17 00:00:00 2001 From: frcroth Date: Wed, 19 Mar 2025 11:35:26 +0100 Subject: [PATCH 06/17] Revert changes to application.conf --- conf/application.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conf/application.conf b/conf/application.conf index ddb16db6cfe..f62ca861b1b 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -196,7 +196,7 @@ datastore { pingInterval = 10 minutes } baseDirectory = "binaryData" - localDirectoryWhitelist = ["/home/felix/scm/webknossos/binaryData/sample_organization"] # list of local absolute directory paths where image data may be explored and served from + localDirectoryWhitelist = [] # list of local absolute directory paths where image data may be explored and served from watchFileSystem { enabled = true interval = 1 minute From 41ba8ff5e42fd006c736a82a8328b24163036124 Mon Sep 17 00:00:00 2001 From: frcroth Date: Wed, 19 Mar 2025 11:46:25 +0100 Subject: [PATCH 07/17] Remove code duplication --- app/models/dataset/Dataset.scala | 30 +++++++++---------- app/models/dataset/DatasetService.scala | 4 +-- .../datastore/helpers/DatasetDeleter.scala | 4 +-- .../datastore/helpers/MagLinkInfo.scala | 8 ++--- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/app/models/dataset/Dataset.scala b/app/models/dataset/Dataset.scala index f6e908238a0..4de788011d3 100755 --- a/app/models/dataset/Dataset.scala +++ b/app/models/dataset/Dataset.scala @@ -5,7 +5,7 @@ import com.scalableminds.util.geometry.{BoundingBox, Vec3Double, Vec3Int} import com.scalableminds.util.objectid.ObjectId import com.scalableminds.util.time.Instant import com.scalableminds.util.tools.{Fox, FoxImplicits, JsonHelper} -import com.scalableminds.webknossos.datastore.helpers.DatasourceMagInfo +import com.scalableminds.webknossos.datastore.helpers.DataSourceMagInfo import com.scalableminds.webknossos.datastore.models.{LengthUnit, VoxelSize} import com.scalableminds.webknossos.datastore.models.datasource.DatasetViewConfiguration.DatasetViewConfiguration import com.scalableminds.webknossos.datastore.models.datasource.LayerViewConfiguration.LayerViewConfiguration @@ -802,33 +802,33 @@ class DatasetMagsDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionConte r.nextString(), r.nextString())) - def findPathsForDatasetAndDatalayer(datasetId: ObjectId, dataLayerName: String): Fox[List[DatasourceMagInfo]] = + private def rowsToMagInfos(rows: Vector[DataSourceMagRow]): Fox[List[DataSourceMagInfo]] = for { - rows <- run(q"""SELECT $columns, _organization, directoryName - FROM webknossos.dataset_mags - INNER JOIN webknossos.datasets ON webknossos.dataset_mags._dataset = webknossos.datasets._id - WHERE _dataset = $datasetId - AND dataLayerName = $dataLayerName""".as[DataSourceMagRow]) //TODO: Remove duplication mags <- Fox.serialCombined(rows.toList)(r => parseMag(r.mag)) dataSources = rows.map(row => DataSourceId(row.directoryName, row._organization)) magInfos = rows.toList.zip(mags).zip(dataSources).map { case ((row, mag), dataSource) => - DatasourceMagInfo(dataSource, row.dataLayerName, mag, row.path, row.realPath, row.hasLocalData) + DataSourceMagInfo(dataSource, row.dataLayerName, mag, row.path, row.realPath, row.hasLocalData) } } yield magInfos - def findAllByRealPath(realPath: String): Fox[List[DatasourceMagInfo]] = + def findPathsForDatasetAndDatalayer(datasetId: ObjectId, dataLayerName: String): Fox[List[DataSourceMagInfo]] = + for { + rows <- run(q"""SELECT $columns, _organization, directoryName + FROM webknossos.dataset_mags + INNER JOIN webknossos.datasets ON webknossos.dataset_mags._dataset = webknossos.datasets._id + WHERE _dataset = $datasetId + AND dataLayerName = $dataLayerName""".as[DataSourceMagRow]) + magInfos <- rowsToMagInfos(rows) + } yield magInfos + + def findAllByRealPath(realPath: String): Fox[List[DataSourceMagInfo]] = for { rows <- run(q"""SELECT $columns, _organization, directoryName FROM webknossos.dataset_mags INNER JOIN webknossos.datasets ON webknossos.dataset_mags._dataset = webknossos.datasets._id WHERE realPath = $realPath""".as[DataSourceMagRow]) - mags <- Fox.serialCombined(rows.toList)(r => parseMag(r.mag)) - dataSources = rows.map(row => DataSourceId(row.directoryName, row._organization)) - magInfos = rows.toList.zip(mags).zip(dataSources).map { - case ((row, mag), dataSource) => - DatasourceMagInfo(dataSource, row.dataLayerName, mag, row.path, row.realPath, row.hasLocalData) - } + magInfos <- rowsToMagInfos(rows) } yield magInfos } diff --git a/app/models/dataset/DatasetService.scala b/app/models/dataset/DatasetService.scala index 616880654f0..c861e26661c 100644 --- a/app/models/dataset/DatasetService.scala +++ b/app/models/dataset/DatasetService.scala @@ -4,7 +4,7 @@ import com.scalableminds.util.accesscontext.{DBAccessContext, GlobalAccessContex import com.scalableminds.util.objectid.ObjectId import com.scalableminds.util.time.Instant import com.scalableminds.util.tools.{Fox, FoxImplicits} -import com.scalableminds.webknossos.datastore.helpers.DatasourceMagInfo +import com.scalableminds.webknossos.datastore.helpers.DataSourceMagInfo import com.scalableminds.webknossos.datastore.models.datasource.inbox.{ UnusableDataSource, InboxDataSourceLike => InboxDataSource @@ -358,7 +358,7 @@ class DatasetService @Inject()(organizationDAO: OrganizationDAO, } yield () def getPathsForDataLayer(datasetId: ObjectId, - layerName: String): Fox[List[(DatasourceMagInfo, List[DatasourceMagInfo])]] = + layerName: String): Fox[List[(DataSourceMagInfo, List[DataSourceMagInfo])]] = for { magInfos <- datasetMagsDAO.findPathsForDatasetAndDatalayer(datasetId, layerName) magInfosAndLinkedMags <- Fox.serialCombined(magInfos)(magInfo => diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala index 378c6e2f382..4a68f179e1a 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala @@ -108,7 +108,7 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { relativeTargetPath } - private def getMagPath(basePath: Path, magInfo: DatasourceMagInfo): Path = + private def getMagPath(basePath: Path, magInfo: DataSourceMagInfo): Path = basePath .resolve(magInfo.dataSourceId.organizationId) .resolve(magInfo.dataSourceId.directoryName) @@ -147,7 +147,7 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { } }) - private def updateMagSymlinks(targetMagPath: Path, linkedMag: DatasourceMagInfo): Unit = { + private def updateMagSymlinks(targetMagPath: Path, linkedMag: DataSourceMagInfo): Unit = { val linkedMagPath = getMagPath(dataBaseDir, linkedMag) if (Files.exists(linkedMagPath) || Files.isSymbolicLink(linkedMagPath)) { Files.delete(linkedMagPath) diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/MagLinkInfo.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/MagLinkInfo.scala index 976c81177ef..c5076828bce 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/MagLinkInfo.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/MagLinkInfo.scala @@ -4,18 +4,18 @@ import com.scalableminds.util.geometry.Vec3Int import com.scalableminds.webknossos.datastore.models.datasource.DataSourceId import play.api.libs.json.{Format, Json} -case class DatasourceMagInfo(dataSourceId: DataSourceId, +case class DataSourceMagInfo(dataSourceId: DataSourceId, dataLayerName: String, mag: Vec3Int, path: Option[String], realPath: Option[String], hasLocalData: Boolean) -object DatasourceMagInfo { - implicit val jsonFormat: Format[DatasourceMagInfo] = Json.format[DatasourceMagInfo] +object DataSourceMagInfo { + implicit val jsonFormat: Format[DataSourceMagInfo] = Json.format[DataSourceMagInfo] } -case class MagLinkInfo(mag: DatasourceMagInfo, linkedMags: Seq[DatasourceMagInfo]) +case class MagLinkInfo(mag: DataSourceMagInfo, linkedMags: Seq[DataSourceMagInfo]) object MagLinkInfo { implicit val jsonFormat: Format[MagLinkInfo] = Json.format[MagLinkInfo] From 6c54a5127fcd66b469962a919f4f3b81f245532e Mon Sep 17 00:00:00 2001 From: frcroth Date: Wed, 19 Mar 2025 13:11:11 +0100 Subject: [PATCH 08/17] Add logs whenever something is deleted --- .../webknossos/datastore/helpers/DatasetDeleter.scala | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala index 4a68f179e1a..f79b63ff87c 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala @@ -151,6 +151,7 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { val linkedMagPath = getMagPath(dataBaseDir, linkedMag) if (Files.exists(linkedMagPath) || Files.isSymbolicLink(linkedMagPath)) { Files.delete(linkedMagPath) + logger.info(s"Deleting symlink and recreating it at $linkedMagPath") Files.createSymbolicLink(linkedMagPath, relativizeSymlinkPath(targetMagPath, linkedMagPath)) } else { if (!Files.exists(linkedMagPath) && linkedMag.path == linkedMag.realPath) { @@ -198,7 +199,8 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { // 2. The layer is a symlink to the other layer itself. // We can handle both by deleting the layer and creating a new symlink. Files.delete(linkedLayerPath) - + logger.info( + s"Deleting existing symlink at $linkedLayerPath linking to $sourceDataSource/$sourceLayer, creating new symlink") Files.createSymbolicLink(linkedLayerPath, relativizeSymlinkPath(targetPath, linkedLayerPath)) } else { if (!Files.exists(linkedLayerPath)) { @@ -251,6 +253,9 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { val target = magLinkInfo.linkedMags.head val targetPath = getMagPath(dataBaseDir, target) if (Files.exists(targetPath) && Files.isSymbolicLink(targetPath)) { + logger.info( + s"Deleting existing symlink at $targetPath linking to ${magToDelete.dataSourceId}/${magToDelete.dataLayerName}/${magToDelete.mag + .toMagLiteral(true)}") Files.delete(targetPath) } Files.move(magPath, targetPath) From ab0ab84a531a5362a22472675d2b7be96b78c9d8 Mon Sep 17 00:00:00 2001 From: frcroth Date: Mon, 24 Mar 2025 17:10:54 +0100 Subject: [PATCH 09/17] Make remoteWkClient non optional --- app/models/dataset/Dataset.scala | 11 ++---- app/models/dataset/DatasetService.scala | 7 ++++ .../controllers/DataSourceController.scala | 2 +- .../datastore/helpers/DatasetDeleter.scala | 36 +++++++++---------- .../services/BinaryDataService.scala | 5 +-- .../services/BinaryDataServiceHolder.scala | 13 ++++--- .../services/DataSourceService.scala | 6 ++-- .../services/uploading/UploadService.scala | 2 -- .../EditableMappingService.scala | 2 +- .../volume/VolumeTracingService.scala | 2 +- 10 files changed, 38 insertions(+), 48 deletions(-) diff --git a/app/models/dataset/Dataset.scala b/app/models/dataset/Dataset.scala index 4de788011d3..60aa37fb308 100755 --- a/app/models/dataset/Dataset.scala +++ b/app/models/dataset/Dataset.scala @@ -717,14 +717,7 @@ case class MagWithPaths(layerName: String, realPath: Option[String], hasLocalData: Boolean) -case class DatasetMagInfo(datasetId: ObjectId, - dataLayerName: String, - mag: Vec3Int, - path: Option[String], - realPath: Option[String], - hasLocalData: Boolean) - -case class DataSourceMagRow(_dataset: String, +case class DataSourceMagRow(_dataset: ObjectId, dataLayerName: String, mag: String, path: Option[String], @@ -793,7 +786,7 @@ class DatasetMagsDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionConte implicit def GetResultDataSourceMagRow: GetResult[DataSourceMagRow] = GetResult( r => - DataSourceMagRow(r.nextString(), + DataSourceMagRow(ObjectId(r.nextString()), r.nextString(), r.nextString(), r.nextStringOption(), diff --git a/app/models/dataset/DatasetService.scala b/app/models/dataset/DatasetService.scala index c861e26661c..62933748f71 100644 --- a/app/models/dataset/DatasetService.scala +++ b/app/models/dataset/DatasetService.scala @@ -357,6 +357,13 @@ class DatasetService @Inject()(organizationDAO: OrganizationDAO, _ <- Fox.serialCombined(pathInfos)(updateRealPath) } yield () + /** + * Returns a list of tuples, where the first element is the magInfo and the second element is a list of all magInfos + * that share the same realPath but have a different dataSourceId. For each mag in the data layer there is one tuple. + * @param datasetId id of the dataset + * @param layerName name of the layer in the dataset + * @return + */ def getPathsForDataLayer(datasetId: ObjectId, layerName: String): Fox[List[(DataSourceMagInfo, List[DataSourceMagInfo])]] = for { diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DataSourceController.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DataSourceController.scala index 6fa5fb34dfb..7e65b3efb89 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DataSourceController.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DataSourceController.scala @@ -486,7 +486,7 @@ class DataSourceController @Inject()( val dataSourceId = DataSourceId(datasetDirectoryName, organizationId) accessTokenService.validateAccessFromTokenContext(UserAccessRequest.deleteDataSource(dataSourceId)) { for { - _ <- binaryDataServiceHolder.binaryDataService.deleteOnDisk( + _ <- dataSourceService.deleteOnDisk( organizationId, datasetDirectoryName, reason = Some("the user wants to delete the dataset")) ?~> "dataset.delete.failed" diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala index f79b63ff87c..cdad6632a6c 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala @@ -7,9 +7,10 @@ import com.scalableminds.webknossos.datastore.models.datasource.{ GenericDataSource } import com.scalableminds.webknossos.datastore.services.DSRemoteWebknossosClient +import com.scalableminds.webknossos.datastore.storage.DataVaultService import com.typesafe.scalalogging.LazyLogging import net.liftweb.common.Box.tryo -import net.liftweb.common.{Box, Full} +import net.liftweb.common.{Box, EmptyBox, Full} import java.io.File import java.nio.file.{Files, Path} @@ -48,9 +49,8 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { val targetPath = trashPath.resolve(datasetName) new File(trashPath.toString).mkdirs() - logger.info(s"Deleting dataset by moving it from $dataSourcePath to $targetPath${if (reason.isDefined) - s" because ${reason.getOrElse("")}" - else "..."}") + logger.info( + s"Deleting dataset by moving it from $dataSourcePath to $targetPath${reason.map(r => s"because $r").getOrElse("...")}") deleteWithRetry(dataSourcePath, targetPath) } else { Fox.successful(logger.info( @@ -67,25 +67,19 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { } yield () } - def remoteWKClient: Option[DSRemoteWebknossosClient] + def remoteWebknossosClient: DSRemoteWebknossosClient // Handle references to layers and mags that are deleted private def moveSymlinks(organizationId: String, datasetName: String)(implicit ec: ExecutionContext) = for { dataSourceId <- Fox.successful(DataSourceId(datasetName, organizationId)) - layersAndLinkedMagsOpt <- Fox.runOptional(remoteWKClient)(_.fetchPaths(dataSourceId)) - exceptionBoxes = layersAndLinkedMagsOpt match { - case Some(layersAndLinkedMags) => - layersAndLinkedMags.map(layerMagLinkInfo => - handleLayerSymlinks(dataSourceId, layerMagLinkInfo.layerName, layerMagLinkInfo.magLinkInfos.toList)) - case None => Seq(tryo {}) - } - _ <- Fox.combined(exceptionBoxes.toList.map(Fox.box2Fox)) ?~> "Failed to move symlinks" - affectedDataSources = layersAndLinkedMagsOpt - .map(layersAndMags => layersAndMags.map(_.magLinkInfos.map(m => m.linkedMags.map(_.dataSourceId)))) - .getOrElse(List()) - .flatten + layersAndLinkedMags <- remoteWebknossosClient.fetchPaths(dataSourceId) + exceptionBoxes = layersAndLinkedMags.map(layerMagLinkInfo => + handleLayerSymlinks(dataSourceId, layerMagLinkInfo.layerName, layerMagLinkInfo.magLinkInfos.toList)) + _ <- Fox.combined(exceptionBoxes.map(Fox.box2Fox)) ?~> "Failed to move symlinks" + affectedDataSources = layersAndLinkedMags + .flatMap(_.magLinkInfos.map(m => m.linkedMags.map(_.dataSourceId))) .flatten _ <- updateDatasourceProperties(affectedDataSources) } yield () @@ -129,7 +123,7 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { case Full(dataSource) => val updatedDataSource = dataSource.copy(dataLayers = dataSource.dataLayers.map { case dl: DataLayerWithMagLocators => - if (dl.mags.forall(_.path.exists(_.startsWith("file://")))) { + if (dl.mags.forall(_.path.exists(_.startsWith(s"${DataVaultService.schemeFile}://")))) { // Setting path to None means using resolution of layer/mag directories to access data dl.mapped(magMapping = _.copy(path = None)) } else { @@ -138,8 +132,10 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { case dl => dl }) // Write properties back - Files.delete(propertiesPath) - JsonHelper.jsonToFile(propertiesPath, updatedDataSource) + tryo(Files.delete(propertiesPath)) match { + case Full(_) => JsonHelper.jsonToFile(propertiesPath, updatedDataSource) + case e => e + } case _ => Full(()) } } else { diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/BinaryDataService.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/BinaryDataService.scala index 7a0d3347c13..9ff57b81a8f 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/BinaryDataService.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/BinaryDataService.scala @@ -5,7 +5,6 @@ import com.scalableminds.util.cache.AlfuCache import com.scalableminds.util.geometry.Vec3Int import com.scalableminds.util.tools.ExtendedTypes.ExtendedArraySeq import com.scalableminds.util.tools.{Fox, FoxImplicits} -import com.scalableminds.webknossos.datastore.helpers.DatasetDeleter import com.scalableminds.webknossos.datastore.models.BucketPosition import com.scalableminds.webknossos.datastore.models.datasource.{Category, DataLayer, DataSourceId} import com.scalableminds.webknossos.datastore.models.requests.{DataReadInstruction, DataServiceDataRequest} @@ -22,10 +21,8 @@ class BinaryDataService(val dataBaseDir: Path, val agglomerateServiceOpt: Option[AgglomerateService], remoteSourceDescriptorServiceOpt: Option[RemoteSourceDescriptorService], sharedChunkContentsCache: Option[AlfuCache[String, MultiArray]], - datasetErrorLoggingService: Option[DatasetErrorLoggingService], - val remoteWKClient: Option[DSRemoteWebknossosClient])(implicit ec: ExecutionContext) + datasetErrorLoggingService: Option[DatasetErrorLoggingService])(implicit ec: ExecutionContext) extends FoxImplicits - with DatasetDeleter with LazyLogging { /* Note that this must stay in sync with the front-end constant MAX_MAG_FOR_AGGLOMERATE_MAPPING diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/BinaryDataServiceHolder.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/BinaryDataServiceHolder.scala index f6a452fd172..09c34a3bc08 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/BinaryDataServiceHolder.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/BinaryDataServiceHolder.scala @@ -19,11 +19,11 @@ import scala.concurrent.ExecutionContext * The DataStore one is singleton-ized via this holder. */ -class BinaryDataServiceHolder @Inject()(config: DataStoreConfig, - agglomerateService: AgglomerateService, - remoteSourceDescriptorService: RemoteSourceDescriptorService, - datasetErrorLoggingService: DatasetErrorLoggingService, - remoteWebknossosClient: DSRemoteWebknossosClient)(implicit ec: ExecutionContext) +class BinaryDataServiceHolder @Inject()( + config: DataStoreConfig, + agglomerateService: AgglomerateService, + remoteSourceDescriptorService: RemoteSourceDescriptorService, + datasetErrorLoggingService: DatasetErrorLoggingService)(implicit ec: ExecutionContext) extends LazyLogging { private lazy val sharedChunkContentsCache: AlfuCache[String, MultiArray] = { @@ -46,8 +46,7 @@ class BinaryDataServiceHolder @Inject()(config: DataStoreConfig, Some(agglomerateService), Some(remoteSourceDescriptorService), Some(sharedChunkContentsCache), - Some(datasetErrorLoggingService), - Some(remoteWebknossosClient) + Some(datasetErrorLoggingService) ) } diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/DataSourceService.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/DataSourceService.scala index fc4e6855e2d..d7f843ac411 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/DataSourceService.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/DataSourceService.scala @@ -10,7 +10,7 @@ import com.scalableminds.util.time.Instant import com.scalableminds.util.tools.{Fox, FoxImplicits, JsonHelper} import com.scalableminds.webknossos.datastore.DataStoreConfig import com.scalableminds.webknossos.datastore.dataformats.{MagLocator, MappingProvider} -import com.scalableminds.webknossos.datastore.helpers.IntervalScheduler +import com.scalableminds.webknossos.datastore.helpers.{DatasetDeleter, IntervalScheduler} import com.scalableminds.webknossos.datastore.models.datasource._ import com.scalableminds.webknossos.datastore.models.datasource.inbox.{InboxDataSource, UnusableDataSource} import com.scalableminds.webknossos.datastore.storage.{DataVaultService, RemoteSourceDescriptorService} @@ -31,11 +31,12 @@ class DataSourceService @Inject()( config: DataStoreConfig, dataSourceRepository: DataSourceRepository, remoteSourceDescriptorService: RemoteSourceDescriptorService, - remoteWebknossosClient: DSRemoteWebknossosClient, + val remoteWebknossosClient: DSRemoteWebknossosClient, val lifecycle: ApplicationLifecycle, @Named("webknossos-datastore") val actorSystem: ActorSystem )(implicit val ec: ExecutionContext) extends IntervalScheduler + with DatasetDeleter with LazyLogging with FoxImplicits with Formatter { @@ -334,5 +335,4 @@ class DataSourceService @Inject()( remoteSourceDescriptorService.removeVaultFromCache(dataBaseDir, dataSource.id, dataLayer.name, mag)) } yield dataLayer.mags.length } yield removedEntriesList.sum - } diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/uploading/UploadService.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/uploading/UploadService.scala index 03911c21287..f462b656df5 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/uploading/UploadService.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/uploading/UploadService.scala @@ -149,8 +149,6 @@ class UploadService @Inject()(dataSourceRepository: DataSourceRepository, override def dataBaseDir: Path = dataSourceService.dataBaseDir - override def remoteWKClient: Option[DSRemoteWebknossosClient] = Some(remoteWebknossosClient) - def isKnownUploadByFileId(uploadFileId: String): Fox[Boolean] = isKnownUpload(extractDatasetUploadId(uploadFileId)) def isKnownUpload(uploadId: String): Fox[Boolean] = diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/editablemapping/EditableMappingService.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/editablemapping/EditableMappingService.scala index 128ab129ceb..c4b0801474a 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/editablemapping/EditableMappingService.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/editablemapping/EditableMappingService.scala @@ -103,7 +103,7 @@ class EditableMappingService @Inject()( val defaultSegmentToAgglomerateChunkSize: Int = 64 * 1024 // max. 1 MiB chunks (two 8-byte numbers per element) - val binaryDataService = new BinaryDataService(Paths.get(""), None, None, None, None, None) + val binaryDataService = new BinaryDataService(Paths.get(""), None, None, None, None) adHocMeshServiceHolder.tracingStoreAdHocMeshConfig = (binaryDataService, 30 seconds, 1) private val adHocMeshService: AdHocMeshService = adHocMeshServiceHolder.tracingStoreAdHocMeshService diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingService.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingService.scala index 653205736c2..a688e210e70 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingService.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingService.scala @@ -69,7 +69,7 @@ class VolumeTracingService @Inject()( /* We want to reuse the bucket loading methods from binaryDataService for the volume tracings, however, it does not actually load anything from disk, unlike its “normal” instance in the datastore (only from the volume tracing store) */ - private val binaryDataService = new BinaryDataService(Paths.get(""), None, None, None, None, None) + private val binaryDataService = new BinaryDataService(Paths.get(""), None, None, None, None) adHocMeshServiceHolder.tracingStoreAdHocMeshConfig = (binaryDataService, 30 seconds, 1) val adHocMeshService: AdHocMeshService = adHocMeshServiceHolder.tracingStoreAdHocMeshService From 6d45d081ab4a1e9cfb0683fa755de6ef186c6506 Mon Sep 17 00:00:00 2001 From: frcroth Date: Mon, 24 Mar 2025 17:26:15 +0100 Subject: [PATCH 10/17] Throw exceptions when write permission is not given --- .../datastore/helpers/DatasetDeleter.scala | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala index cdad6632a6c..5d501b297d7 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala @@ -145,6 +145,10 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { private def updateMagSymlinks(targetMagPath: Path, linkedMag: DataSourceMagInfo): Unit = { val linkedMagPath = getMagPath(dataBaseDir, linkedMag) + // Before deleting, check write permissions at linkedMagPath + if (!Files.isWritable(linkedMagPath.getParent)) { + throw new Exception(s"Cannot update symlink at $linkedMagPath, no write permissions!") + } if (Files.exists(linkedMagPath) || Files.isSymbolicLink(linkedMagPath)) { Files.delete(linkedMagPath) logger.info(s"Deleting symlink and recreating it at $linkedMagPath") @@ -177,6 +181,12 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { .resolve(moveToDataSource.organizationId) .resolve(moveToDataSource.directoryName) .resolve(moveToDataLayer) + + // Before deleting, check write permissions at targetPath + if (!Files.isWritable(targetPath.getParent)) { + throw new Exception(s"Cannot move layer $sourceLayer to $targetPath, no write permissions!") + } + logger.info( s"Found complete symlinks to layer; Moving layer $sourceLayer from $sourceDataSource to $moveToDataSource/$moveToDataLayer") if (Files.exists(targetPath) && Files.isSymbolicLink(targetPath)) { @@ -190,6 +200,10 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { fullLayerLinks.tail.foreach { linkedLayer => val linkedLayerPath = dataBaseDir.resolve(linkedLayer._1.organizationId).resolve(linkedLayer._1.directoryName).resolve(linkedLayer._2) + // Before deleting, check write permissions at linkedLayerPath + if (!Files.isWritable(linkedLayerPath.getParent)) { + throw new Exception(s"Cannot move layer $sourceLayer to $targetPath, no write permissions!") + } if (Files.exists(linkedLayerPath) || Files.isSymbolicLink(linkedLayerPath)) { // Two cases exist here: 1. The layer is a regular directory where each mag is a symlink // 2. The layer is a symlink to the other layer itself. @@ -248,6 +262,12 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { // Select an arbitrary linked mag to move to val target = magLinkInfo.linkedMags.head val targetPath = getMagPath(dataBaseDir, target) + + // Before deleting, check write permissions at targetPath + if (!Files.isWritable(targetPath.getParent)) { + throw new Exception(s"Cannot move mag $magToDelete to $targetPath, no write permissions!") + } + if (Files.exists(targetPath) && Files.isSymbolicLink(targetPath)) { logger.info( s"Deleting existing symlink at $targetPath linking to ${magToDelete.dataSourceId}/${magToDelete.dataLayerName}/${magToDelete.mag From 8d14be3ef327daf6af42dfd889c4f4cb58b05499 Mon Sep 17 00:00:00 2001 From: frcroth Date: Wed, 26 Mar 2025 10:29:22 +0100 Subject: [PATCH 11/17] Handle deletion of fully linked layer with all mags seperately linked --- .../scala/com/scalableminds/util/mvc/Formatter.scala | 9 ++++++++- .../webknossos/datastore/helpers/DatasetDeleter.scala | 8 +++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/util/src/main/scala/com/scalableminds/util/mvc/Formatter.scala b/util/src/main/scala/com/scalableminds/util/mvc/Formatter.scala index b223bff0e1e..5b4cbcc5446 100644 --- a/util/src/main/scala/com/scalableminds/util/mvc/Formatter.scala +++ b/util/src/main/scala/com/scalableminds/util/mvc/Formatter.scala @@ -92,6 +92,12 @@ trait Formatter { case _ => "" } + def firstException(failure: Failure): String = + failure.exception match { + case Full(exception) => exception.toString + ": " + case _ => "" + } + def formatNextChain(chainBox: Box[Failure]): String = chainBox match { case Full(chainFailure) => " <~ " + formatFailureChain(chainFailure, includeStackTraces, includeTime = false, messagesProviderOpt) @@ -108,7 +114,8 @@ trait Formatter { } val serverTimeMsg = if (includeTime) s"[Server Time ${Instant.now}] " else "" - serverTimeMsg + formatOneFailure(failure) + formatStackTrace(failure) + formatNextChain(failure.chain) + serverTimeMsg + firstException(failure) + formatOneFailure(failure) + formatStackTrace(failure) + formatNextChain( + failure.chain) } } diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala index 5d501b297d7..23e3240e147 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala @@ -10,7 +10,8 @@ import com.scalableminds.webknossos.datastore.services.DSRemoteWebknossosClient import com.scalableminds.webknossos.datastore.storage.DataVaultService import com.typesafe.scalalogging.LazyLogging import net.liftweb.common.Box.tryo -import net.liftweb.common.{Box, EmptyBox, Full} +import net.liftweb.common.{Box, Full} +import org.apache.commons.io.FileUtils import java.io.File import java.nio.file.{Files, Path} @@ -192,6 +193,11 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { if (Files.exists(targetPath) && Files.isSymbolicLink(targetPath)) { Files.delete(targetPath) } + if (Files.exists(targetPath) && Files.isDirectory(targetPath)) { + // This happens when the fully linked layer consists of mag symlinks. The directory exists and is full of symlinked mags. + // We need to delete the directory before moving the layer. + FileUtils.deleteDirectory(targetPath.toFile) + } Files.move(layerPath, targetPath) // All symlinks are now broken, we need to recreate them From d026b65acfb10c5b45df0a4efe92567cfbdd6640 Mon Sep 17 00:00:00 2001 From: frcroth Date: Wed, 26 Mar 2025 10:56:06 +0100 Subject: [PATCH 12/17] fix format --- app/models/dataset/DatasetService.scala | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/models/dataset/DatasetService.scala b/app/models/dataset/DatasetService.scala index 62933748f71..c0563a7406d 100644 --- a/app/models/dataset/DatasetService.scala +++ b/app/models/dataset/DatasetService.scala @@ -358,12 +358,12 @@ class DatasetService @Inject()(organizationDAO: OrganizationDAO, } yield () /** - * Returns a list of tuples, where the first element is the magInfo and the second element is a list of all magInfos - * that share the same realPath but have a different dataSourceId. For each mag in the data layer there is one tuple. - * @param datasetId id of the dataset - * @param layerName name of the layer in the dataset - * @return - */ + * Returns a list of tuples, where the first element is the magInfo and the second element is a list of all magInfos + * that share the same realPath but have a different dataSourceId. For each mag in the data layer there is one tuple. + * @param datasetId id of the dataset + * @param layerName name of the layer in the dataset + * @return + */ def getPathsForDataLayer(datasetId: ObjectId, layerName: String): Fox[List[(DataSourceMagInfo, List[DataSourceMagInfo])]] = for { From f6c5123e0a3e5abc662582d4d22b5b805b0b0858 Mon Sep 17 00:00:00 2001 From: Florian M Date: Mon, 7 Apr 2025 11:46:35 +0200 Subject: [PATCH 13/17] improve error msg on realpath scan --- .../webknossos/datastore/services/DataSourceService.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/DataSourceService.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/DataSourceService.scala index d7f843ac411..c5bcb77a266 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/DataSourceService.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/DataSourceService.scala @@ -100,10 +100,10 @@ class DataSourceService @Inject()( pathInfos = magPathBoxes.map { case (ds, Full(magPaths)) => DataSourcePathInfo(ds.id, magPaths) case (ds, failure: Failure) => - logger.error(formatFailureChain(failure)) + logger.error(s"Failed to determine real paths of mags of ${ds.id}: ${formatFailureChain(failure)}") DataSourcePathInfo(ds.id, List()) case (ds, Empty) => - logger.error(s"Failed to determine real paths for mags of $ds") + logger.error(s"Failed to determine real paths for mags of ${ds.id}") DataSourcePathInfo(ds.id, List()) } _ <- remoteWebknossosClient.reportRealPaths(pathInfos) From 30f0d0faf0bbb194b6659b8c07483fd6abbc3a2c Mon Sep 17 00:00:00 2001 From: frcroth Date: Wed, 9 Apr 2025 10:22:52 +0200 Subject: [PATCH 14/17] Apply suggestions from code review --- .../scalableminds/util/mvc/Formatter.scala | 17 ++-- .../datastore/helpers/DatasetDeleter.scala | 95 ++++++++++--------- 2 files changed, 55 insertions(+), 57 deletions(-) diff --git a/util/src/main/scala/com/scalableminds/util/mvc/Formatter.scala b/util/src/main/scala/com/scalableminds/util/mvc/Formatter.scala index 5b4cbcc5446..f88891d7d5f 100644 --- a/util/src/main/scala/com/scalableminds/util/mvc/Formatter.scala +++ b/util/src/main/scala/com/scalableminds/util/mvc/Formatter.scala @@ -88,14 +88,12 @@ trait Formatter { def formatStackTrace(failure: Failure) = failure.exception match { - case Full(exception) if includeStackTraces => s" Stack trace: ${TextUtils.stackTraceAsString(exception)} " - case _ => "" - } - - def firstException(failure: Failure): String = - failure.exception match { - case Full(exception) => exception.toString + ": " - case _ => "" + case Full(exception) => + if (includeStackTraces) + s" Stack trace: ${TextUtils.stackTraceAsString(exception)} " + else + exception.toString + case _ => "" } def formatNextChain(chainBox: Box[Failure]): String = chainBox match { @@ -114,8 +112,7 @@ trait Formatter { } val serverTimeMsg = if (includeTime) s"[Server Time ${Instant.now}] " else "" - serverTimeMsg + firstException(failure) + formatOneFailure(failure) + formatStackTrace(failure) + formatNextChain( - failure.chain) + serverTimeMsg + formatOneFailure(failure) + formatStackTrace(failure) + formatNextChain(failure.chain) } } diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala index 23e3240e147..ee69187ab02 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala @@ -85,15 +85,15 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { _ <- updateDatasourceProperties(affectedDataSources) } yield () - private def getFullyLinkedLayers(linkedMags: List[MagLinkInfo]): Option[Seq[(DataSourceId, String)]] = { + private def getFullyLinkedLayers(linkedMags: List[MagLinkInfo]): Seq[(DataSourceId, String)] = { val allMagsLocal = linkedMags.forall(_.mag.hasLocalData) val allLinkedDatasetLayers = linkedMags.map(_.linkedMags.map(lm => (lm.dataSourceId, lm.dataLayerName))) // Get combinations of datasourceId, layerName that link to EVERY mag val linkedToByAllMags = allLinkedDatasetLayers.reduce((a, b) => a.intersect(b)) if (allMagsLocal && linkedToByAllMags.nonEmpty) { - Some(linkedToByAllMags) + linkedToByAllMags } else { - None + Seq() } } @@ -173,6 +173,12 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { // Move layer on disk val layerPath = dataBaseDir.resolve(sourceDataSource.organizationId).resolve(sourceDataSource.directoryName).resolve(sourceLayer) + + if (fullLayerLinks.isEmpty) { + throw new IllegalArgumentException( + s"Cannot move layer $sourceLayer from $sourceDataSource, no fully linked layers provided!") + } + // Select one of the fully linked layers as target to move layer to // Selection of the first one is arbitrary, is there anything to distinguish between them? val target = fullLayerLinks.head @@ -249,56 +255,51 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { layerName: String, linkedMags: List[MagLinkInfo]): Box[Unit] = tryo { - val fullyLinkedLayersOpt = getFullyLinkedLayers(linkedMags) - fullyLinkedLayersOpt match { - case Some(fullLayerLinks) => - moveLayer(dataSourceId, layerName, fullLayerLinks, linkedMags) - case None => - logger.info(s"Found incomplete symlinks to layer; Moving mags from $dataSourceId to other datasets") - linkedMags.foreach { magLinkInfo => - val magToDelete = magLinkInfo.mag - if (magLinkInfo.linkedMags.nonEmpty) { - if (magToDelete.hasLocalData) { - // Move mag to a different dataset - val magPath = dataBaseDir - .resolve(dataSourceId.organizationId) - .resolve(dataSourceId.directoryName) - .resolve(layerName) - .resolve(magToDelete.mag.toMagLiteral(true)) - // Select an arbitrary linked mag to move to - val target = magLinkInfo.linkedMags.head - val targetPath = getMagPath(dataBaseDir, target) + val fullyLinkedLayers = getFullyLinkedLayers(linkedMags) + if (fullyLinkedLayers.nonEmpty) { + moveLayer(dataSourceId, layerName, fullyLinkedLayers, linkedMags) + } else { + logger.info(s"Found incomplete symlinks to layer; Moving mags from $dataSourceId to other datasets") + linkedMags.foreach { magLinkInfo => + val magToDelete = magLinkInfo.mag + if (magLinkInfo.linkedMags.nonEmpty) { + if (magToDelete.hasLocalData) { + // Move mag to a different dataset + val magPath = getMagPath(dataBaseDir, magToDelete) + // Select an arbitrary linked mag to move to + val target = magLinkInfo.linkedMags.head + val targetPath = getMagPath(dataBaseDir, target) - // Before deleting, check write permissions at targetPath - if (!Files.isWritable(targetPath.getParent)) { - throw new Exception(s"Cannot move mag $magToDelete to $targetPath, no write permissions!") - } + // Before deleting, check write permissions at targetPath + if (!Files.isWritable(targetPath.getParent)) { + throw new Exception(s"Cannot move mag $magToDelete to $targetPath, no write permissions!") + } - if (Files.exists(targetPath) && Files.isSymbolicLink(targetPath)) { - logger.info( - s"Deleting existing symlink at $targetPath linking to ${magToDelete.dataSourceId}/${magToDelete.dataLayerName}/${magToDelete.mag - .toMagLiteral(true)}") - Files.delete(targetPath) - } - Files.move(magPath, targetPath) + if (Files.exists(targetPath) && Files.isSymbolicLink(targetPath)) { + logger.info( + s"Deleting existing symlink at $targetPath linking to ${magToDelete.dataSourceId}/${magToDelete.dataLayerName}/${magToDelete.mag + .toMagLiteral(true)}") + Files.delete(targetPath) + } + Files.move(magPath, targetPath) - // Move all symlinks to this mag to link to the moved mag - magLinkInfo.linkedMags.tail.foreach { linkedMag => - updateMagSymlinks(targetPath, linkedMag) - } - } else { - // The mag has no local data but there are links to it... - // Mags without local data are either - // 1. remote and thus they have no mags that can be linked to (but also we do not need to delete anything more here) - // 2. are links themselves to other mags. In this case, there can't be any links to this here since they - // would be resolved to the other mag. - // 3. locally explored datasets. They don't have layer directories that could have symlinks to them, so - // this is also not a problem. - // So this should not happen. - logger.warn(s"Trying to move mag $magToDelete, but it has no local data!") + // Move all symlinks to this mag to link to the moved mag + magLinkInfo.linkedMags.tail.foreach { linkedMag => + updateMagSymlinks(targetPath, linkedMag) } + } else { + // The mag has no local data but there are links to it... + // Mags without local data are either + // 1. remote and thus they have no mags that can be linked to (but also we do not need to delete anything more here) + // 2. are links themselves to other mags. In this case, there can't be any links to this here since they + // would be resolved to the other mag. + // 3. locally explored datasets. They don't have layer directories that could have symlinks to them, so + // this is also not a problem. + // So this should not happen. + logger.warn(s"Trying to move mag $magToDelete, but it has no local data!") } } + } } } } From 14f8a82965acb236e37f18270b857811d8cb090d Mon Sep 17 00:00:00 2001 From: frcroth Date: Wed, 9 Apr 2025 13:47:50 +0200 Subject: [PATCH 15/17] Handle non-compact mag paths --- .../datastore/helpers/DatasetDeleter.scala | 76 ++++++++++++------- 1 file changed, 47 insertions(+), 29 deletions(-) diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala index ee69187ab02..adb56bf1f99 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala @@ -51,7 +51,7 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { new File(trashPath.toString).mkdirs() logger.info( - s"Deleting dataset by moving it from $dataSourcePath to $targetPath${reason.map(r => s"because $r").getOrElse("...")}") + s"Deleting dataset by moving it from $dataSourcePath to $targetPath ${reason.map(r => s"because $r").getOrElse("...")}") deleteWithRetry(dataSourcePath, targetPath) } else { Fox.successful(logger.info( @@ -103,12 +103,14 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { relativeTargetPath } - private def getMagPath(basePath: Path, magInfo: DataSourceMagInfo): Path = - basePath + private def getPossibleMagPaths(basePath: Path, magInfo: DataSourceMagInfo): List[Path] = { + val layerPath = basePath .resolve(magInfo.dataSourceId.organizationId) .resolve(magInfo.dataSourceId.directoryName) .resolve(magInfo.dataLayerName) - .resolve(magInfo.mag.toMagLiteral(true)) + List(layerPath.resolve(magInfo.mag.toMagLiteral(allowScalar = true)), + layerPath.resolve(magInfo.mag.toMagLiteral(allowScalar = false))) + } private def updateDatasourceProperties(dataSourceIds: List[DataSourceId])( implicit ec: ExecutionContext): Fox[List[Unit]] = @@ -145,24 +147,28 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { }) private def updateMagSymlinks(targetMagPath: Path, linkedMag: DataSourceMagInfo): Unit = { - val linkedMagPath = getMagPath(dataBaseDir, linkedMag) + val linkedMagPaths = getPossibleMagPaths(dataBaseDir, linkedMag) // Before deleting, check write permissions at linkedMagPath - if (!Files.isWritable(linkedMagPath.getParent)) { - throw new Exception(s"Cannot update symlink at $linkedMagPath, no write permissions!") + if (!Files.isWritable(linkedMagPaths.head.getParent)) { + throw new Exception(s"Cannot update symlink at ${linkedMagPaths.head}, no write permissions!") } - if (Files.exists(linkedMagPath) || Files.isSymbolicLink(linkedMagPath)) { - Files.delete(linkedMagPath) - logger.info(s"Deleting symlink and recreating it at $linkedMagPath") - Files.createSymbolicLink(linkedMagPath, relativizeSymlinkPath(targetMagPath, linkedMagPath)) - } else { - if (!Files.exists(linkedMagPath) && linkedMag.path == linkedMag.realPath) { - // This is the case for locally explored datasets - // Since locally explored datasets are always fully linked layers when explored, this case can - // only happen if one of the mags was manually edited in the properties file. + val existingLinkedMagPath = linkedMagPaths.find(p => Files.exists(p) || Files.isSymbolicLink(p)) + + existingLinkedMagPath match { + case Some(linkedMagPath) => + Files.delete(linkedMagPath) + logger.info(s"Deleting symlink and recreating it at $linkedMagPath") Files.createSymbolicLink(linkedMagPath, relativizeSymlinkPath(targetMagPath, linkedMagPath)) - } else { - logger.warn(s"Trying to recreate symlink at mag $linkedMagPath, but it does not exist!") - } + case None => + val linkedMagPath = linkedMagPaths.head + if (!Files.exists(linkedMagPath) && linkedMag.path == linkedMag.realPath) { + // This is the case for locally explored datasets + // Since locally explored datasets are always fully linked layers when explored, this case can + // only happen if one of the mags was manually edited in the properties file. + Files.createSymbolicLink(linkedMagPath, relativizeSymlinkPath(targetMagPath, linkedMagPath)) + } else { + logger.warn(s"Trying to recreate symlink at mag $linkedMagPath, but it does not exist!") + } } } @@ -241,7 +247,12 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { layerMags.foreach { magLinkInfo => val mag = magLinkInfo.mag - val newMagPath = targetPath.resolve(mag.mag.toMagLiteral(true)) + val newMagPath = + Seq(targetPath.resolve(mag.mag.toMagLiteral(true)), targetPath.resolve(mag.mag.toMagLiteral(false))) + .find(Files.exists(_)) + .getOrElse( + throw new Exception(s"Cleaning up move failed for $mag, no local data found ${targetPath.resolve(mag.mag + .toMagLiteral(true))} or ${targetPath.resolve(mag.mag.toMagLiteral(false))}, failed to create symlink!")) magLinkInfo.linkedMags .filter(linkedMag => !fullLayerLinks.contains((linkedMag.dataSourceId, linkedMag.dataLayerName))) // Filter out mags that are fully linked layers, we already handled them .foreach { linkedMag => @@ -265,22 +276,29 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { if (magLinkInfo.linkedMags.nonEmpty) { if (magToDelete.hasLocalData) { // Move mag to a different dataset - val magPath = getMagPath(dataBaseDir, magToDelete) + val magPath = getPossibleMagPaths(dataBaseDir, magToDelete).find(Files.exists(_)).getOrElse { + throw new IllegalArgumentException( + s"Cannot move mag $magToDelete, no local data found at ${magToDelete.path}!") + } // Select an arbitrary linked mag to move to val target = magLinkInfo.linkedMags.head - val targetPath = getMagPath(dataBaseDir, target) + val possibleMagTargetPaths = getPossibleMagPaths(dataBaseDir, target) // Before deleting, check write permissions at targetPath - if (!Files.isWritable(targetPath.getParent)) { - throw new Exception(s"Cannot move mag $magToDelete to $targetPath, no write permissions!") + if (!Files.isWritable(possibleMagTargetPaths.head.getParent)) { + throw new Exception( + s"Cannot move mag $magToDelete to ${possibleMagTargetPaths.head.getParent}, no write permissions!") } - if (Files.exists(targetPath) && Files.isSymbolicLink(targetPath)) { - logger.info( - s"Deleting existing symlink at $targetPath linking to ${magToDelete.dataSourceId}/${magToDelete.dataLayerName}/${magToDelete.mag - .toMagLiteral(true)}") - Files.delete(targetPath) + val targetPathExistingSymlink = possibleMagTargetPaths.find(Files.isSymbolicLink) + targetPathExistingSymlink match { + case Some(targetPath) => + logger.info( + s"Deleting existing symlink at $targetPath linking to ${Files.readSymbolicLink(targetPath)}") + Files.delete(targetPath) + case _ => () } + val targetPath = targetPathExistingSymlink.getOrElse(possibleMagTargetPaths.head) Files.move(magPath, targetPath) // Move all symlinks to this mag to link to the moved mag From 6f7e6bd3c6ade5de8641bfdd370b55ae76897679 Mon Sep 17 00:00:00 2001 From: frcroth Date: Wed, 9 Apr 2025 13:59:54 +0200 Subject: [PATCH 16/17] Prevent exception on empty reduce --- .../webknossos/datastore/helpers/DatasetDeleter.scala | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala index adb56bf1f99..aa39bfd5228 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala @@ -89,7 +89,9 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { val allMagsLocal = linkedMags.forall(_.mag.hasLocalData) val allLinkedDatasetLayers = linkedMags.map(_.linkedMags.map(lm => (lm.dataSourceId, lm.dataLayerName))) // Get combinations of datasourceId, layerName that link to EVERY mag - val linkedToByAllMags = allLinkedDatasetLayers.reduce((a, b) => a.intersect(b)) + val linkedToByAllMags = + if (allLinkedDatasetLayers.isEmpty) Seq() + else allLinkedDatasetLayers.reduce((a, b) => a.intersect(b)) if (allMagsLocal && linkedToByAllMags.nonEmpty) { linkedToByAllMags } else { From 4776358b0f3ff5194050de282476e222d3da92aa Mon Sep 17 00:00:00 2001 From: frcroth Date: Mon, 14 Apr 2025 13:35:02 +0200 Subject: [PATCH 17/17] Handle edge case of fully linked layer with symlinked mags and not moving data to that directory --- .../webknossos/datastore/helpers/DatasetDeleter.scala | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala index aa39bfd5228..815dad421ad 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/DatasetDeleter.scala @@ -228,9 +228,13 @@ trait DatasetDeleter extends LazyLogging with DirectoryConstants { // Two cases exist here: 1. The layer is a regular directory where each mag is a symlink // 2. The layer is a symlink to the other layer itself. // We can handle both by deleting the layer and creating a new symlink. - Files.delete(linkedLayerPath) + if (Files.isDirectory(linkedLayerPath)) { // Case 1 + FileUtils.deleteDirectory(linkedLayerPath.toFile) + } else { // Case 2 + Files.delete(linkedLayerPath) + } logger.info( - s"Deleting existing symlink at $linkedLayerPath linking to $sourceDataSource/$sourceLayer, creating new symlink") + s"Deleting existing symlink(s) at $linkedLayerPath linking to $sourceDataSource/$sourceLayer, creating new symlink") Files.createSymbolicLink(linkedLayerPath, relativizeSymlinkPath(targetPath, linkedLayerPath)) } else { if (!Files.exists(linkedLayerPath)) {