aicam commented on code in PR #6872:
URL: https://github.com/apache/texera/pull/6872#discussion_r3855419473
##########
file-service/src/main/scala/org/apache/texera/service/resource/ModelResource.scala:
##########
@@ -408,4 +460,341 @@ class ModelResource extends LazyLogging {
): DashboardModel = {
withTransaction(context)(ctx => getDashboardModel(ctx, mid, None))
}
+
+ //
===========================================================================
+ // Versioning
+ //
===========================================================================
+
+ @POST
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{mid}/version/create")
+ @Consumes(Array(MediaType.TEXT_PLAIN))
+ def createModelVersion(
+ versionName: String,
+ @PathParam("mid") mid: Integer,
+ @Auth user: SessionUser
+ ): DashboardModelVersion = {
+ val uid = user.getUid
+ withTransaction(context) { ctx =>
+ if (!userHasWriteAccess(ctx, mid, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
+ }
+
+ val model = getModelByID(ctx, mid)
+ val modelName = model.getName
+ val repositoryName = model.getRepositoryName
+
+ // Check if there are any changes in LakeFS before creating a new version
+ val diffs = withLakeFSErrorHandling {
+ LakeFSStorageClient.retrieveUncommittedObjects(repoName =
repositoryName)
+ }
+
+ if (diffs.isEmpty) {
+ throw new WebApplicationException(
+ "No changes detected in model. Version creation aborted.",
+ Response.Status.BAD_REQUEST
+ )
+ }
+
+ // Generate a new version name
+ val versionCount = ctx
+ .selectCount()
+ .from(MODEL_VERSION)
+ .where(MODEL_VERSION.MID.eq(mid))
+ .fetchOne(0, classOf[Int])
+
+ val sanitizedVersionName =
Option(versionName).filter(_.nonEmpty).getOrElse("")
+ val newVersionName = if (sanitizedVersionName.isEmpty) {
+ s"v${versionCount + 1}"
+ } else {
+ s"v${versionCount + 1} - $sanitizedVersionName"
Review Comment:
The version name goes straight into `model_version.name`, which is
`VARCHAR(128)`, with no length check — and the LakeFS commit at line 514 lands
*before* the insert at 536.
Reproduced on `eafd888` by posting a 200-char name:
```
POST /model/8/version/create --data 'release-candidate-...' -> 500
server log: PSQLException: value too long for type character varying(128)
lakefs commits: 7e2c221cdbd3 Created model version: v3 -
release-candidate-release-...
model_version rows for mid=8: was 2, still 2
POST /model/8/version/create --data 'retry-with-a-sane-name' -> 400 No
changes detected in model.
```
The transaction rolls back the row, nothing rolls back the commit, and the
retry now fails the `diffs.isEmpty` guard. The staged file is committed,
belongs to no version, and can never be versioned again. Validating or
truncating the name before `createCommit` avoids it.
##########
file-service/src/main/scala/org/apache/texera/service/resource/ModelResource.scala:
##########
@@ -408,4 +460,341 @@ class ModelResource extends LazyLogging {
): DashboardModel = {
withTransaction(context)(ctx => getDashboardModel(ctx, mid, None))
}
+
+ //
===========================================================================
+ // Versioning
+ //
===========================================================================
+
+ @POST
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{mid}/version/create")
+ @Consumes(Array(MediaType.TEXT_PLAIN))
+ def createModelVersion(
+ versionName: String,
+ @PathParam("mid") mid: Integer,
+ @Auth user: SessionUser
+ ): DashboardModelVersion = {
+ val uid = user.getUid
+ withTransaction(context) { ctx =>
+ if (!userHasWriteAccess(ctx, mid, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
+ }
+
+ val model = getModelByID(ctx, mid)
+ val modelName = model.getName
+ val repositoryName = model.getRepositoryName
+
+ // Check if there are any changes in LakeFS before creating a new version
+ val diffs = withLakeFSErrorHandling {
+ LakeFSStorageClient.retrieveUncommittedObjects(repoName =
repositoryName)
+ }
+
+ if (diffs.isEmpty) {
+ throw new WebApplicationException(
+ "No changes detected in model. Version creation aborted.",
+ Response.Status.BAD_REQUEST
+ )
+ }
+
+ // Generate a new version name
+ val versionCount = ctx
+ .selectCount()
+ .from(MODEL_VERSION)
+ .where(MODEL_VERSION.MID.eq(mid))
+ .fetchOne(0, classOf[Int])
+
+ val sanitizedVersionName =
Option(versionName).filter(_.nonEmpty).getOrElse("")
+ val newVersionName = if (sanitizedVersionName.isEmpty) {
+ s"v${versionCount + 1}"
+ } else {
+ s"v${versionCount + 1} - $sanitizedVersionName"
+ }
+
+ // Create a commit in LakeFS
+ val commit = withLakeFSErrorHandling {
+ LakeFSStorageClient.createCommit(
+ repoName = repositoryName,
+ branch = "main",
+ commitMessage = s"Created model version: $newVersionName"
+ )
+ }
+
+ if (commit == null || commit.getId == null) {
+ throw new WebApplicationException(
+ "Failed to create commit in LakeFS. Version creation aborted.",
+ Response.Status.INTERNAL_SERVER_ERROR
+ )
+ }
+
+ // Create a new model version entry in the database
+ val modelVersion = new ModelVersion()
+ modelVersion.setMid(mid)
+ modelVersion.setCreatorUid(uid)
+ modelVersion.setName(newVersionName)
+ modelVersion.setVersionHash(commit.getId) // Store LakeFS version hash
+
+ val insertedVersion = ctx
+ .insertInto(MODEL_VERSION)
+ .set(ctx.newRecord(MODEL_VERSION, modelVersion))
+ .returning()
+ .fetchOne()
+ .into(classOf[ModelVersion])
+
+ // Retrieve committed file structure
+ val fileNodes = withLakeFSErrorHandling {
+ LakeFSStorageClient.retrieveObjectsOfVersion(repositoryName,
commit.getId)
+ }
+
+ DashboardModelVersion(
+ insertedVersion,
+ LakeFSFileNode
+ .fromLakeFSRepositoryCommittedObjects(
+ ResourceType.Model,
+ Map((user.getEmail, modelName, newVersionName) -> fileNodes)
+ )
+ )
+ }
+ }
+
+ @GET
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{mid}/version/list")
+ def getModelVersionList(
+ @PathParam("mid") mid: Integer,
+ @Auth user: SessionUser
+ ): List[ModelVersion] = {
+ val uid = user.getUid
+ withTransaction(context)(ctx => {
+ val model = getModelByID(ctx, mid)
+ if (!userHasReadAccess(ctx, model.getMid, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
+ }
+ fetchModelVersions(ctx, model.getMid)
+ })
+ }
+
+ @GET
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{mid}/version/latest")
+ def retrieveLatestModelVersion(
+ @PathParam("mid") mid: Integer,
+ @Auth user: SessionUser
+ ): DashboardModelVersion = {
+ val uid = user.getUid
+ withTransaction(context)(ctx => {
+ if (!userHasReadAccess(ctx, mid, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
+ }
+ val model = getModelByID(ctx, mid)
+ val latestVersion = getLatestModelVersion(ctx, mid).getOrElse(
+ throw new NotFoundException(ERR_MODEL_VERSION_NOT_FOUND_MESSAGE)
+ )
+ DashboardModelVersion(latestVersion, versionRootFileNodes(ctx, mid,
latestVersion))
+ })
+ }
+
+ @GET
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{mid}/version/{mvid}/rootFileNodes")
+ def retrieveModelVersionRootFileNodes(
+ @PathParam("mid") mid: Integer,
+ @PathParam("mvid") mvid: Integer,
+ @Auth user: SessionUser
+ ): ModelVersionRootFileNodesResponse = {
+ val uid = user.getUid
+ withTransaction(context)(ctx => fetchModelVersionRootFileNodes(ctx, mid,
mvid, Some(uid)))
+ }
+
+ //
===========================================================================
+ // File upload (one-shot + session-based multipart)
+ //
===========================================================================
+
+ @POST
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{mid}/upload")
+ @Consumes(Array(MediaType.APPLICATION_OCTET_STREAM))
+ def uploadOneFileToModel(
+ @PathParam("mid") mid: Integer,
+ @QueryParam("filePath") encodedFilePath: String,
+ @QueryParam("message") message: String,
+ fileStream: InputStream,
+ @Context headers: HttpHeaders,
+ @Auth user: SessionUser
+ ): Response = {
+ ResourceUploadService.uploadOneFile(
+ ResourceStorage.Model,
+ mid,
+ encodedFilePath,
+ fileStream,
+ headers,
+ user.getUid
+ )
+ }
+
+ @DELETE
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{mid}/file")
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ def deleteModelFile(
+ @PathParam("mid") mid: Integer,
+ @QueryParam("filePath") encodedFilePath: String,
+ @Auth user: SessionUser
+ ): Response = {
+ ResourceUploadService.deleteStagedFile(
+ ResourceStorage.Model,
+ mid,
+ encodedFilePath,
+ user.getUid
+ )
+ }
+
+ @POST
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/multipart-upload")
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ def multipartUpload(
+ @QueryParam("type") operationType: String,
+ @QueryParam("ownerEmail") ownerEmail: String,
+ @QueryParam("modelName") modelName: String,
+ @QueryParam("filePath") filePath: String,
+ @QueryParam("fileSizeBytes") fileSizeBytes: Optional[java.lang.Long],
+ @QueryParam("partSizeBytes") partSizeBytes: Optional[java.lang.Long],
+ @QueryParam("restart") restart: Optional[java.lang.Boolean],
+ @Auth user: SessionUser
+ ): Response = {
+ val uid = user.getUid
+ val model: Model = getModelBy(ownerEmail, modelName)
+
+ operationType.toLowerCase match {
Review Comment:
`operationType` is an optional query param, so omitting it NPEs instead of
returning the 400 that line 675 intends:
```
POST /model/multipart-upload?ownerEmail=texera&modelName=resnet50 -> 500
java.lang.NullPointerException: Cannot invoke "String.toLowerCase()" because
"operationType" is null
```
Two small things while you're here: `getModelBy` on line 666 runs before the
type is validated, so an invalid request still costs a DB round-trip; and line
675's message lists `'init', 'finish', 'abort'` but `list` is handled on line
669 as well.
##########
file-service/src/main/scala/org/apache/texera/service/resource/ModelResource.scala:
##########
@@ -50,6 +63,9 @@ object ModelResource {
.getInstance()
.createDSLContext()
+ private def singleFileUploadMaxBytes(defaultMiB: Long = 20L): Long =
Review Comment:
This is a verbatim copy of the private helper in `ResourceUploadService`,
and nothing in this file calls it — it's the only user of the new
`SiteSettings` import. Worth noting `POST /model/{mid}/upload` enforces no size
limit at all (the multipart path enforces its own in
`initUpload`/`finishUpload`), which looks like what this was meant for. Either
wire it up or drop it with the import.
##########
file-service/src/main/scala/org/apache/texera/service/resource/ResourceUploadService.scala:
##########
@@ -139,6 +171,49 @@ object ResourceUploadService {
private def singleFileUploadMaxBytes(defaultMiB: Long = 20L): Long =
SiteSettings.getLong("single_file_upload_max_size_mib", defaultMiB) *
1024L * 1024L
+ /**
+ * Builds the file nodes of one committed version, plus the version's total
size.
+ *
+ * The tree is rooted at the resource-type prefix, so the paths it yields
resolve against
+ * the right table when they are handed back to `FileResolver`.
+ */
+ def versionRootFileNodes(
Review Comment:
Nice extraction. Since the point of it is one shared implementation, the two
inlined copies in `DatasetResource` are worth migrating in the same pass —
`fetchDatasetVersionRootFileNodes` (`DatasetResource.scala:1234`) and
`retrieveLatestDatasetVersion` (1033-1063) still build this tree by hand.
Otherwise there are three copies where the comment says there is one.
##########
file-service/src/main/scala/org/apache/texera/service/resource/ModelResource.scala:
##########
@@ -408,4 +460,341 @@ class ModelResource extends LazyLogging {
): DashboardModel = {
withTransaction(context)(ctx => getDashboardModel(ctx, mid, None))
}
+
+ //
===========================================================================
+ // Versioning
+ //
===========================================================================
+
+ @POST
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{mid}/version/create")
+ @Consumes(Array(MediaType.TEXT_PLAIN))
+ def createModelVersion(
+ versionName: String,
+ @PathParam("mid") mid: Integer,
+ @Auth user: SessionUser
+ ): DashboardModelVersion = {
+ val uid = user.getUid
+ withTransaction(context) { ctx =>
+ if (!userHasWriteAccess(ctx, mid, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
+ }
+
+ val model = getModelByID(ctx, mid)
+ val modelName = model.getName
+ val repositoryName = model.getRepositoryName
+
+ // Check if there are any changes in LakeFS before creating a new version
+ val diffs = withLakeFSErrorHandling {
+ LakeFSStorageClient.retrieveUncommittedObjects(repoName =
repositoryName)
+ }
+
+ if (diffs.isEmpty) {
+ throw new WebApplicationException(
+ "No changes detected in model. Version creation aborted.",
+ Response.Status.BAD_REQUEST
+ )
+ }
+
+ // Generate a new version name
+ val versionCount = ctx
+ .selectCount()
+ .from(MODEL_VERSION)
+ .where(MODEL_VERSION.MID.eq(mid))
+ .fetchOne(0, classOf[Int])
+
+ val sanitizedVersionName =
Option(versionName).filter(_.nonEmpty).getOrElse("")
+ val newVersionName = if (sanitizedVersionName.isEmpty) {
+ s"v${versionCount + 1}"
+ } else {
+ s"v${versionCount + 1} - $sanitizedVersionName"
+ }
+
+ // Create a commit in LakeFS
+ val commit = withLakeFSErrorHandling {
+ LakeFSStorageClient.createCommit(
+ repoName = repositoryName,
+ branch = "main",
+ commitMessage = s"Created model version: $newVersionName"
+ )
+ }
+
+ if (commit == null || commit.getId == null) {
+ throw new WebApplicationException(
+ "Failed to create commit in LakeFS. Version creation aborted.",
+ Response.Status.INTERNAL_SERVER_ERROR
+ )
+ }
+
+ // Create a new model version entry in the database
+ val modelVersion = new ModelVersion()
+ modelVersion.setMid(mid)
+ modelVersion.setCreatorUid(uid)
+ modelVersion.setName(newVersionName)
+ modelVersion.setVersionHash(commit.getId) // Store LakeFS version hash
+
+ val insertedVersion = ctx
+ .insertInto(MODEL_VERSION)
+ .set(ctx.newRecord(MODEL_VERSION, modelVersion))
+ .returning()
+ .fetchOne()
+ .into(classOf[ModelVersion])
+
+ // Retrieve committed file structure
+ val fileNodes = withLakeFSErrorHandling {
+ LakeFSStorageClient.retrieveObjectsOfVersion(repositoryName,
commit.getId)
+ }
+
+ DashboardModelVersion(
+ insertedVersion,
+ LakeFSFileNode
+ .fromLakeFSRepositoryCommittedObjects(
+ ResourceType.Model,
+ Map((user.getEmail, modelName, newVersionName) -> fileNodes)
+ )
+ )
+ }
+ }
+
+ @GET
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{mid}/version/list")
+ def getModelVersionList(
+ @PathParam("mid") mid: Integer,
+ @Auth user: SessionUser
+ ): List[ModelVersion] = {
+ val uid = user.getUid
+ withTransaction(context)(ctx => {
+ val model = getModelByID(ctx, mid)
+ if (!userHasReadAccess(ctx, model.getMid, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
+ }
+ fetchModelVersions(ctx, model.getMid)
+ })
+ }
+
+ @GET
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{mid}/version/latest")
+ def retrieveLatestModelVersion(
+ @PathParam("mid") mid: Integer,
+ @Auth user: SessionUser
+ ): DashboardModelVersion = {
+ val uid = user.getUid
+ withTransaction(context)(ctx => {
+ if (!userHasReadAccess(ctx, mid, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
+ }
+ val model = getModelByID(ctx, mid)
+ val latestVersion = getLatestModelVersion(ctx, mid).getOrElse(
+ throw new NotFoundException(ERR_MODEL_VERSION_NOT_FOUND_MESSAGE)
+ )
+ DashboardModelVersion(latestVersion, versionRootFileNodes(ctx, mid,
latestVersion))
+ })
+ }
+
+ @GET
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{mid}/version/{mvid}/rootFileNodes")
+ def retrieveModelVersionRootFileNodes(
+ @PathParam("mid") mid: Integer,
+ @PathParam("mvid") mvid: Integer,
+ @Auth user: SessionUser
+ ): ModelVersionRootFileNodesResponse = {
+ val uid = user.getUid
+ withTransaction(context)(ctx => fetchModelVersionRootFileNodes(ctx, mid,
mvid, Some(uid)))
+ }
+
+ //
===========================================================================
+ // File upload (one-shot + session-based multipart)
+ //
===========================================================================
+
+ @POST
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{mid}/upload")
+ @Consumes(Array(MediaType.APPLICATION_OCTET_STREAM))
+ def uploadOneFileToModel(
+ @PathParam("mid") mid: Integer,
+ @QueryParam("filePath") encodedFilePath: String,
+ @QueryParam("message") message: String,
+ fileStream: InputStream,
+ @Context headers: HttpHeaders,
+ @Auth user: SessionUser
+ ): Response = {
+ ResourceUploadService.uploadOneFile(
+ ResourceStorage.Model,
+ mid,
+ encodedFilePath,
+ fileStream,
+ headers,
+ user.getUid
+ )
+ }
+
+ @DELETE
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{mid}/file")
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ def deleteModelFile(
+ @PathParam("mid") mid: Integer,
+ @QueryParam("filePath") encodedFilePath: String,
+ @Auth user: SessionUser
+ ): Response = {
+ ResourceUploadService.deleteStagedFile(
+ ResourceStorage.Model,
+ mid,
+ encodedFilePath,
+ user.getUid
+ )
+ }
+
+ @POST
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/multipart-upload")
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ def multipartUpload(
+ @QueryParam("type") operationType: String,
+ @QueryParam("ownerEmail") ownerEmail: String,
+ @QueryParam("modelName") modelName: String,
+ @QueryParam("filePath") filePath: String,
+ @QueryParam("fileSizeBytes") fileSizeBytes: Optional[java.lang.Long],
+ @QueryParam("partSizeBytes") partSizeBytes: Optional[java.lang.Long],
+ @QueryParam("restart") restart: Optional[java.lang.Boolean],
+ @Auth user: SessionUser
+ ): Response = {
+ val uid = user.getUid
+ val model: Model = getModelBy(ownerEmail, modelName)
+
+ operationType.toLowerCase match {
+ case "list" => listMultipartUploads(model.getMid, uid)
+ case "init" =>
+ initMultipartUpload(model.getMid, filePath, fileSizeBytes,
partSizeBytes, restart, uid)
+ case "finish" => finishMultipartUpload(model.getMid, filePath, uid)
+ case "abort" => abortMultipartUpload(model.getMid, filePath, uid)
+ case _ =>
+ throw new BadRequestException("Invalid type parameter. Use 'init',
'finish', or 'abort'.")
+ }
+ }
+
+ @POST
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Consumes(Array(MediaType.APPLICATION_OCTET_STREAM))
+ @Path("/multipart-upload/part")
+ def uploadPart(
+ @QueryParam("ownerEmail") modelOwnerEmail: String,
+ @QueryParam("modelName") modelName: String,
+ @QueryParam("filePath") encodedFilePath: String,
+ @QueryParam("partNumber") partNumber: Int,
+ partStream: InputStream,
+ @Context headers: HttpHeaders,
+ @Auth user: SessionUser
+ ): Response = {
+ val model = getModelBy(modelOwnerEmail, modelName)
+ ResourceUploadService.uploadPart(
+ ResourceStorage.Model,
+ model.getMid,
+ user.getUid,
+ encodedFilePath,
+ partNumber,
+ partStream,
+ headers
+ )
+ }
+
+ //
===========================================================================
+ // Private helpers
+ //
===========================================================================
+
+ private def fetchModelVersions(ctx: DSLContext, mid: Integer):
List[ModelVersion] = {
+ ctx
+ .selectFrom(MODEL_VERSION)
+ .where(MODEL_VERSION.MID.eq(mid))
+ .orderBy(MODEL_VERSION.CREATION_TIME.desc())
+ .fetchInto(classOf[ModelVersion])
+ .asScala
+ .toList
+ }
+
+ /**
+ * Builds the file-tree children of a single model version, drilling into
the
+ * owner/model/version nesting produced by LakeFSFileNode.
+ */
+ private def versionRootFileNodes(
+ ctx: DSLContext,
+ mid: Integer,
+ modelVersion: ModelVersion
+ ): List[LakeFSFileNode] = {
+ val model = getModelByID(ctx, mid)
+ ResourceUploadService
+ .versionRootFileNodes(
+ ResourceType.Model,
+ getOwner(ctx, mid).getEmail,
+ model.getName,
+ modelVersion.getName,
+ model.getRepositoryName,
+ modelVersion.getVersionHash
+ )
+ ._1
+ }
+
+ private def fetchModelVersionRootFileNodes(
+ ctx: DSLContext,
+ mid: Integer,
+ mvid: Integer,
+ uid: Option[Integer]
+ ): ModelVersionRootFileNodesResponse = {
+ val model = getDashboardModel(ctx, mid, uid)
+ val modelVersion = getModelVersionByID(ctx, mvid)
Review Comment:
`getModelVersionByID(ctx, mvid)` isn't constrained to `mid`, but the access
check above ran against `mid` — so a version of another model can be resolved
through this one's repository:
```
GET /model/8/version/17/rootFileNodes (mid of resnet50, mvid of
bert-base) -> 500
server log: io.lakefs.clients.sdk.ApiException: Not Found (404) from
listObjects
```
Two things: the 500 should be a 404, and by then the other model's version
name has already been spliced into the path being built. Adding
`.and(MODEL_VERSION.MID.eq(mid))` to the lookup covers both.
##########
file-service/src/main/scala/org/apache/texera/service/resource/ModelResource.scala:
##########
@@ -408,4 +460,341 @@ class ModelResource extends LazyLogging {
): DashboardModel = {
withTransaction(context)(ctx => getDashboardModel(ctx, mid, None))
}
+
+ //
===========================================================================
+ // Versioning
+ //
===========================================================================
+
+ @POST
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{mid}/version/create")
+ @Consumes(Array(MediaType.TEXT_PLAIN))
+ def createModelVersion(
+ versionName: String,
+ @PathParam("mid") mid: Integer,
+ @Auth user: SessionUser
+ ): DashboardModelVersion = {
+ val uid = user.getUid
+ withTransaction(context) { ctx =>
+ if (!userHasWriteAccess(ctx, mid, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
+ }
+
+ val model = getModelByID(ctx, mid)
+ val modelName = model.getName
+ val repositoryName = model.getRepositoryName
+
+ // Check if there are any changes in LakeFS before creating a new version
+ val diffs = withLakeFSErrorHandling {
+ LakeFSStorageClient.retrieveUncommittedObjects(repoName =
repositoryName)
+ }
+
+ if (diffs.isEmpty) {
+ throw new WebApplicationException(
+ "No changes detected in model. Version creation aborted.",
+ Response.Status.BAD_REQUEST
+ )
+ }
+
+ // Generate a new version name
+ val versionCount = ctx
+ .selectCount()
+ .from(MODEL_VERSION)
+ .where(MODEL_VERSION.MID.eq(mid))
+ .fetchOne(0, classOf[Int])
+
+ val sanitizedVersionName =
Option(versionName).filter(_.nonEmpty).getOrElse("")
+ val newVersionName = if (sanitizedVersionName.isEmpty) {
+ s"v${versionCount + 1}"
+ } else {
+ s"v${versionCount + 1} - $sanitizedVersionName"
+ }
+
+ // Create a commit in LakeFS
+ val commit = withLakeFSErrorHandling {
+ LakeFSStorageClient.createCommit(
+ repoName = repositoryName,
+ branch = "main",
+ commitMessage = s"Created model version: $newVersionName"
+ )
+ }
+
+ if (commit == null || commit.getId == null) {
+ throw new WebApplicationException(
+ "Failed to create commit in LakeFS. Version creation aborted.",
+ Response.Status.INTERNAL_SERVER_ERROR
+ )
+ }
+
+ // Create a new model version entry in the database
+ val modelVersion = new ModelVersion()
+ modelVersion.setMid(mid)
+ modelVersion.setCreatorUid(uid)
+ modelVersion.setName(newVersionName)
+ modelVersion.setVersionHash(commit.getId) // Store LakeFS version hash
+
+ val insertedVersion = ctx
+ .insertInto(MODEL_VERSION)
+ .set(ctx.newRecord(MODEL_VERSION, modelVersion))
+ .returning()
+ .fetchOne()
+ .into(classOf[ModelVersion])
+
+ // Retrieve committed file structure
+ val fileNodes = withLakeFSErrorHandling {
+ LakeFSStorageClient.retrieveObjectsOfVersion(repositoryName,
commit.getId)
+ }
+
+ DashboardModelVersion(
+ insertedVersion,
+ LakeFSFileNode
+ .fromLakeFSRepositoryCommittedObjects(
+ ResourceType.Model,
+ Map((user.getEmail, modelName, newVersionName) -> fileNodes)
+ )
+ )
+ }
+ }
+
+ @GET
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{mid}/version/list")
+ def getModelVersionList(
+ @PathParam("mid") mid: Integer,
+ @Auth user: SessionUser
+ ): List[ModelVersion] = {
+ val uid = user.getUid
+ withTransaction(context)(ctx => {
+ val model = getModelByID(ctx, mid)
+ if (!userHasReadAccess(ctx, model.getMid, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
+ }
+ fetchModelVersions(ctx, model.getMid)
+ })
+ }
+
+ @GET
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{mid}/version/latest")
+ def retrieveLatestModelVersion(
+ @PathParam("mid") mid: Integer,
+ @Auth user: SessionUser
+ ): DashboardModelVersion = {
+ val uid = user.getUid
+ withTransaction(context)(ctx => {
+ if (!userHasReadAccess(ctx, mid, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
+ }
+ val model = getModelByID(ctx, mid)
Review Comment:
`model` is never used — `versionRootFileNodes` on the next line re-fetches
the model and the owner itself, so this is three redundant queries per call. It
can't serve as an existence check either, since `userHasReadAccess` above
already returns false for a nonexistent `mid`.
##########
file-service/src/main/scala/org/apache/texera/service/resource/ModelResource.scala:
##########
@@ -408,4 +460,341 @@ class ModelResource extends LazyLogging {
): DashboardModel = {
withTransaction(context)(ctx => getDashboardModel(ctx, mid, None))
}
+
+ //
===========================================================================
+ // Versioning
+ //
===========================================================================
+
+ @POST
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{mid}/version/create")
+ @Consumes(Array(MediaType.TEXT_PLAIN))
+ def createModelVersion(
+ versionName: String,
+ @PathParam("mid") mid: Integer,
+ @Auth user: SessionUser
+ ): DashboardModelVersion = {
+ val uid = user.getUid
+ withTransaction(context) { ctx =>
+ if (!userHasWriteAccess(ctx, mid, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
+ }
+
+ val model = getModelByID(ctx, mid)
+ val modelName = model.getName
+ val repositoryName = model.getRepositoryName
+
+ // Check if there are any changes in LakeFS before creating a new version
+ val diffs = withLakeFSErrorHandling {
+ LakeFSStorageClient.retrieveUncommittedObjects(repoName =
repositoryName)
+ }
+
+ if (diffs.isEmpty) {
+ throw new WebApplicationException(
+ "No changes detected in model. Version creation aborted.",
+ Response.Status.BAD_REQUEST
+ )
+ }
+
+ // Generate a new version name
+ val versionCount = ctx
Review Comment:
Nit / defensive: the name comes from a `count(*)` with no lock on the model
row, and `model_version` has no `UNIQUE (mid, name)`. Two creates interleaving
between this read and the insert would both produce `v2`, and
`FileResolver.lookupModel` does `MODEL_VERSION.NAME.eq(versionName) …
fetchOneInto`, which would then throw `TooManyRowsException` for every file
lookup in that model.
I could not trigger it — 8 rounds of 3 concurrent creates produced no
duplicate, because LakeFS rejects the second empty commit and serialises them
in practice. So this is theoretical, but a `UNIQUE (mid, name)` in `41.sql`
would close it for free.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]