This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-6860-490e57f2cc384581401f56aa9eafb5cd7af03d7d in repository https://gitbox.apache.org/repos/asf/texera.git
commit 3cb3479ec9201e43ca9e803da2a2a26d59a480a7 Author: Tanishq Gandhi <[email protected]> AuthorDate: Fri Aug 14 17:27:42 2026 +0000 feat(storage): add model file storage and path resolution (#6860) ### What changes were proposed in this PR? Adds ML **models** as a first-class versioned-file resource — a sibling of datasets that reuses the existing LakeFS + object-storage engine. A model version is stored as one commit in a per-model repository (`model-{mid}`); a logical path `/models/<owner>/<name>/<version>/<file>` resolves to a stable physical URI `model:///<repositoryName>/<versionHash>/<file>` that can be opened to stream the file's bytes. Rather than duplicate the dataset path, the shared logic is **generalized** , then models are added on top: ### Any related issues, documentation, discussions? Closes #6497. Part of the "Supporting ML models" umbrella #6494. Stacked on #6495 (#6502) — the diff carries its commits until #6502 merges. ### How was this PR tested? Unit tests (`sbt "WorkflowCore/testOnly *FileResolverSpec *DocumentFactorySpec *DatasetFileDocumentSpec"`): model path resolution, required-prefix enforcement, dataset↔model cross-prefix isolation (both directions), and `DocumentFactory` routing the `model` scheme to a `ModelFileDocument`. Also verified end-to-end against a live Postgres: a seeded `model`/`model_version` row resolves to the expected `model:///…` URI and opens as a `ModelFileDocument`. ## Notes - The model **REST/upload API** and the `/api/model/presign-download` endpoint land in #6498. No code produces a `/models/…` path yet; the JWT/file-service read branch is inherited from the shared base and falls back to a direct LakeFS fetch, so model reads work without that endpoint today. - The remaining model tables (access control, upload sessions, likes, view count) are added in later PRs alongside the logic that uses them. - Frontend/Python `ResourceType` still expose only `Datasets`; `Models` is added there when the UI/UDF work lands. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 4.8) --------- Co-authored-by: ali <[email protected]> Co-authored-by: Claude Opus 4.8 <[email protected]> --- .../pytexera/storage/dataset_file_document.py | 4 +- .../pytexera/storage/test_dataset_file_document.py | 24 +- .../workflow-compiling-service-deployment.yaml | 2 +- ...workflow-computing-unit-manager-deployment.yaml | 2 +- bin/local-dev/main.sh | 2 +- bin/single-node/.env | 2 +- .../common/config/EnvironmentalVariable.scala | 5 +- .../amber/core/storage/DocumentFactory.scala | 7 +- .../texera/amber/core/storage/FileResolver.scala | 260 +++++++++++++++------ .../texera/amber/core/storage/ResourceType.scala | 13 +- .../core/storage/model/DatasetFileDocument.scala | 148 +----------- ...FileDocument.scala => LakeFSFileDocument.scala} | 63 ++--- .../{OnDataset.scala => ModelFileDocument.scala} | 23 +- ...Dataset.scala => OnVersionedFileResource.scala} | 5 +- .../amber/core/storage/DocumentFactorySpec.scala | 23 +- .../storage/model/DatasetFileDocumentSpec.scala | 18 +- .../texera/amber/storage/FileResolverSpec.scala | 93 +++++++- .../resource/ComputingUnitManagingResource.scala | 4 +- .../texera/service/resource/DatasetResource.scala | 11 +- sql/changelog.xml | 5 + sql/texera_ddl.sql | 33 ++- sql/updates/37.sql | 57 +++++ 22 files changed, 507 insertions(+), 297 deletions(-) diff --git a/amber/src/main/python/pytexera/storage/dataset_file_document.py b/amber/src/main/python/pytexera/storage/dataset_file_document.py index 4446da5ee5..b8d778d342 100644 --- a/amber/src/main/python/pytexera/storage/dataset_file_document.py +++ b/amber/src/main/python/pytexera/storage/dataset_file_document.py @@ -89,7 +89,9 @@ class DatasetFileDocument: raise invalid_format self.jwt_token = os.getenv("USER_JWT_TOKEN") - self.presign_endpoint = os.getenv("FILE_SERVICE_GET_PRESIGNED_URL_ENDPOINT") + self.presign_endpoint = os.getenv( + "FILE_SERVICE_GET_DATASET_PRESIGNED_URL_ENDPOINT" + ) if not self.jwt_token: raise ValueError( diff --git a/amber/src/test/python/pytexera/storage/test_dataset_file_document.py b/amber/src/test/python/pytexera/storage/test_dataset_file_document.py index 760e6234be..bef134bc74 100644 --- a/amber/src/test/python/pytexera/storage/test_dataset_file_document.py +++ b/amber/src/test/python/pytexera/storage/test_dataset_file_document.py @@ -31,7 +31,9 @@ CUSTOM_ENDPOINT = "https://example.test/api/presign" def auth_env(monkeypatch): """Provide a JWT and pinned presign endpoint for the duration of one test.""" monkeypatch.setenv("USER_JWT_TOKEN", "test-jwt-token") - monkeypatch.setenv("FILE_SERVICE_GET_PRESIGNED_URL_ENDPOINT", CUSTOM_ENDPOINT) + monkeypatch.setenv( + "FILE_SERVICE_GET_DATASET_PRESIGNED_URL_ENDPOINT", CUSTOM_ENDPOINT + ) def make_response(status_code: int, body=None, content: bytes = b""): @@ -86,7 +88,9 @@ class TestDatasetFileDocumentInit: def test_requires_jwt_token_in_environment(self, monkeypatch): monkeypatch.delenv("USER_JWT_TOKEN", raising=False) - monkeypatch.setenv("FILE_SERVICE_GET_PRESIGNED_URL_ENDPOINT", CUSTOM_ENDPOINT) + monkeypatch.setenv( + "FILE_SERVICE_GET_DATASET_PRESIGNED_URL_ENDPOINT", CUSTOM_ENDPOINT + ) with pytest.raises(ValueError, match="JWT token is required"): DatasetFileDocument("/datasets/[email protected]/ds/v1/file.csv") @@ -98,7 +102,9 @@ class TestDatasetFileDocumentInit: def test_falls_back_to_default_endpoint_when_env_missing(self, monkeypatch): monkeypatch.setenv("USER_JWT_TOKEN", "tok") - monkeypatch.delenv("FILE_SERVICE_GET_PRESIGNED_URL_ENDPOINT", raising=False) + monkeypatch.delenv( + "FILE_SERVICE_GET_DATASET_PRESIGNED_URL_ENDPOINT", raising=False + ) doc = DatasetFileDocument("/datasets/[email protected]/ds/v1/file.csv") assert doc.presign_endpoint == DEFAULT_ENDPOINT @@ -110,7 +116,9 @@ class TestDatasetFileDocumentInit: class TestGetPresignedUrl: def _make_doc(self, monkeypatch, path="/datasets/[email protected]/ds/v1/file.csv"): monkeypatch.setenv("USER_JWT_TOKEN", "test-jwt-token") - monkeypatch.setenv("FILE_SERVICE_GET_PRESIGNED_URL_ENDPOINT", CUSTOM_ENDPOINT) + monkeypatch.setenv( + "FILE_SERVICE_GET_DATASET_PRESIGNED_URL_ENDPOINT", CUSTOM_ENDPOINT + ) return DatasetFileDocument(path) def test_returns_presigned_url_field_from_json_body(self, monkeypatch): @@ -224,7 +232,9 @@ class TestGetPresignedUrl: class TestReadFile: def _make_doc(self, monkeypatch): monkeypatch.setenv("USER_JWT_TOKEN", "test-jwt-token") - monkeypatch.setenv("FILE_SERVICE_GET_PRESIGNED_URL_ENDPOINT", CUSTOM_ENDPOINT) + monkeypatch.setenv( + "FILE_SERVICE_GET_DATASET_PRESIGNED_URL_ENDPOINT", CUSTOM_ENDPOINT + ) return DatasetFileDocument("/datasets/[email protected]/ds/v1/file.csv") def test_returns_bytesio_with_downloaded_content(self, monkeypatch): @@ -278,7 +288,9 @@ class TestReadFile: class TestTimeoutsAndRetries: def _make_doc(self, monkeypatch): monkeypatch.setenv("USER_JWT_TOKEN", "test-jwt-token") - monkeypatch.setenv("FILE_SERVICE_GET_PRESIGNED_URL_ENDPOINT", CUSTOM_ENDPOINT) + monkeypatch.setenv( + "FILE_SERVICE_GET_DATASET_PRESIGNED_URL_ENDPOINT", CUSTOM_ENDPOINT + ) return DatasetFileDocument("/datasets/[email protected]/ds/v1/file.csv") def test_presigned_url_request_passes_request_timeout(self, monkeypatch): diff --git a/bin/k8s/templates/base/workflow-compiling-service/workflow-compiling-service-deployment.yaml b/bin/k8s/templates/base/workflow-compiling-service/workflow-compiling-service-deployment.yaml index 50a0a04e1b..2061e9e53e 100644 --- a/bin/k8s/templates/base/workflow-compiling-service/workflow-compiling-service-deployment.yaml +++ b/bin/k8s/templates/base/workflow-compiling-service/workflow-compiling-service-deployment.yaml @@ -40,7 +40,7 @@ spec: - containerPort: {{ .Values.workflowCompilingService.service.port }} env: # FileService Access - - name: FILE_SERVICE_GET_PRESIGNED_URL_ENDPOINT + - name: FILE_SERVICE_GET_DATASET_PRESIGNED_URL_ENDPOINT value: http://{{ .Values.fileService.name }}-svc:9092/api/dataset/presign-download # LakeFS Access - name: STORAGE_LAKEFS_ENDPOINT diff --git a/bin/k8s/templates/base/workflow-computing-unit-manager/workflow-computing-unit-manager-deployment.yaml b/bin/k8s/templates/base/workflow-computing-unit-manager/workflow-computing-unit-manager-deployment.yaml index a911845041..ea61b242d1 100644 --- a/bin/k8s/templates/base/workflow-computing-unit-manager/workflow-computing-unit-manager-deployment.yaml +++ b/bin/k8s/templates/base/workflow-computing-unit-manager/workflow-computing-unit-manager-deployment.yaml @@ -75,7 +75,7 @@ spec: name: {{ .Release.Name }}-postgresql key: postgres-password # FileService Access - - name: FILE_SERVICE_GET_PRESIGNED_URL_ENDPOINT + - name: FILE_SERVICE_GET_DATASET_PRESIGNED_URL_ENDPOINT value: http://{{ .Values.fileService.name }}-svc:9092/api/dataset/presign-download - name: FILE_SERVICE_UPLOAD_ONE_FILE_TO_DATASET_ENDPOINT value: http://{{ .Values.fileService.name }}-svc:9092/api/dataset/did/upload diff --git a/bin/local-dev/main.sh b/bin/local-dev/main.sh index 7ef7a20cb6..08e8cd2495 100755 --- a/bin/local-dev/main.sh +++ b/bin/local-dev/main.sh @@ -707,7 +707,7 @@ export STORAGE_LAKEFS_AUTH_API_SECRET="${STORAGE_LAKEFS_AUTH_API_SECRET:-random_ export TEXERA_DASHBOARD_SERVICE_ENDPOINT="${TEXERA_DASHBOARD_SERVICE_ENDPOINT:-http://localhost:8080}" export WORKFLOW_COMPILING_SERVICE_ENDPOINT="${WORKFLOW_COMPILING_SERVICE_ENDPOINT:-http://localhost:9090}" export WORKFLOW_EXECUTION_SERVICE_ENDPOINT="${WORKFLOW_EXECUTION_SERVICE_ENDPOINT:-http://localhost:8085}" -export FILE_SERVICE_GET_PRESIGNED_URL_ENDPOINT="${FILE_SERVICE_GET_PRESIGNED_URL_ENDPOINT:-http://localhost:9092/api/dataset/presign-download}" +export FILE_SERVICE_GET_DATASET_PRESIGNED_URL_ENDPOINT="${FILE_SERVICE_GET_DATASET_PRESIGNED_URL_ENDPOINT:-http://localhost:9092/api/dataset/presign-download}" export FILE_SERVICE_UPLOAD_ONE_FILE_TO_DATASET_ENDPOINT="${FILE_SERVICE_UPLOAD_ONE_FILE_TO_DATASET_ENDPOINT:-http://localhost:9092/api/dataset/did/upload}" export LITELLM_BASE_URL="${LITELLM_BASE_URL:-http://localhost:4000}" export LITELLM_MASTER_KEY="${LITELLM_MASTER_KEY:-sk-texera-internal-do-not-share}" diff --git a/bin/single-node/.env b/bin/single-node/.env index 555e14db7d..dc8877275d 100644 --- a/bin/single-node/.env +++ b/bin/single-node/.env @@ -81,7 +81,7 @@ STORAGE_ICEBERG_CATALOG_POSTGRES_USERNAME=texera STORAGE_ICEBERG_CATALOG_POSTGRES_PASSWORD=password # File service endpoints -FILE_SERVICE_GET_PRESIGNED_URL_ENDPOINT=http://file-service:9092/api/dataset/presign-download +FILE_SERVICE_GET_DATASET_PRESIGNED_URL_ENDPOINT=http://file-service:9092/api/dataset/presign-download FILE_SERVICE_UPLOAD_ONE_FILE_TO_DATASET_ENDPOINT=http://file-service:9092/api/dataset/did/upload # Toggles the texera agent panel; set false to hide it in the GUI. diff --git a/common/config/src/main/scala/org/apache/texera/common/config/EnvironmentalVariable.scala b/common/config/src/main/scala/org/apache/texera/common/config/EnvironmentalVariable.scala index a335ddeff6..b0cc3028f9 100644 --- a/common/config/src/main/scala/org/apache/texera/common/config/EnvironmentalVariable.scala +++ b/common/config/src/main/scala/org/apache/texera/common/config/EnvironmentalVariable.scala @@ -35,7 +35,10 @@ object EnvironmentalVariable { /** * FileService related endpoint */ - val ENV_FILE_SERVICE_GET_PRESIGNED_URL_ENDPOINT = "FILE_SERVICE_GET_PRESIGNED_URL_ENDPOINT" + val ENV_FILE_SERVICE_GET_DATASET_PRESIGNED_URL_ENDPOINT = + "FILE_SERVICE_GET_DATASET_PRESIGNED_URL_ENDPOINT" + val ENV_FILE_SERVICE_GET_MODEL_PRESIGNED_URL_ENDPOINT = + "FILE_SERVICE_GET_MODEL_PRESIGNED_URL_ENDPOINT" val ENV_FILE_SERVICE_UPLOAD_ONE_FILE_TO_DATASET_ENDPOINT = "FILE_SERVICE_UPLOAD_ONE_FILE_TO_DATASET_ENDPOINT" diff --git a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/DocumentFactory.scala b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/DocumentFactory.scala index 16fa5c07cb..dfc63dc6a0 100644 --- a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/DocumentFactory.scala +++ b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/DocumentFactory.scala @@ -20,7 +20,10 @@ package org.apache.texera.amber.core.storage import org.apache.texera.common.config.StorageConfig -import org.apache.texera.amber.core.storage.FileResolver.DATASET_FILE_URI_SCHEME +import org.apache.texera.amber.core.storage.FileResolver.{ + DATASET_FILE_URI_SCHEME, + MODEL_FILE_URI_SCHEME +} import org.apache.texera.amber.core.storage.VFSResourceType._ import org.apache.texera.amber.core.storage.VFSURIFactory.{VFS_FILE_URI_SCHEME, decodeURI} import org.apache.texera.amber.core.storage.model._ @@ -58,6 +61,7 @@ object DocumentFactory { def openReadonlyDocument(fileUri: URI): ReadonlyVirtualDocument[_] = { fileUri.getScheme match { case DATASET_FILE_URI_SCHEME => new DatasetFileDocument(fileUri) + case MODEL_FILE_URI_SCHEME => new ModelFileDocument(fileUri) case "file" => new ReadonlyLocalFileDocument(fileUri) case unsupportedScheme => throw new UnsupportedOperationException( @@ -181,6 +185,7 @@ object DocumentFactory { def openDocument(uri: URI): (VirtualDocument[_], Option[Schema]) = { uri.getScheme match { case DATASET_FILE_URI_SCHEME => (new DatasetFileDocument(uri), None) + case MODEL_FILE_URI_SCHEME => (new ModelFileDocument(uri), None) case VFS_FILE_URI_SCHEME => val IcebergLocation(warehouse, namespace, storageKey) = resolveIcebergLocation(uri) diff --git a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/FileResolver.scala b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/FileResolver.scala index ac7f8d71f6..1a2e4fcf2a 100644 --- a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/FileResolver.scala +++ b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/FileResolver.scala @@ -24,8 +24,15 @@ import org.apache.texera.dao.SqlServer import org.apache.texera.dao.SqlServer.withTransaction import org.apache.texera.dao.jooq.generated.tables.Dataset.DATASET import org.apache.texera.dao.jooq.generated.tables.DatasetVersion.DATASET_VERSION +import org.apache.texera.dao.jooq.generated.tables.Model.MODEL +import org.apache.texera.dao.jooq.generated.tables.ModelVersion.MODEL_VERSION import org.apache.texera.dao.jooq.generated.tables.User.USER -import org.apache.texera.dao.jooq.generated.tables.pojos.{Dataset, DatasetVersion} +import org.apache.texera.dao.jooq.generated.tables.pojos.{ + Dataset, + DatasetVersion, + Model, + ModelVersion +} import java.net.{URI, URLEncoder} import java.nio.charset.StandardCharsets @@ -34,11 +41,13 @@ import scala.jdk.CollectionConverters.IteratorHasAsScala import scala.util.{Success, Try} /** - * Unified object for resolving both VFS resources and local/dataset files. + * Unified object for resolving local files and versioned-resource (dataset / model) logical + * paths to physical URIs. */ object FileResolver { val DATASET_FILE_URI_SCHEME = "dataset" + val MODEL_FILE_URI_SCHEME = "model" /** * Resolves a given fileName to either a file on the local file system or a dataset file. @@ -51,7 +60,7 @@ object FileResolver { if (isFileResolved(fileName)) { return new URI(fileName) } - val resolvers: Seq[String => URI] = Seq(localResolveFunc, datasetResolveFunc) + val resolvers: Seq[String => URI] = Seq(localResolveFunc, versionedResourceResolveFunc) // Try each resolver function in sequence resolvers @@ -76,106 +85,203 @@ object FileResolver { } /** - * Parses a dataset logical path into its components, or None if it is not a well-formed dataset path. - * Expected format: /datasets/ownerEmail/datasetName/versionName/fileRelativePath - * The legacy unprefixed format is also accepted. + * Parses a versioned-resource file path into its components, or None if it is not well-formed. + * + * Two accepted forms: + * - Prefixed: /<prefix>/ownerEmail/resourceName/versionName/fileRelativePath (>= 5 segments) + * where the leading segment names a known [[ResourceType]] (dataset, model, …), so a single + * caller can dispatch to the right backing table. + * - Legacy unprefixed (datasets only, backward compat): /ownerEmail/datasetName/versionName/ + * fileRelativePath (>= 4 segments), resolved as a dataset. Models are new and always require + * the /models/ prefix. * - * @param fileName The file path to parse - * @return Some((ownerEmail, datasetName, versionName, fileRelativePath)) if valid, None otherwise + * @param fileName the file path to parse + * @return Some((resourceType, ownerEmail, resourceName, versionName, fileRelativePath)) if valid, + * None otherwise */ - private def parseDatasetFilePath( + private def parsePrefixedPath( fileName: String - ): Option[(String, String, String, Array[String])] = { + ): Option[(ResourceType.Value, String, String, String, Array[String])] = { val filePath = Paths.get(fileName) val pathSegments = (0 until filePath.getNameCount).map(filePath.getName(_).toString).toArray - // TODO(datasets-prefix): require the prefix once all stored paths are migrated (36.sql). - if (pathSegments.headOption.exists(ResourceType.isValidPrefix)) { - if (pathSegments.length < 5) None - else Some((pathSegments(1), pathSegments(2), pathSegments(3), pathSegments.drop(4))) - } else if (pathSegments.length >= 4) { - Some((pathSegments(0), pathSegments(1), pathSegments(2), pathSegments.drop(3))) - } else None + pathSegments.headOption.flatMap(ResourceType.fromPrefix) match { + case Some(resourceType) => + // Prefixed: /<prefix>/ownerEmail/resourceName/versionName/<file> (>= 5 segments). + if (pathSegments.length < 5) None + else + Some( + (resourceType, pathSegments(1), pathSegments(2), pathSegments(3), pathSegments.drop(4)) + ) + case None => + // Legacy unprefixed dataset path (backward compat): /ownerEmail/datasetName/versionName/<file>. + // TODO(datasets-prefix): require the prefix once all stored paths are migrated (36.sql). + if (pathSegments.length >= 4) + Some( + ( + ResourceType.Datasets, + pathSegments(0), + pathSegments(1), + pathSegments(2), + pathSegments.drop(3) + ) + ) + else None + } } /** - * Attempts to resolve a given fileName to a URI. + * Resolves a versioned-resource logical path to its physical `scheme:///` URI. * - * The fileName format should be: /datasets/ownerEmail/datasetName/versionName/fileRelativePath - * e.g. /datasets/[email protected]/twitterDataset/v1/california/irvine/tw1.csv - * The output dataset URI format is: {DATASET_FILE_URI_SCHEME}:///{repositoryName}/{versionHash}/fileRelativePath - * e.g. {DATASET_FILE_URI_SCHEME}:///dataset-15/adeq233td/some/dir/file.txt + * The leading prefix selects the resource kind; only the per-type DB lookup and URI scheme + * differ, so adding a new versioned resource is a single new `case` below plus a lookup helper. + * An unprefixed path is resolved as a legacy dataset path (see [[parsePrefixedPath]]). * - * @param fileName the name of the file to attempt resolving as a DatasetFileDocument - * @return Either[String, DatasetFileDocument] - Right(document) if creation succeeds - * @throws java.io.FileNotFoundException if the dataset file does not exist or cannot be created + * Input: /<prefix>/ownerEmail/resourceName/versionName/fileRelativePath (or legacy unprefixed) + * Output: {scheme}:///{repositoryName}/{versionHash}/fileRelativePath + * e.g. /datasets/[email protected]/twitter/v1/dir/f.csv -> dataset:///dataset-15/adeq233td/dir/f.csv + * /models/[email protected]/resnet/v1/weights/m.pt -> model:///model-15/adeq233td/weights/m.pt + * + * @throws java.io.FileNotFoundException if the path is not a valid versioned-resource path, the + * resource/version does not exist, or the URI is malformed */ - private def datasetResolveFunc(fileName: String): URI = { - val (ownerEmail, datasetName, versionName, fileRelativePathSegments) = - parseDatasetFilePath(fileName).getOrElse( - throw new FileNotFoundException(s"Dataset file $fileName not found.") + private def versionedResourceResolveFunc(fileName: String): URI = { + val (resourceType, ownerEmail, resourceName, versionName, fileRelativePathSegments) = + parsePrefixedPath(fileName).getOrElse( + throw new FileNotFoundException(s"Versioned-resource file $fileName not found.") ) + val (scheme, repositoryName, versionHash) = resourceType match { + case ResourceType.Datasets => + val (repo, hash) = lookupDataset(ownerEmail, resourceName, versionName, fileName) + (DATASET_FILE_URI_SCHEME, repo, hash) + case ResourceType.Models => + val (repo, hash) = lookupModel(ownerEmail, resourceName, versionName, fileName) + (MODEL_FILE_URI_SCHEME, repo, hash) + case other => + throw new FileNotFoundException(s"Unsupported resource type $other for file $fileName.") + } + + buildVersionedFileURI(scheme, repositoryName, versionHash, fileRelativePathSegments, fileName) + } + + /** + * Builds the physical URI {scheme}:///{repositoryName}/{versionHash}/{fileRelativePath}, + * URL-encoding each file-relative-path segment. Uses forward slash on both Linux and Windows. + */ + private def buildVersionedFileURI( + scheme: String, + repositoryName: String, + versionHash: String, + fileRelativePathSegments: Array[String], + fileName: String + ): URI = { val fileRelativePath = Paths.get(fileRelativePathSegments.head, fileRelativePathSegments.tail: _*) - // fetch the dataset and version from DB to get dataset ID and version hash - val (dataset, datasetVersion) = - withTransaction( - SqlServer - .getInstance() - .createDSLContext() - ) { ctx => - // fetch the dataset from DB - val dataset = ctx - .select(DATASET.fields: _*) - .from(DATASET) - .leftJoin(USER) - .on(USER.UID.eq(DATASET.OWNER_UID)) - .where(USER.EMAIL.eq(ownerEmail)) - .and(DATASET.NAME.eq(datasetName)) - .fetchOneInto(classOf[Dataset]) - - // fetch the dataset version from DB - val datasetVersion = ctx - .selectFrom(DATASET_VERSION) - .where(DATASET_VERSION.DID.eq(dataset.getDid)) - .and(DATASET_VERSION.NAME.eq(versionName)) - .fetchOneInto(classOf[DatasetVersion]) - - if (dataset == null || datasetVersion == null) { - throw new FileNotFoundException(s"Dataset file $fileName not found.") - } - (dataset, datasetVersion) - } - - // Convert each segment of fileRelativePath to an encoded String val encodedFileRelativePath = fileRelativePath .iterator() .asScala - .map { segment => - URLEncoder.encode(segment.toString, StandardCharsets.UTF_8) - } + .map(segment => URLEncoder.encode(segment.toString, StandardCharsets.UTF_8)) .toArray - // Prepend dataset name and versionHash to the encoded path segments - val allPathSegments = Array( - dataset.getRepositoryName, - datasetVersion.getVersionHash - ) ++ encodedFileRelativePath - - // Build the format /{repositoryName}/{versionHash}/{fileRelativePath}, both Linux and Windows use forward slash as the splitter - val uriSplitter = "/" - val encodedPath = uriSplitter + allPathSegments.mkString(uriSplitter) + val allPathSegments = Array(repositoryName, versionHash) ++ encodedFileRelativePath + val encodedPath = "/" + allPathSegments.mkString("/") try { - new URI(DATASET_FILE_URI_SCHEME, "", encodedPath, null) + new URI(scheme, "", encodedPath, null) } catch { - case e: Exception => - throw new FileNotFoundException(s"Dataset file $fileName not found.") + case _: Exception => + throw new FileNotFoundException(s"Versioned-resource file $fileName not found.") } } + /** + * Looks up a dataset + version by owner email / name / version name, returning its + * (repositoryName, versionHash). + * + * @throws java.io.FileNotFoundException if the dataset or version does not exist + */ + private def lookupDataset( + ownerEmail: String, + datasetName: String, + versionName: String, + fileName: String + ): (String, String) = + withTransaction( + SqlServer + .getInstance() + .createDSLContext() + ) { ctx => + val dataset = ctx + .select(DATASET.fields: _*) + .from(DATASET) + .leftJoin(USER) + .on(USER.UID.eq(DATASET.OWNER_UID)) + .where(USER.EMAIL.eq(ownerEmail)) + .and(DATASET.NAME.eq(datasetName)) + .fetchOneInto(classOf[Dataset]) + + // fail early if the dataset does not exist (before dereferencing it below) + if (dataset == null) { + throw new FileNotFoundException(s"Dataset file $fileName not found.") + } + + val datasetVersion = ctx + .selectFrom(DATASET_VERSION) + .where(DATASET_VERSION.DID.eq(dataset.getDid)) + .and(DATASET_VERSION.NAME.eq(versionName)) + .fetchOneInto(classOf[DatasetVersion]) + + if (datasetVersion == null) { + throw new FileNotFoundException(s"Dataset file $fileName not found.") + } + (dataset.getRepositoryName, datasetVersion.getVersionHash) + } + + /** + * Looks up a model + version by owner email / name / version name, returning its + * (repositoryName, versionHash). Mirrors [[lookupDataset]] against the model tables. + * + * @throws java.io.FileNotFoundException if the model or version does not exist + */ + private def lookupModel( + ownerEmail: String, + modelName: String, + versionName: String, + fileName: String + ): (String, String) = + withTransaction( + SqlServer + .getInstance() + .createDSLContext() + ) { ctx => + val model = ctx + .select(MODEL.fields: _*) + .from(MODEL) + .leftJoin(USER) + .on(USER.UID.eq(MODEL.OWNER_UID)) + .where(USER.EMAIL.eq(ownerEmail)) + .and(MODEL.NAME.eq(modelName)) + .fetchOneInto(classOf[Model]) + + // fail early if the model does not exist (before dereferencing it below) + if (model == null) { + throw new FileNotFoundException(s"Model file $fileName not found.") + } + + val modelVersion = ctx + .selectFrom(MODEL_VERSION) + .where(MODEL_VERSION.MID.eq(model.getMid)) + .and(MODEL_VERSION.NAME.eq(versionName)) + .fetchOneInto(classOf[ModelVersion]) + + if (modelVersion == null) { + throw new FileNotFoundException(s"Model file $fileName not found.") + } + (model.getRepositoryName, modelVersion.getVersionHash) + } + /** * Checks if a given file path has a valid scheme. * @@ -199,8 +305,8 @@ object FileResolver { if (path == null) { return None } - parseDatasetFilePath(path).map { - case (ownerEmail, datasetName, _, _) => (ownerEmail, datasetName) + parsePrefixedPath(path).collect { + case (ResourceType.Datasets, ownerEmail, datasetName, _, _) => (ownerEmail, datasetName) } } } diff --git a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/ResourceType.scala b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/ResourceType.scala index cb3db537d5..087dc6ef87 100644 --- a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/ResourceType.scala +++ b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/ResourceType.scala @@ -27,6 +27,17 @@ package org.apache.texera.amber.core.storage */ object ResourceType extends Enumeration { val Datasets: Value = Value("datasets") + val Models: Value = Value("models") - def isValidPrefix(segment: String): Boolean = values.exists(_.toString == segment) + /** + * Returns the resource type named by the given path segment, or None if it is not a known + * resource type. + */ + def fromPrefix(segment: String): Option[Value] = values.find(_.toString == segment) + + /** + * Returns true if the given path segment names a known resource type. + * Used to validate the leading prefix of a logical path. + */ + def isValidPrefix(segment: String): Boolean = fromPrefix(segment).isDefined } diff --git a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/DatasetFileDocument.scala b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/DatasetFileDocument.scala index 6d8f917c7f..f2862c87ee 100644 --- a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/DatasetFileDocument.scala +++ b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/DatasetFileDocument.scala @@ -19,155 +19,31 @@ package org.apache.texera.amber.core.storage.model -import com.typesafe.scalalogging.LazyLogging import org.apache.texera.common.config.EnvironmentalVariable -import org.apache.texera.amber.core.storage.model.DatasetFileDocument.{ - fileServiceGetPresignURLEndpoint, - userJwtToken -} -import org.apache.texera.amber.core.storage.util.LakeFSStorageClient +import org.apache.texera.amber.core.storage.model.DatasetFileDocument.fileServiceGetPresignURLEndpoint import org.apache.texera.amber.core.storage.util.dataset.GitVersionControlLocalFileStorage -import java.io.{File, FileOutputStream, InputStream} -import java.net._ -import java.nio.charset.StandardCharsets -import java.nio.file.{Files, Path, Paths} -import scala.jdk.CollectionConverters.IteratorHasAsScala +import java.net.URI +import java.nio.file.Path object DatasetFileDocument { - // Since requests need to be sent to the FileService in order to read the file, we store USER_JWT_TOKEN in the environment vars - // This variable should be NON-EMPTY in the dynamic-computing-unit architecture, i.e. each user-created computing unit should store user's jwt token. - // In the local development or other architectures, this token can be empty. - lazy val userJwtToken: String = - sys.env.getOrElse(EnvironmentalVariable.ENV_USER_JWT_TOKEN, "").trim - // The endpoint of getting presigned url from the file service, also stored in the environment vars. lazy val fileServiceGetPresignURLEndpoint: String = sys.env .getOrElse( - EnvironmentalVariable.ENV_FILE_SERVICE_GET_PRESIGNED_URL_ENDPOINT, + EnvironmentalVariable.ENV_FILE_SERVICE_GET_DATASET_PRESIGNED_URL_ENDPOINT, "http://localhost:9092/api/dataset/presign-download" ) .trim } private[storage] class DatasetFileDocument(uri: URI) - extends VirtualDocument[Nothing] - with OnDataset - with LazyLogging { - // Utility function to parse and decode URI segments into individual components - private def parseUri(uri: URI): (String, String, Path) = { - val segments = Paths.get(uri.getPath).iterator().asScala.map(_.toString).toArray - if (segments.length < 3) - throw new IllegalArgumentException("URI format is incorrect") - - // parse uri to dataset components - val repositoryName = segments(0) - val datasetVersionHash = URLDecoder.decode(segments(1), StandardCharsets.UTF_8) - val decodedRelativeSegments = - segments.drop(2).map(part => URLDecoder.decode(part, StandardCharsets.UTF_8)) - val fileRelativePath = Paths.get(decodedRelativeSegments.head, decodedRelativeSegments.tail: _*) - - (repositoryName, datasetVersionHash, fileRelativePath) - } - - // Extract components from URI using the utility function - private val (repositoryName, datasetVersionHash, fileRelativePath) = parseUri(uri) - - private var tempFile: Option[File] = None - - override def getURI: URI = uri - - override def asInputStream(): InputStream = { - - def fallbackToLakeFS(exception: Throwable): InputStream = { - logger.warn(s"${exception.getMessage}. Falling back to LakeFS direct file fetch.", exception) - val file = LakeFSStorageClient.getFileFromRepo( - getRepositoryName(), - getVersionHash(), - getFileRelativePath() - ) - Files.newInputStream(file.toPath) - } - - if (userJwtToken.isEmpty) { - try { - val presignUrl = LakeFSStorageClient.getFilePresignedUrl( - getRepositoryName(), - getVersionHash(), - getFileRelativePath() - ) - new URL(presignUrl).openStream() - } catch { - case e: Exception => - fallbackToLakeFS(e) - } - } else { - val presignRequestUrl = - s"$fileServiceGetPresignURLEndpoint?repositoryName=${getRepositoryName()}&commitHash=${getVersionHash()}&filePath=${URLEncoder - .encode(getFileRelativePath(), StandardCharsets.UTF_8.name())}" - - val connection = new URL(presignRequestUrl).openConnection().asInstanceOf[HttpURLConnection] - connection.setRequestMethod("GET") - connection.setRequestProperty("Authorization", s"Bearer $userJwtToken") - - try { - if (connection.getResponseCode != HttpURLConnection.HTTP_OK) { - throw new RuntimeException( - s"Failed to retrieve presigned URL: HTTP ${connection.getResponseCode}" - ) - } - - // Read response body as a string - val responseBody = - new String(connection.getInputStream.readAllBytes(), StandardCharsets.UTF_8) - - // Extract presigned URL from JSON response - val presignedUrl = responseBody - .split("\"presignedUrl\"\\s*:\\s*\"")(1) - .split("\"")(0) - - new URL(presignedUrl).openStream() - } catch { - case e: Exception => - fallbackToLakeFS(e) - } finally { - connection.disconnect() - } - } - } - - override def asFile(): File = { - tempFile match { - case Some(file) => file - case None => - val tempFilePath = Files.createTempFile("versionedFile", ".tmp") - val tempFileStream = new FileOutputStream(tempFilePath.toFile) - val inputStream = asInputStream() - - val buffer = new Array[Byte](1024) - - // Create an iterator to repeatedly call inputStream.read, and direct buffered data to file - Iterator - .continually(inputStream.read(buffer)) - .takeWhile(_ != -1) - .foreach(tempFileStream.write(buffer, 0, _)) - - inputStream.close() - tempFileStream.close() - - val file = tempFilePath.toFile - tempFile = Some(file) - file - } - } + extends LakeFSFileDocument(uri, fileServiceGetPresignURLEndpoint) { override def clear(): Unit = { - // first remove the temporary file - tempFile match { - case Some(file) => Files.delete(file.toPath) - case None => // Do nothing - } + // first remove the temporary file (handled by the shared base) + super.clear() + lazy val datasetsRootPath = Path .of(sys.env.getOrElse("TEXERA_HOME", ".")) @@ -179,16 +55,10 @@ private[storage] class DatasetFileDocument(uri: URI) datasetsRootPath.resolve(did.toString) } - // then remove the dataset file + // then remove the dataset file from the local git-backed storage GitVersionControlLocalFileStorage.removeFileFromRepo( getDatasetPath(0), getDatasetPath(0).resolve(fileRelativePath) ) } - - override def getRepositoryName(): String = repositoryName - - override def getVersionHash(): String = datasetVersionHash - - override def getFileRelativePath(): String = fileRelativePath.toString } diff --git a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/DatasetFileDocument.scala b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/LakeFSFileDocument.scala similarity index 76% copy from common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/DatasetFileDocument.scala copy to common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/LakeFSFileDocument.scala index 6d8f917c7f..044437889e 100644 --- a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/DatasetFileDocument.scala +++ b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/LakeFSFileDocument.scala @@ -21,12 +21,8 @@ package org.apache.texera.amber.core.storage.model import com.typesafe.scalalogging.LazyLogging import org.apache.texera.common.config.EnvironmentalVariable -import org.apache.texera.amber.core.storage.model.DatasetFileDocument.{ - fileServiceGetPresignURLEndpoint, - userJwtToken -} +import org.apache.texera.amber.core.storage.model.LakeFSFileDocument.userJwtToken import org.apache.texera.amber.core.storage.util.LakeFSStorageClient -import org.apache.texera.amber.core.storage.util.dataset.GitVersionControlLocalFileStorage import java.io.{File, FileOutputStream, InputStream} import java.net._ @@ -34,26 +30,26 @@ import java.nio.charset.StandardCharsets import java.nio.file.{Files, Path, Paths} import scala.jdk.CollectionConverters.IteratorHasAsScala -object DatasetFileDocument { +object LakeFSFileDocument { // Since requests need to be sent to the FileService in order to read the file, we store USER_JWT_TOKEN in the environment vars // This variable should be NON-EMPTY in the dynamic-computing-unit architecture, i.e. each user-created computing unit should store user's jwt token. // In the local development or other architectures, this token can be empty. lazy val userJwtToken: String = sys.env.getOrElse(EnvironmentalVariable.ENV_USER_JWT_TOKEN, "").trim - - // The endpoint of getting presigned url from the file service, also stored in the environment vars. - lazy val fileServiceGetPresignURLEndpoint: String = - sys.env - .getOrElse( - EnvironmentalVariable.ENV_FILE_SERVICE_GET_PRESIGNED_URL_ENDPOINT, - "http://localhost:9092/api/dataset/presign-download" - ) - .trim } -private[storage] class DatasetFileDocument(uri: URI) +/** + * A read-only document over a single file stored in a LakeFS repository, addressed by the URI + * {scheme}:///{repositoryName}/{versionHash}/{fileRelativePath}. This is the shared behavior + * for every versioned-file resource (datasets, models, …): the file bytes are fetched via a + * presigned URL, falling back to a direct LakeFS fetch. + * + * @param uri the resolved {scheme}:///{repositoryName}/{versionHash}/{file} URI + * @param presignEndpoint the file-service presign-download endpoint for this resource kind + */ +private[storage] abstract class LakeFSFileDocument(uri: URI, presignEndpoint: String) extends VirtualDocument[Nothing] - with OnDataset + with OnVersionedFileResource with LazyLogging { // Utility function to parse and decode URI segments into individual components private def parseUri(uri: URI): (String, String, Path) = { @@ -61,20 +57,20 @@ private[storage] class DatasetFileDocument(uri: URI) if (segments.length < 3) throw new IllegalArgumentException("URI format is incorrect") - // parse uri to dataset components + // parse uri to (repositoryName, versionHash, fileRelativePath) val repositoryName = segments(0) - val datasetVersionHash = URLDecoder.decode(segments(1), StandardCharsets.UTF_8) + val versionHash = URLDecoder.decode(segments(1), StandardCharsets.UTF_8) val decodedRelativeSegments = segments.drop(2).map(part => URLDecoder.decode(part, StandardCharsets.UTF_8)) val fileRelativePath = Paths.get(decodedRelativeSegments.head, decodedRelativeSegments.tail: _*) - (repositoryName, datasetVersionHash, fileRelativePath) + (repositoryName, versionHash, fileRelativePath) } // Extract components from URI using the utility function - private val (repositoryName, datasetVersionHash, fileRelativePath) = parseUri(uri) + protected val (repositoryName, versionHash, fileRelativePath) = parseUri(uri) - private var tempFile: Option[File] = None + protected var tempFile: Option[File] = None override def getURI: URI = uri @@ -104,7 +100,7 @@ private[storage] class DatasetFileDocument(uri: URI) } } else { val presignRequestUrl = - s"$fileServiceGetPresignURLEndpoint?repositoryName=${getRepositoryName()}&commitHash=${getVersionHash()}&filePath=${URLEncoder + s"$presignEndpoint?repositoryName=${getRepositoryName()}&commitHash=${getVersionHash()}&filePath=${URLEncoder .encode(getFileRelativePath(), StandardCharsets.UTF_8.name())}" val connection = new URL(presignRequestUrl).openConnection().asInstanceOf[HttpURLConnection] @@ -163,32 +159,17 @@ private[storage] class DatasetFileDocument(uri: URI) } override def clear(): Unit = { - // first remove the temporary file + // remove the temporary file, if one was materialized tempFile match { case Some(file) => Files.delete(file.toPath) case None => // Do nothing } - lazy val datasetsRootPath = - Path - .of(sys.env.getOrElse("TEXERA_HOME", ".")) - .resolve("amber") - .resolve("user-resources") - .resolve("datasets") - - def getDatasetPath(did: Integer): Path = { - datasetsRootPath.resolve(did.toString) - } - - // then remove the dataset file - GitVersionControlLocalFileStorage.removeFileFromRepo( - getDatasetPath(0), - getDatasetPath(0).resolve(fileRelativePath) - ) + tempFile = None } override def getRepositoryName(): String = repositoryName - override def getVersionHash(): String = datasetVersionHash + override def getVersionHash(): String = versionHash override def getFileRelativePath(): String = fileRelativePath.toString } diff --git a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/OnDataset.scala b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/ModelFileDocument.scala similarity index 50% copy from common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/OnDataset.scala copy to common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/ModelFileDocument.scala index 6c19ee1002..e67ec92992 100644 --- a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/OnDataset.scala +++ b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/ModelFileDocument.scala @@ -19,10 +19,25 @@ package org.apache.texera.amber.core.storage.model -trait OnDataset { - def getRepositoryName(): String +import org.apache.texera.common.config.EnvironmentalVariable +import org.apache.texera.amber.core.storage.model.ModelFileDocument.fileServiceGetModelPresignURLEndpoint - def getVersionHash(): String +import java.net.URI - def getFileRelativePath(): String +object ModelFileDocument { + // The endpoint of getting a presigned url for a model file from the file service. + lazy val fileServiceGetModelPresignURLEndpoint: String = + sys.env + .getOrElse( + EnvironmentalVariable.ENV_FILE_SERVICE_GET_MODEL_PRESIGNED_URL_ENDPOINT, + "http://localhost:9092/api/model/presign-download" + ) + .trim } + +/** + * A read-only document over a single file in a model's LakeFS repository (`model-{mid}`), + * addressed by a `model:///{repositoryName}/{versionHash}/{fileRelativePath}` URI. + */ +private[storage] class ModelFileDocument(uri: URI) + extends LakeFSFileDocument(uri, fileServiceGetModelPresignURLEndpoint) diff --git a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/OnDataset.scala b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/OnVersionedFileResource.scala similarity index 90% rename from common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/OnDataset.scala rename to common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/OnVersionedFileResource.scala index 6c19ee1002..cbb76ee4f5 100644 --- a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/OnDataset.scala +++ b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/OnVersionedFileResource.scala @@ -19,7 +19,10 @@ package org.apache.texera.amber.core.storage.model -trait OnDataset { +/** + * A document backed by a versioned file in a LakeFS repository. + */ +trait OnVersionedFileResource { def getRepositoryName(): String def getVersionHash(): String diff --git a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/DocumentFactorySpec.scala b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/DocumentFactorySpec.scala index ecb5f5c54f..eb18e87762 100644 --- a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/DocumentFactorySpec.scala +++ b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/DocumentFactorySpec.scala @@ -21,6 +21,8 @@ package org.apache.texera.amber.core.storage import org.apache.texera.amber.core.storage.model.{ DatasetFileDocument, + ModelFileDocument, + OnVersionedFileResource, ReadonlyLocalFileDocument, VirtualDocument } @@ -38,7 +40,7 @@ import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import java.net.URI -import java.nio.file.Files +import java.nio.file.{Files, Paths} import java.util.UUID /** @@ -139,6 +141,18 @@ class DocumentFactorySpec extends AnyFlatSpec with Matchers with BeforeAndAfterA doc.getURI shouldBe datasetUri } + it should "return a ModelFileDocument for the model scheme and parse its URI components" in { + val modelUri = new URI(s"model:///model-1/$versionHash/weights/model.pt") + val doc = DocumentFactory.openReadonlyDocument(modelUri) + doc shouldBe a[ModelFileDocument] + doc.getURI shouldBe modelUri + + val resource = doc.asInstanceOf[OnVersionedFileResource] + resource.getRepositoryName() shouldBe "model-1" + resource.getVersionHash() shouldBe versionHash + resource.getFileRelativePath() shouldBe Paths.get("weights", "model.pt").toString + } + it should "return a ReadonlyLocalFileDocument for the file scheme" in { val tempFile = Files.createTempFile("doc-factory-spec", ".txt") try { @@ -169,6 +183,13 @@ class DocumentFactorySpec extends AnyFlatSpec with Matchers with BeforeAndAfterA schemaOpt shouldBe None } + it should "return a ModelFileDocument and no schema for the model scheme" in { + val modelUri = new URI(s"model:///model-1/$versionHash/weights/model.pt") + val (doc, schemaOpt) = DocumentFactory.openDocument(modelUri) + doc shouldBe a[ModelFileDocument] + schemaOpt shouldBe None + } + it should "reject an unsupported scheme" in { val thrown = intercept[UnsupportedOperationException] { DocumentFactory.openDocument(new URI("ftp://host/path")) diff --git a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/model/DatasetFileDocumentSpec.scala b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/model/DatasetFileDocumentSpec.scala index c16d417a73..5a313c2763 100644 --- a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/model/DatasetFileDocumentSpec.scala +++ b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/model/DatasetFileDocumentSpec.scala @@ -142,26 +142,28 @@ class DatasetFileDocumentSpec extends AnyFlatSpec with Matchers { } } - // The companion object resolves the file-service endpoint and the user JWT - // token from environment variables, falling back to a trimmed default. These - // lazy vals are read whenever asInputStream needs to fetch a file; assert their - // fallback behavior without requiring a live FileService or LakeFS. The checks - // are guarded so they hold regardless of whether the env overrides are present. + // The companion object resolves the file-service endpoint from environment + // variables, falling back to a trimmed default. This lazy val is read whenever + // asInputStream needs to fetch a file; assert its fallback behavior without + // requiring a live FileService or LakeFS. The check is guarded so it holds + // regardless of whether the env override is present. "DatasetFileDocument companion" should "expose the default presigned-URL endpoint when the env override is absent" in { val expected = sys.env .getOrElse( - EnvironmentalVariable.ENV_FILE_SERVICE_GET_PRESIGNED_URL_ENDPOINT, + EnvironmentalVariable.ENV_FILE_SERVICE_GET_DATASET_PRESIGNED_URL_ENDPOINT, "http://localhost:9092/api/dataset/presign-download" ) .trim DatasetFileDocument.fileServiceGetPresignURLEndpoint shouldBe expected } - it should "expose a trimmed user JWT token defaulting to empty" in { + // The user JWT token is shared by every LakeFS-backed document, so it now lives on + // the LakeFSFileDocument base object rather than on DatasetFileDocument. + "LakeFSFileDocument companion" should "expose a trimmed user JWT token defaulting to empty" in { val expected = sys.env.getOrElse(EnvironmentalVariable.ENV_USER_JWT_TOKEN, "").trim - DatasetFileDocument.userJwtToken shouldBe expected + LakeFSFileDocument.userJwtToken shouldBe expected } } diff --git a/common/workflow-core/src/test/scala/org/apache/texera/amber/storage/FileResolverSpec.scala b/common/workflow-core/src/test/scala/org/apache/texera/amber/storage/FileResolverSpec.scala index e6533c7f1b..e414371222 100644 --- a/common/workflow-core/src/test/scala/org/apache/texera/amber/storage/FileResolverSpec.scala +++ b/common/workflow-core/src/test/scala/org/apache/texera/amber/storage/FileResolverSpec.scala @@ -23,8 +23,20 @@ import org.apache.texera.amber.core.storage.FileResolver import org.apache.commons.vfs2.FileNotFoundException import org.apache.texera.dao.MockTexeraDB import org.apache.texera.dao.jooq.generated.enums.UserRoleEnum -import org.apache.texera.dao.jooq.generated.tables.daos.{DatasetDao, DatasetVersionDao, UserDao} -import org.apache.texera.dao.jooq.generated.tables.pojos.{Dataset, DatasetVersion, User} +import org.apache.texera.dao.jooq.generated.tables.daos.{ + DatasetDao, + DatasetVersionDao, + ModelDao, + ModelVersionDao, + UserDao +} +import org.apache.texera.dao.jooq.generated.tables.pojos.{ + Dataset, + DatasetVersion, + Model, + ModelVersion, + User +} import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach} @@ -76,19 +88,61 @@ class FileResolverSpec datasetVersion } + private val testModel: Model = { + val model = new Model + model.setMid(Integer.valueOf(1)) + model.setName("test_model") + model.setRepositoryName("model-1") + model.setDescription("model for test") + model.setIsPublic(true) + model.setIsDownloadable(true) + model.setOwnerUid(Integer.valueOf(1)) + model + } + + private val testModelVersion1: ModelVersion = { + val modelVersion = new ModelVersion + modelVersion.setMid(Integer.valueOf(1)) + modelVersion.setName("v1") + modelVersion.setMvid(Integer.valueOf(1)) + modelVersion.setCreatorUid(Integer.valueOf(1)) + modelVersion.setVersionHash("a1b2c3d4e5f60718293a4b5c6d7e8f9001122334") + modelVersion + } + + private val testModelVersion2: ModelVersion = { + val modelVersion = new ModelVersion + modelVersion.setMid(Integer.valueOf(1)) + modelVersion.setName("v2") + modelVersion.setMvid(Integer.valueOf(2)) + modelVersion.setCreatorUid(Integer.valueOf(1)) + modelVersion.setVersionHash("998877665544332211ffeeddccbbaa0099887766") + modelVersion + } + private val localCsvFilePath = "common/workflow-core/src/test/resources/country_sales_small.csv" private val datasetACsvFilePath = "/datasets/[email protected]/test_dataset/v2/directory/a.csv" private val dataset1TxtFilePath = "/datasets/[email protected]/test_dataset/v1/1.txt" - // Legacy unprefixed form. + private val modelWeightsFilePath = "/models/[email protected]/test_model/v2/weights/model.pt" + + private val modelReadmeFilePath = "/models/[email protected]/test_model/v1/README.md" + + // Legacy unprefixed form — still resolvable as a dataset (backward compat). private val unprefixedDataset1TxtFilePath = "/[email protected]/test_dataset/v1/1.txt" - // Leading segment names no known dataset owner. + // Leading segment names no known dataset owner, so this is not resolvable. private val unknownResourceTypeFilePath = "/notAResourceType/[email protected]/test_dataset/v1/1.txt" + // A model name presented under the datasets prefix must not resolve as a dataset. + private val modelNameUnderDatasetPrefix = "/datasets/[email protected]/test_model/v1/README.md" + + // A dataset name presented under the models prefix must not resolve as a model. + private val datasetNameUnderModelPrefix = "/models/[email protected]/test_dataset/v1/1.txt" + override protected def beforeAll(): Unit = { initializeDBAndReplaceDSLContext() @@ -104,6 +158,15 @@ class FileResolverSpec val datasetVersionDao = new DatasetVersionDao(getDSLContext.configuration()) datasetVersionDao.insert(testDatasetVersion1) datasetVersionDao.insert(testDatasetVersion2) + + // add test model + val modelDao = new ModelDao(getDSLContext.configuration()) + modelDao.insert(testModel) + + // add test model versions + val modelVersionDao = new ModelVersionDao(getDSLContext.configuration()) + modelVersionDao.insert(testModelVersion1) + modelVersionDao.insert(testModelVersion2) } "FileResolver" should "resolve local file correctly" in { @@ -124,6 +187,18 @@ class FileResolverSpec ) } + "FileResolver" should "resolve model file correctly" in { + val modelWeightsUri = FileResolver.resolve(modelWeightsFilePath) + val modelReadmeUri = FileResolver.resolve(modelReadmeFilePath) + + assert( + modelWeightsUri.toString == f"${FileResolver.MODEL_FILE_URI_SCHEME}:///${testModel.getRepositoryName}/${testModelVersion2.getVersionHash}/weights/model.pt" + ) + assert( + modelReadmeUri.toString == f"${FileResolver.MODEL_FILE_URI_SCHEME}:///${testModel.getRepositoryName}/${testModelVersion1.getVersionHash}/README.md" + ) + } + "FileResolver" should "still resolve a legacy unprefixed dataset path" in { val dataset1TxtUri = FileResolver.resolve(unprefixedDataset1TxtFilePath) @@ -138,6 +213,16 @@ class FileResolverSpec } } + "FileResolver" should "keep datasets and models isolated by prefix" in { + // a real dataset name under the models prefix is not a model, and vice-versa + assertThrows[FileNotFoundException] { + FileResolver.resolve(datasetNameUnderModelPrefix) + } + assertThrows[FileNotFoundException] { + FileResolver.resolve(modelNameUnderDatasetPrefix) + } + } + "FileResolver" should "throw not found exception when a prefixed path has too few segments" in { assertThrows[FileNotFoundException] { FileResolver.resolve("/datasets/[email protected]/test_dataset") diff --git a/computing-unit-managing-service/src/main/scala/org/apache/texera/service/resource/ComputingUnitManagingResource.scala b/computing-unit-managing-service/src/main/scala/org/apache/texera/service/resource/ComputingUnitManagingResource.scala index effd9a4499..3a249d296e 100644 --- a/computing-unit-managing-service/src/main/scala/org/apache/texera/service/resource/ComputingUnitManagingResource.scala +++ b/computing-unit-managing-service/src/main/scala/org/apache/texera/service/resource/ComputingUnitManagingResource.scala @@ -109,8 +109,8 @@ object ComputingUnitManagingResource { EnvironmentalVariable.ENV_S3_REGION -> StorageConfig.s3Region, EnvironmentalVariable.ENV_S3_AUTH_USERNAME -> StorageConfig.s3Username, EnvironmentalVariable.ENV_S3_AUTH_PASSWORD -> StorageConfig.s3Password, - EnvironmentalVariable.ENV_FILE_SERVICE_GET_PRESIGNED_URL_ENDPOINT -> EnvironmentalVariable - .get(EnvironmentalVariable.ENV_FILE_SERVICE_GET_PRESIGNED_URL_ENDPOINT) + EnvironmentalVariable.ENV_FILE_SERVICE_GET_DATASET_PRESIGNED_URL_ENDPOINT -> EnvironmentalVariable + .get(EnvironmentalVariable.ENV_FILE_SERVICE_GET_DATASET_PRESIGNED_URL_ENDPOINT) .get, EnvironmentalVariable.ENV_FILE_SERVICE_UPLOAD_ONE_FILE_TO_DATASET_ENDPOINT -> EnvironmentalVariable .get(EnvironmentalVariable.ENV_FILE_SERVICE_UPLOAD_ONE_FILE_TO_DATASET_ENDPOINT) diff --git a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala index 50af933699..75391149c6 100644 --- a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala +++ b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala @@ -26,7 +26,7 @@ import jakarta.ws.rs._ import jakarta.ws.rs.core._ import org.apache.texera.common.config.StorageConfig import org.apache.texera.common.util.EmailUtil -import org.apache.texera.amber.core.storage.model.OnDataset +import org.apache.texera.amber.core.storage.model.OnVersionedFileResource import org.apache.texera.amber.core.storage.util.LakeFSStorageClient import org.apache.texera.amber.core.storage.{DocumentFactory, FileResolver, ResourceType} import org.apache.texera.auth.SessionUser @@ -1764,7 +1764,8 @@ class DatasetResource extends LazyLogging { // Case 3: Neither repositoryName nor commitHash are provided, resolve normally val response = withTransaction(context) { ctx => val fileUri = FileResolver.resolve(decodedPathStr) - val document = DocumentFactory.openReadonlyDocument(fileUri).asInstanceOf[OnDataset] + val document = + DocumentFactory.openReadonlyDocument(fileUri).asInstanceOf[OnVersionedFileResource] val datasetDao = new DatasetDao(ctx.configuration()) val datasets = datasetDao.fetchByRepositoryName(document.getRepositoryName()).asScala.toList @@ -2406,7 +2407,7 @@ class DatasetResource extends LazyLogging { logicalPath(resourceType, owner.getEmail, dataset.getName, normalized) ) ) - .asInstanceOf[OnDataset] + .asInstanceOf[OnVersionedFileResource] val fileSize = withLakeFSErrorHandling(s"reading the size of cover image '$normalized'") { LakeFSStorageClient.getFileSize( @@ -2463,7 +2464,7 @@ class DatasetResource extends LazyLogging { val document = DocumentFactory .openReadonlyDocument(FileResolver.resolve(fullPath)) - .asInstanceOf[OnDataset] + .asInstanceOf[OnVersionedFileResource] val presignedUrl = withLakeFSErrorHandling( s"generating a presigned URL for cover image '$coverImage'" @@ -2513,7 +2514,7 @@ class DatasetResource extends LazyLogging { val document = DocumentFactory .openReadonlyDocument(FileResolver.resolve(fullPath)) - .asInstanceOf[OnDataset] + .asInstanceOf[OnVersionedFileResource] val presignedUrl = withLakeFSErrorHandling( s"generating a presigned URL for cover image '$coverImage'" diff --git a/sql/changelog.xml b/sql/changelog.xml index a945b6bcb6..d3a7d55228 100644 --- a/sql/changelog.xml +++ b/sql/changelog.xml @@ -94,6 +94,11 @@ <sqlFile path="sql/updates/36.sql"/> </changeSet> + <!-- Add model metadata tables --> + <changeSet id="37" author="tanishqgandhi1908"> + <sqlFile path="sql/updates/37.sql"/> + </changeSet> + <!-- example changeSet <changeSet id="1" author="author"> <sqlFile path="sql/updates/1.sql"/> diff --git a/sql/texera_ddl.sql b/sql/texera_ddl.sql index aedfd6a848..b8ebe3caf6 100644 --- a/sql/texera_ddl.sql +++ b/sql/texera_ddl.sql @@ -62,10 +62,11 @@ DROP TABLE IF EXISTS workflow_of_project CASCADE; DROP TABLE IF EXISTS workflow_executions CASCADE; DROP TABLE IF EXISTS dataset_upload_session CASCADE; DROP TABLE IF EXISTS dataset_upload_session_part CASCADE; - DROP TABLE IF EXISTS dataset CASCADE; DROP TABLE IF EXISTS dataset_user_access CASCADE; DROP TABLE IF EXISTS dataset_version CASCADE; +DROP TABLE IF EXISTS model_version CASCADE; +DROP TABLE IF EXISTS model CASCADE; DROP TABLE IF EXISTS dataset_contributor CASCADE; DROP TABLE IF EXISTS public_project CASCADE; DROP TABLE IF EXISTS project_user_access CASCADE; @@ -418,6 +419,36 @@ CREATE TABLE IF NOT EXISTS dataset_upload_session_part ON DELETE CASCADE ); +-- ML models +CREATE TABLE IF NOT EXISTS model +( + mid SERIAL PRIMARY KEY, + owner_uid INT NOT NULL, + name VARCHAR(128) NOT NULL, + repository_name VARCHAR(128), + is_public BOOLEAN NOT NULL DEFAULT TRUE, + is_downloadable BOOLEAN NOT NULL DEFAULT TRUE, + description TEXT NOT NULL, + creation_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + cover_image varchar(255), + framework VARCHAR(32), + format VARCHAR(32), + FOREIGN KEY (owner_uid) REFERENCES "user"(uid) ON DELETE CASCADE, + UNIQUE (owner_uid, name) + ); + +-- model_version +CREATE TABLE IF NOT EXISTS model_version +( + mvid SERIAL PRIMARY KEY, + mid INT NOT NULL, + creator_uid INT NOT NULL, + name VARCHAR(128) NOT NULL, + version_hash VARCHAR(64) NOT NULL, + creation_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (mid) REFERENCES model(mid) ON DELETE CASCADE + ); + -- operator_executions (modified to match MySQL: no separate primary key; added console_messages_uri) CREATE TABLE IF NOT EXISTS operator_executions ( diff --git a/sql/updates/37.sql b/sql/updates/37.sql new file mode 100644 index 0000000000..5492387a34 --- /dev/null +++ b/sql/updates/37.sql @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +\c texera_db + +SET search_path TO texera_db; + +BEGIN; + +-- Introduce ML models as a first-class resource (own primary key `mid`, own LakeFS repo namespace) +-- and add model-specific attributes (framework, format). + +CREATE TABLE IF NOT EXISTS model +( + mid SERIAL PRIMARY KEY, + owner_uid INT NOT NULL, + name VARCHAR(128) NOT NULL, + repository_name VARCHAR(128), + is_public BOOLEAN NOT NULL DEFAULT TRUE, + is_downloadable BOOLEAN NOT NULL DEFAULT TRUE, + description TEXT NOT NULL, + creation_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + cover_image varchar(255), + framework VARCHAR(32), + format VARCHAR(32), + FOREIGN KEY (owner_uid) REFERENCES "user"(uid) ON DELETE CASCADE, + UNIQUE (owner_uid, name) +); + +CREATE TABLE IF NOT EXISTS model_version +( + mvid SERIAL PRIMARY KEY, + mid INT NOT NULL, + creator_uid INT NOT NULL, + name VARCHAR(128) NOT NULL, + version_hash VARCHAR(64) NOT NULL, + creation_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (mid) REFERENCES model(mid) ON DELETE CASCADE +); + +COMMIT;
