diff --git a/common/config/src/main/resources/default.conf b/common/config/src/main/resources/default.conf
index 6b83f50f6b3..f46fbf0a185 100644
--- a/common/config/src/main/resources/default.conf
+++ b/common/config/src/main/resources/default.conf
@@ -64,6 +64,10 @@ gui {
datasets_enabled = true
datasets_enabled = ${?GUI_TABS_DATASETS_ENABLED}
+ # Hides the Models sidebar entry, not the route.
+ models_enabled = false
+ models_enabled = ${?GUI_TABS_MODELS_ENABLED}
+
compute_enabled = true
compute_enabled = ${?GUI_TABS_COMPUTE_ENABLED}
@@ -80,24 +84,41 @@ gui {
}
dataset {
- single_file_upload_max_size_mib = 20
- single_file_upload_max_size_mib = ${?DATASET_SINGLE_FILE_UPLOAD_MAX_SIZE_MIB}
+ dataset_single_file_upload_max_size_mib = 20
+ dataset_single_file_upload_max_size_mib = ${?DATASET_SINGLE_FILE_UPLOAD_MAX_SIZE_MIB}
+
+ dataset_max_number_of_concurrent_uploading_file = 3
+ dataset_max_number_of_concurrent_uploading_file = ${?DATASET_MAX_NUMBER_OF_CONCURRENT_UPLOADING_FILE}
+
+ # The maximum number of file chunks that can be held in the memory
+ dataset_max_number_of_concurrent_uploading_file_chunks = 10
+ dataset_max_number_of_concurrent_uploading_file_chunks = ${?DATASET_MAX_NUMBER_OF_CONCURRENT_UPLOADING_FILE_CHUNKS}
+
+ # the size of each chunk during the multipart upload of file
+ dataset_multipart_upload_chunk_size_mib = 50
+ dataset_multipart_upload_chunk_size_mib = ${?DATASET_MULTIPART_UPLOAD_CHUNK_SIZE_MIB}
+}
+
+model {
+ model_single_file_upload_max_size_mib = 2048
+ model_single_file_upload_max_size_mib = ${?MODEL_SINGLE_FILE_UPLOAD_MAX_SIZE_MIB}
- max_number_of_concurrent_uploading_file = 3
- max_number_of_concurrent_uploading_file = ${?MAX_NUMBER_OF_CONCURRENT_UPLOADING_FILE}
+ model_max_number_of_concurrent_uploading_file = 3
+ model_max_number_of_concurrent_uploading_file = ${?MODEL_MAX_NUMBER_OF_CONCURRENT_UPLOADING_FILE}
# The maximum number of file chunks that can be held in the memory
- max_number_of_concurrent_uploading_file_chunks = 10
- max_number_of_concurrent_uploading_file_chunks = ${?DATASET_MAX_NUMBER_OF_CONCURRENT_UPLOADING_FILE_CHUNKS}
+ model_max_number_of_concurrent_uploading_file_chunks = 10
+ model_max_number_of_concurrent_uploading_file_chunks = ${?MODEL_MAX_NUMBER_OF_CONCURRENT_UPLOADING_FILE_CHUNKS}
# the size of each chunk during the multipart upload of file
- multipart_upload_chunk_size_mib = 50
- multipart_upload_chunk_size_mib = ${?DATASET_MULTIPART_UPLOAD_CHUNK_SIZE_MIB}
+ model_multipart_upload_chunk_size_mib = 50
+ model_multipart_upload_chunk_size_mib = ${?MODEL_MULTIPART_UPLOAD_CHUNK_SIZE_MIB}
}
# Operator-level defaults. These are management-only site settings (edited from
-# the admin page, read by the engine) and are deliberately NOT under gui/dataset,
-# so they stay out of the anonymous /config/settings/public whitelist.
+# the admin page, read by the engine) and are deliberately NOT under
+# gui/dataset/model, so they stay out of the anonymous /config/settings/public
+# whitelist.
operator {
# Upper bound on the number of columns the CSV scan source will parse.
csv_parser_max_columns = 512
diff --git a/common/config/src/test/scala/org/apache/texera/common/config/DefaultsConfigSpec.scala b/common/config/src/test/scala/org/apache/texera/common/config/DefaultsConfigSpec.scala
index e7bf3fb6ab4..e675ebcda2d 100644
--- a/common/config/src/test/scala/org/apache/texera/common/config/DefaultsConfigSpec.scala
+++ b/common/config/src/test/scala/org/apache/texera/common/config/DefaultsConfigSpec.scala
@@ -43,9 +43,14 @@ class DefaultsConfigSpec extends AnyFlatSpec with Matchers {
defaults should not be empty
// scalar leaves are flattened to their last path segment
ifUnset("DATASET_SINGLE_FILE_UPLOAD_MAX_SIZE_MIB")(
- defaults.get("single_file_upload_max_size_mib") shouldBe Some("20")
+ defaults.get("dataset_single_file_upload_max_size_mib") shouldBe Some("20")
)
ifUnset("GUI_TABS_HUB_ENABLED")(defaults.get("hub_enabled") shouldBe Some("true"))
+ // the model block's leaves are `model_`-prefixed to keep short keys unique
+ ifUnset("MODEL_SINGLE_FILE_UPLOAD_MAX_SIZE_MIB")(
+ defaults.get("model_single_file_upload_max_size_mib") shouldBe Some("2048")
+ )
+ ifUnset("GUI_TABS_MODELS_ENABLED")(defaults.get("models_enabled") shouldBe Some("false"))
// management-only keys are flattened too (used by reset + the startup seeder)
ifUnset("OPERATOR_CSV_PARSER_MAX_COLUMNS")(
defaults.get("csv_parser_max_columns") shouldBe Some("512")
@@ -54,13 +59,13 @@ class DefaultsConfigSpec extends AnyFlatSpec with Matchers {
defaults.values.foreach(_ shouldBe a[String])
}
- it should "keep management-only keys out of the public gui/dataset whitelist" in {
+ it should "keep management-only keys out of the public gui/dataset/model whitelist" in {
// csv_parser_max_columns is seeded and resettable (present in allDefaults)
// but lives under `operator`, so it must never reach the anonymous
// /config/settings/public payload.
DefaultsConfig.allDefaults.keySet should contain("csv_parser_max_columns")
DefaultsConfig.keysUnderSections(
- Set("gui", "dataset")
+ Set("gui", "dataset", "model")
) should not contain "csv_parser_max_columns"
}
@@ -68,17 +73,24 @@ class DefaultsConfigSpec extends AnyFlatSpec with Matchers {
val guiKeys = DefaultsConfig.keysUnderSections(Set("gui"))
guiKeys should contain allOf ("logo", "mini_logo", "favicon", "hub_enabled")
// keys from other sections are excluded
- guiKeys should not contain "single_file_upload_max_size_mib"
+ guiKeys should not contain "dataset_single_file_upload_max_size_mib"
guiKeys should not contain "always-reset-configurations-to-default-values"
val datasetKeys = DefaultsConfig.keysUnderSections(Set("dataset"))
- datasetKeys should contain("single_file_upload_max_size_mib")
+ datasetKeys should contain("dataset_single_file_upload_max_size_mib")
datasetKeys should not contain "logo"
+
+ // `model` is a sibling section, not a sub-section of `dataset`: disjoint sets
+ // are what let a model carry a different ceiling.
+ val modelKeys = DefaultsConfig.keysUnderSections(Set("model"))
+ modelKeys should contain("model_single_file_upload_max_size_mib")
+ modelKeys should not contain "dataset_single_file_upload_max_size_mib"
+ datasetKeys should not contain "model_single_file_upload_max_size_mib"
}
it should "union multiple sections and be empty for an unknown section" in {
- val union = DefaultsConfig.keysUnderSections(Set("gui", "dataset"))
- union should contain allOf ("logo", "single_file_upload_max_size_mib")
+ val union = DefaultsConfig.keysUnderSections(Set("gui", "dataset", "model"))
+ union should contain allOf ("logo", "dataset_single_file_upload_max_size_mib", "model_single_file_upload_max_size_mib")
// every returned key exists in allDefaults under the same short name
union.subsetOf(DefaultsConfig.allDefaults.keySet) shouldBe true
diff --git a/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala b/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala
index 18a35edc0a6..23c1e7fa688 100644
--- a/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala
+++ b/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala
@@ -117,21 +117,21 @@ class ConfigResource {
)
// The site_settings keys that non-admin pages consume: dashboard branding,
- // sidebar tab toggles, and dataset upload limits — exactly the gui.* and
- // dataset.* sections of default.conf, which is also where the seeding
- // pipeline gets them. Keys declared outside those sections (e.g.
+ // sidebar tab toggles, and the dataset/model upload limits — exactly the
+ // gui.*, dataset.* and model.* sections of default.conf, which is also where
+ // the seeding pipeline gets them. Keys declared outside those sections (e.g.
// csv_parser_max_columns) are management-only. Deriving the set from the
// file keeps "which section does this default live in" the single place
// where visibility is decided.
private val publicSettingKeys: Set[String] =
- DefaultsConfig.keysUnderSections(Set("gui", "dataset"))
+ DefaultsConfig.keysUnderSections(Set("gui", "dataset", "model"))
// SECURITY: every key returned here is served anonymously (see
// /settings/public below), so `publicSettingKeys` is the anonymous-exposure
- // surface. It is derived from the gui/dataset sections of default.conf and
- // pinned by ConfigResourceSpec/DefaultsConfigSpec — adding a key under those
- // sections (or moving one in) changes what unauthenticated callers can read
- // and MUST be reviewed there. Never place a secret under gui/dataset.
+ // surface. It is derived from the gui/dataset/model sections of default.conf
+ // and pinned by ConfigResourceSpec/DefaultsConfigSpec — adding a key under
+ // those sections (or moving one in) changes what unauthenticated callers can
+ // read and MUST be reviewed there. Never place a secret under those sections.
private def fetchSettings(condition: Condition): Map[String, String] =
ctx
diff --git a/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceSpec.scala b/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceSpec.scala
index 295de41c563..6c569db80d9 100644
--- a/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceSpec.scala
+++ b/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceSpec.scala
@@ -401,11 +401,11 @@ class ConfigResourceSpec
publicSettings should not contain key("csv_parser_max_columns")
}
- // The public whitelist is derived from the gui/dataset sections of
+ // The public whitelist is derived from the gui/dataset/model sections of
// default.conf. This pins the derived set, so moving a key between sections
// (or adding one) forces the visibility decision into review here.
- it should "expose exactly the gui and dataset section keys of default.conf" in {
- DefaultsConfig.keysUnderSections(Set("gui", "dataset")) shouldBe Set(
+ it should "expose exactly the gui, dataset and model section keys of default.conf" in {
+ DefaultsConfig.keysUnderSections(Set("gui", "dataset", "model")) shouldBe Set(
"logo",
"mini_logo",
"favicon",
@@ -417,14 +417,19 @@ class ConfigResourceSpec
"projects_enabled",
"workflows_enabled",
"datasets_enabled",
+ "models_enabled",
"compute_enabled",
"quota_enabled",
"forum_enabled",
"about_enabled",
- "single_file_upload_max_size_mib",
- "multipart_upload_chunk_size_mib",
- "max_number_of_concurrent_uploading_file",
- "max_number_of_concurrent_uploading_file_chunks"
+ "dataset_single_file_upload_max_size_mib",
+ "dataset_multipart_upload_chunk_size_mib",
+ "dataset_max_number_of_concurrent_uploading_file",
+ "dataset_max_number_of_concurrent_uploading_file_chunks",
+ "model_single_file_upload_max_size_mib",
+ "model_multipart_upload_chunk_size_mib",
+ "model_max_number_of_concurrent_uploading_file",
+ "model_max_number_of_concurrent_uploading_file_chunks"
)
}
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 6c15196a0b1..76eb3ebd742 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
@@ -29,7 +29,6 @@ import org.apache.texera.common.util.EmailUtil
import org.apache.texera.amber.core.storage.util.LakeFSStorageClient
import org.apache.texera.amber.core.storage.ResourceType
import org.apache.texera.auth.SessionUser
-import org.apache.texera.dao.SiteSettings
import org.apache.texera.dao.SqlServer
import org.apache.texera.dao.SqlServer.withTransaction
import org.apache.texera.dao.jooq.generated.enums.{PrivilegeEnum, UserRoleEnum}
@@ -72,9 +71,6 @@ object DatasetResource {
.getInstance()
.createDSLContext()
- private def singleFileUploadMaxBytes(defaultMiB: Long = 20L): Long =
- SiteSettings.getLong("single_file_upload_max_size_mib", defaultMiB) * 1024L * 1024L
-
/**
* Helper function to get the dataset from DB using did
*/
diff --git a/file-service/src/main/scala/org/apache/texera/service/resource/ModelResource.scala b/file-service/src/main/scala/org/apache/texera/service/resource/ModelResource.scala
index 1ef4fddfc74..54cf7cc5ce8 100644
--- a/file-service/src/main/scala/org/apache/texera/service/resource/ModelResource.scala
+++ b/file-service/src/main/scala/org/apache/texera/service/resource/ModelResource.scala
@@ -28,7 +28,7 @@ import org.apache.texera.amber.core.storage.ResourceType
import org.apache.texera.amber.core.storage.util.LakeFSStorageClient
import org.apache.texera.auth.SessionUser
import org.apache.texera.common.config.StorageConfig
-import org.apache.texera.dao.{SiteSettings, SqlServer}
+import org.apache.texera.dao.SqlServer
import org.apache.texera.dao.SqlServer.withTransaction
import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum
import org.apache.texera.dao.jooq.generated.tables.Model.MODEL
@@ -107,9 +107,6 @@ object ModelResource {
.getInstance()
.createDSLContext()
- private def singleFileUploadMaxBytes(defaultMiB: Long = 20L): Long =
- SiteSettings.getLong("single_file_upload_max_size_mib", defaultMiB) * 1024L * 1024L
-
/**
* Helper function to get the model from DB using mid
*/
diff --git a/file-service/src/main/scala/org/apache/texera/service/resource/ResourceUploadService.scala b/file-service/src/main/scala/org/apache/texera/service/resource/ResourceUploadService.scala
index b974cf31a81..f95a0f92f4e 100644
--- a/file-service/src/main/scala/org/apache/texera/service/resource/ResourceUploadService.scala
+++ b/file-service/src/main/scala/org/apache/texera/service/resource/ResourceUploadService.scala
@@ -26,7 +26,7 @@ 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}
import org.apache.texera.common.config.StorageConfig
-import org.apache.texera.dao.{SiteSettings, SqlServer}
+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.Model.MODEL
@@ -74,11 +74,11 @@ import scala.util.Try
* Describes where a resource's files live and how its in-progress uploads are tracked.
*
* [[ResourceTables]] names the columns that carry identity, ownership and grants; this adds
- * the storage side — the LakeFS repository column plus the `*_upload_session` and
- * `*_upload_session_part` tables that back resumable multipart uploads. Naming the columns
- * keeps one implementation of the upload engine serving every resource type, so adding the
- * next one costs a descriptor rather than another copy of the locking, part-size and
- * resume rules.
+ * the storage side — the LakeFS repository column, the [[UploadLimits]] this type is held to,
+ * and the `*_upload_session` and `*_upload_session_part` tables that back resumable multipart
+ * uploads. Naming them keeps one implementation of the upload engine serving every resource
+ * type, so adding the next one costs a descriptor rather than another copy of the locking,
+ * part-size and resume rules.
*
* @tparam R record type of the resource table
* @tparam A record type of the companion user-access table
@@ -88,6 +88,7 @@ import scala.util.Try
case class ResourceStorage[R <: Record, A <: Record, S <: Record, P <: Record](
resource: ResourceTables[R, A],
resourceType: ResourceType.Value,
+ uploadLimits: UploadLimits,
repositoryNameField: TableField[R, String],
sessionResourceId: TableField[S, Integer],
sessionUid: TableField[S, Integer],
@@ -117,6 +118,7 @@ object ResourceStorage {
ResourceStorage(
resource = ResourceTables.Dataset,
resourceType = ResourceType.Dataset,
+ uploadLimits = UploadLimits.Dataset,
repositoryNameField = DATASET.REPOSITORY_NAME,
sessionResourceId = DATASET_UPLOAD_SESSION.DID,
sessionUid = DATASET_UPLOAD_SESSION.UID,
@@ -141,6 +143,7 @@ object ResourceStorage {
ResourceStorage(
resource = ResourceTables.Model,
resourceType = ResourceType.Model,
+ uploadLimits = UploadLimits.Model,
repositoryNameField = MODEL.REPOSITORY_NAME,
sessionResourceId = MODEL_UPLOAD_SESSION.MID,
sessionUid = MODEL_UPLOAD_SESSION.UID,
@@ -172,9 +175,6 @@ object ResourceUploadService {
.getInstance()
.createDSLContext()
- 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.
*
@@ -714,7 +714,7 @@ object ResourceUploadService {
if (fileSizeBytesValue <= 0L) throw new BadRequestException("fileSizeBytes must be > 0")
if (partSizeBytesValue <= 0L) throw new BadRequestException("partSizeBytes must be > 0")
- val totalMaxBytes: Long = singleFileUploadMaxBytes()
+ val totalMaxBytes: Long = s.uploadLimits.singleFileUploadMaxBytes
if (totalMaxBytes <= 0L) {
throw new WebApplicationException(
"singleFileUploadMaxBytes must be > 0",
@@ -1105,7 +1105,7 @@ object ResourceUploadService {
)
}
- val maxBytes = singleFileUploadMaxBytes()
+ val maxBytes = s.uploadLimits.singleFileUploadMaxBytes
val tooLarge = actualSizeBytes > maxBytes
if (tooLarge) {
diff --git a/file-service/src/main/scala/org/apache/texera/service/resource/UploadLimits.scala b/file-service/src/main/scala/org/apache/texera/service/resource/UploadLimits.scala
new file mode 100644
index 00000000000..6c5fa093409
--- /dev/null
+++ b/file-service/src/main/scala/org/apache/texera/service/resource/UploadLimits.scala
@@ -0,0 +1,74 @@
+/*
+ * 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.
+ */
+
+package org.apache.texera.service.resource
+
+import org.apache.texera.dao.SiteSettings
+
+/** An admin-editable limit: its `site_settings` key, and the fallback when the row is missing
+ * (must match the leaf's default in `default.conf`).
+ */
+case class UploadLimit(key: String, defaultValue: Long)
+
+/**
+ * The upload ceilings of one resource type.
+ *
+ * `site_settings` rows are keyed by a `default.conf` leaf's last path segment, so each type
+ * needs its own key names. Only the size ceiling is enforced server-side; the chunk-size and
+ * concurrency limits are client-side tuning served from `/config/settings/public`.
+ */
+case class UploadLimits(
+ singleFileMaxSizeMiB: UploadLimit,
+ multipartChunkSizeMiB: UploadLimit,
+ maxConcurrentFiles: UploadLimit,
+ maxConcurrentFileChunks: UploadLimit
+) {
+
+ /** The largest single file this resource type accepts, in bytes. */
+ def singleFileUploadMaxBytes: Long =
+ SiteSettings.getLong(
+ singleFileMaxSizeMiB.key,
+ singleFileMaxSizeMiB.defaultValue
+ ) * 1024L * 1024L
+
+ def all: Seq[UploadLimit] =
+ Seq(singleFileMaxSizeMiB, multipartChunkSizeMiB, maxConcurrentFiles, maxConcurrentFileChunks)
+}
+
+object UploadLimits {
+
+ val Dataset: UploadLimits =
+ UploadLimits(
+ singleFileMaxSizeMiB = UploadLimit("dataset_single_file_upload_max_size_mib", 20L),
+ multipartChunkSizeMiB = UploadLimit("dataset_multipart_upload_chunk_size_mib", 50L),
+ maxConcurrentFiles = UploadLimit("dataset_max_number_of_concurrent_uploading_file", 3L),
+ maxConcurrentFileChunks =
+ UploadLimit("dataset_max_number_of_concurrent_uploading_file_chunks", 10L)
+ )
+
+ // Model weights are far larger than the files datasets are sized for: 2 GiB, not 20 MiB.
+ val Model: UploadLimits =
+ UploadLimits(
+ singleFileMaxSizeMiB = UploadLimit("model_single_file_upload_max_size_mib", 2048L),
+ multipartChunkSizeMiB = UploadLimit("model_multipart_upload_chunk_size_mib", 50L),
+ maxConcurrentFiles = UploadLimit("model_max_number_of_concurrent_uploading_file", 3L),
+ maxConcurrentFileChunks =
+ UploadLimit("model_max_number_of_concurrent_uploading_file_chunks", 10L)
+ )
+}
diff --git a/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala b/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala
index e0931570fc2..b728b748c5c 100644
--- a/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala
+++ b/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala
@@ -1654,7 +1654,7 @@ class DatasetResourceSpec
s"$prefix/${System.nanoTime()}-${Random.alphanumeric.take(8).mkString}.bin"
// ---------- site_settings helpers (max upload size) ----------
- private val MaxUploadKey = "single_file_upload_max_size_mib"
+ private val MaxUploadKey = "dataset_single_file_upload_max_size_mib"
private def upsertSiteSetting(key: String, value: String): Unit = {
val table = DSL.table(DSL.name("texera_db", "site_settings"))
diff --git a/file-service/src/test/scala/org/apache/texera/service/resource/UploadLimitsSpec.scala b/file-service/src/test/scala/org/apache/texera/service/resource/UploadLimitsSpec.scala
new file mode 100644
index 00000000000..09e9c7a0e9f
--- /dev/null
+++ b/file-service/src/test/scala/org/apache/texera/service/resource/UploadLimitsSpec.scala
@@ -0,0 +1,87 @@
+/*
+ * 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.
+ */
+
+package org.apache.texera.service.resource
+
+import org.apache.texera.common.config.DefaultsConfig
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+/**
+ * Unit tests for [[UploadLimits]] and its wiring into [[ResourceStorage]]. Pure: `SiteSettings`
+ * falls back to the caller's default when `SqlServer` is uninitialised, so no DB harness is
+ * needed. These pin each descriptor's keys against `default.conf` and keep the two types'
+ * key sets disjoint — sharing one is how model uploads came to be capped at 20 MiB.
+ */
+class UploadLimitsSpec extends AnyFlatSpec with Matchers {
+
+ // Each leaf carries a ${?ENV} override, which would legitimately move the default.
+ private def ifUnset(name: String)(assertion: => Any): Unit =
+ if (!sys.env.contains(name) && !sys.props.contains(name)) assertion
+
+ private def defaultOf(key: String): Option[String] = DefaultsConfig.allDefaults.get(key)
+
+ /** Every limit must name a default.conf leaf AND carry that leaf's value: a descriptor
+ * default that has drifted from the file is what the fallback silently serves whenever
+ * the site_settings row is missing. Each leaf's env override is its key uppercased.
+ */
+ private def assertDeclaredDefaults(limits: UploadLimits): Unit =
+ limits.all.foreach { limit =>
+ withClue(s"${limit.key}: ") {
+ defaultOf(limit.key) shouldBe defined
+ ifUnset(limit.key.toUpperCase)(
+ defaultOf(limit.key) shouldBe Some(limit.defaultValue.toString)
+ )
+ }
+ }
+
+ "UploadLimits.Dataset" should "name keys that default.conf declares, with matching defaults" in {
+ assertDeclaredDefaults(UploadLimits.Dataset)
+ UploadLimits.Dataset.singleFileMaxSizeMiB.defaultValue shouldBe 20L
+ }
+
+ "UploadLimits.Model" should "name keys that default.conf declares, with matching defaults" in {
+ assertDeclaredDefaults(UploadLimits.Model)
+ UploadLimits.Model.singleFileMaxSizeMiB.defaultValue shouldBe 2048L
+ }
+
+ it should "not share a single key with the dataset limits" in {
+ val datasetKeys = UploadLimits.Dataset.all.map(_.key).toSet
+ val modelKeys = UploadLimits.Model.all.map(_.key).toSet
+ modelKeys should have size 4
+ datasetKeys intersect modelKeys shouldBe empty
+ }
+
+ "singleFileUploadMaxBytes" should "convert the declared MiB default to bytes when no row exists" in {
+ ifUnset("DATASET_SINGLE_FILE_UPLOAD_MAX_SIZE_MIB")(
+ UploadLimits.Dataset.singleFileUploadMaxBytes shouldBe 20L * 1024L * 1024L
+ )
+ // The regression: models used to resolve the dataset key and land on 20 MiB.
+ ifUnset("MODEL_SINGLE_FILE_UPLOAD_MAX_SIZE_MIB") {
+ UploadLimits.Model.singleFileUploadMaxBytes shouldBe 2048L * 1024L * 1024L
+ UploadLimits.Model.singleFileUploadMaxBytes should be >
+ UploadLimits.Dataset.singleFileUploadMaxBytes
+ }
+ }
+
+ "ResourceStorage" should "hand each resource type its own limits" in {
+ ResourceStorage.Dataset.uploadLimits shouldBe UploadLimits.Dataset
+ ResourceStorage.Model.uploadLimits shouldBe UploadLimits.Model
+ }
+}
diff --git a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts
index 4c764b99fd9..13d10c45884 100644
--- a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts
+++ b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts
@@ -66,10 +66,10 @@ describe("AdminSettingsComponent", () => {
favicon: "fav.ico",
hub_enabled: "true",
home_enabled: "false",
- max_number_of_concurrent_uploading_file: "5",
- single_file_upload_max_size_mib: "128",
- max_number_of_concurrent_uploading_file_chunks: "7",
- multipart_upload_chunk_size_mib: "64",
+ dataset_max_number_of_concurrent_uploading_file: "5",
+ dataset_single_file_upload_max_size_mib: "128",
+ dataset_max_number_of_concurrent_uploading_file_chunks: "7",
+ dataset_multipart_upload_chunk_size_mib: "64",
csv_parser_max_columns: "4096",
});
@@ -87,7 +87,7 @@ describe("AdminSettingsComponent", () => {
it("keeps the initializer defaults for missing or unparsable values", () => {
httpTestingController.expectOne(SETTINGS_URL).flush({
- single_file_upload_max_size_mib: "not-a-number",
+ dataset_single_file_upload_max_size_mib: "not-a-number",
});
expect(component.logoData).toBeNull();
@@ -107,7 +107,7 @@ describe("AdminSettingsComponent", () => {
it("preserves a legitimately stored 0 instead of falling back to the default", () => {
httpTestingController.expectOne(SETTINGS_URL).flush({
- max_number_of_concurrent_uploading_file: "0",
+ dataset_max_number_of_concurrent_uploading_file: "0",
csv_parser_max_columns: "0",
});
@@ -299,10 +299,10 @@ describe("AdminSettingsComponent", () => {
expect(req.request.body).toEqual({ value });
req.flush(null);
};
- expectPut("max_number_of_concurrent_uploading_file", "3");
- expectPut("single_file_upload_max_size_mib", "20");
- expectPut("max_number_of_concurrent_uploading_file_chunks", "10");
- expectPut("multipart_upload_chunk_size_mib", "50");
+ expectPut("dataset_max_number_of_concurrent_uploading_file", "3");
+ expectPut("dataset_single_file_upload_max_size_mib", "20");
+ expectPut("dataset_max_number_of_concurrent_uploading_file_chunks", "10");
+ expectPut("dataset_multipart_upload_chunk_size_mib", "50");
expect(msgSuccess).toHaveBeenCalledWith("Dataset upload settings saved successfully.");
});
@@ -346,10 +346,10 @@ describe("AdminSettingsComponent", () => {
// Fail the last of the four PUTs so forkJoin errors with every request flushed.
const keys = [
- "max_number_of_concurrent_uploading_file",
- "single_file_upload_max_size_mib",
- "max_number_of_concurrent_uploading_file_chunks",
- "multipart_upload_chunk_size_mib",
+ "dataset_max_number_of_concurrent_uploading_file",
+ "dataset_single_file_upload_max_size_mib",
+ "dataset_max_number_of_concurrent_uploading_file_chunks",
+ "dataset_multipart_upload_chunk_size_mib",
];
keys.forEach((key, i) => {
const req = httpTestingController.expectOne(updateUrl(key));
@@ -365,10 +365,10 @@ describe("AdminSettingsComponent", () => {
component.resetDatasetSettings();
[
- "max_number_of_concurrent_uploading_file",
- "single_file_upload_max_size_mib",
- "max_number_of_concurrent_uploading_file_chunks",
- "multipart_upload_chunk_size_mib",
+ "dataset_max_number_of_concurrent_uploading_file",
+ "dataset_single_file_upload_max_size_mib",
+ "dataset_max_number_of_concurrent_uploading_file_chunks",
+ "dataset_multipart_upload_chunk_size_mib",
].forEach(key => httpTestingController.expectOne(resetUrl(key)).flush(null));
expect(msgInfo).toHaveBeenCalledWith("Resetting dataset settings...");
});
diff --git a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.ts b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.ts
index 3714ac0de83..44b8fad73d5 100644
--- a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.ts
+++ b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.ts
@@ -123,15 +123,18 @@ export class AdminSettingsComponent implements OnInit {
tab => (this.sidebarTabs[tab] = settings[tab] === "true")
);
this.maxConcurrentFiles = parseIntOrDefault(
- settings["max_number_of_concurrent_uploading_file"],
+ settings["dataset_max_number_of_concurrent_uploading_file"],
this.maxConcurrentFiles
);
- this.maxFileSizeMiB = parseIntOrDefault(settings["single_file_upload_max_size_mib"], this.maxFileSizeMiB);
+ this.maxFileSizeMiB = parseIntOrDefault(
+ settings["dataset_single_file_upload_max_size_mib"],
+ this.maxFileSizeMiB
+ );
this.maxConcurrentChunks = parseIntOrDefault(
- settings["max_number_of_concurrent_uploading_file_chunks"],
+ settings["dataset_max_number_of_concurrent_uploading_file_chunks"],
this.maxConcurrentChunks
);
- this.chunkSizeMiB = parseIntOrDefault(settings["multipart_upload_chunk_size_mib"], this.chunkSizeMiB);
+ this.chunkSizeMiB = parseIntOrDefault(settings["dataset_multipart_upload_chunk_size_mib"], this.chunkSizeMiB);
this.csvMaxColumns = parseIntOrDefault(settings["csv_parser_max_columns"], this.csvMaxColumns);
this.settingsLoaded = true;
},
@@ -259,15 +262,18 @@ export class AdminSettingsComponent implements OnInit {
const saveRequests = [
this.adminSettingsService.updateSetting(
- "max_number_of_concurrent_uploading_file",
+ "dataset_max_number_of_concurrent_uploading_file",
this.maxConcurrentFiles.toString()
),
- this.adminSettingsService.updateSetting("single_file_upload_max_size_mib", this.maxFileSizeMiB.toString()),
this.adminSettingsService.updateSetting(
- "max_number_of_concurrent_uploading_file_chunks",
+ "dataset_single_file_upload_max_size_mib",
+ this.maxFileSizeMiB.toString()
+ ),
+ this.adminSettingsService.updateSetting(
+ "dataset_max_number_of_concurrent_uploading_file_chunks",
this.maxConcurrentChunks.toString()
),
- this.adminSettingsService.updateSetting("multipart_upload_chunk_size_mib", this.chunkSizeMiB.toString()),
+ this.adminSettingsService.updateSetting("dataset_multipart_upload_chunk_size_mib", this.chunkSizeMiB.toString()),
];
forkJoin(saveRequests)
@@ -280,10 +286,10 @@ export class AdminSettingsComponent implements OnInit {
resetDatasetSettings(): void {
[
- "max_number_of_concurrent_uploading_file",
- "single_file_upload_max_size_mib",
- "max_number_of_concurrent_uploading_file_chunks",
- "multipart_upload_chunk_size_mib",
+ "dataset_max_number_of_concurrent_uploading_file",
+ "dataset_single_file_upload_max_size_mib",
+ "dataset_max_number_of_concurrent_uploading_file_chunks",
+ "dataset_multipart_upload_chunk_size_mib",
].forEach(setting => this.adminSettingsService.resetSetting(setting).pipe(untilDestroyed(this)).subscribe({}));
this.message.info("Resetting dataset settings...");
diff --git a/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.ts b/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.ts
index 5673e558781..10aed3dfd84 100644
--- a/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.ts
+++ b/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.ts
@@ -85,7 +85,7 @@ export class FilesUploaderComponent {
) {
// A missing key or failed fetch keeps the initializer default above.
this.adminSettingsService
- .getPublicSetting("single_file_upload_max_size_mib")
+ .getPublicSetting("dataset_single_file_upload_max_size_mib")
.pipe(untilDestroyed(this))
.subscribe({
next: value => (this.singleFileUploadMaxSizeMiB = parseIntOrDefault(value, this.singleFileUploadMaxSizeMiB)),
diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
index 20570f4e958..7aae86e1033 100644
--- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
+++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
@@ -1227,9 +1227,9 @@ describe("DatasetDetailComponent behavior", () => {
// A distinct value per key, so a setting that lands in the wrong field is
// visible: 7 chunks and 2 files cannot stand in for one another.
adminSettingsServiceStub.getPublicSetting.mockImplementation((key: string) =>
- key === "multipart_upload_chunk_size_mib"
+ key === "dataset_multipart_upload_chunk_size_mib"
? throwError(() => new Error("boom"))
- : of(key === "max_number_of_concurrent_uploading_file_chunks" ? "7" : "2")
+ : of(key === "dataset_max_number_of_concurrent_uploading_file_chunks" ? "7" : "2")
);
createComponent({ did: 5 });
@@ -1246,7 +1246,7 @@ describe("DatasetDetailComponent behavior", () => {
it("leaves both concurrency limits untouched when their settings fail to load", () => {
adminSettingsServiceStub.getPublicSetting.mockImplementation((key: string) =>
- key === "multipart_upload_chunk_size_mib" ? of("128") : throwError(() => new Error("boom"))
+ key === "dataset_multipart_upload_chunk_size_mib" ? of("128") : throwError(() => new Error("boom"))
);
createComponent({ did: 5 });
diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
index 437f01d8bd7..2f2e8b209ec 100644
--- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
+++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
@@ -585,21 +585,21 @@ export class DatasetDetailComponent implements OnInit {
// silently stall the upload queue (`activeUploads < NaN` is always false).
private loadUploadSettings(): void {
this.adminSettingsService
- .getPublicSetting("multipart_upload_chunk_size_mib")
+ .getPublicSetting("dataset_multipart_upload_chunk_size_mib")
.pipe(untilDestroyed(this))
.subscribe({
next: value => (this.chunkSizeMiB = parseIntOrDefault(value, this.chunkSizeMiB)),
error: () => {},
});
this.adminSettingsService
- .getPublicSetting("max_number_of_concurrent_uploading_file_chunks")
+ .getPublicSetting("dataset_max_number_of_concurrent_uploading_file_chunks")
.pipe(untilDestroyed(this))
.subscribe({
next: value => (this.maxConcurrentChunks = parseIntOrDefault(value, this.maxConcurrentChunks)),
error: () => {},
});
this.adminSettingsService
- .getPublicSetting("max_number_of_concurrent_uploading_file")
+ .getPublicSetting("dataset_max_number_of_concurrent_uploading_file")
.pipe(untilDestroyed(this))
.subscribe({
next: value => (this.maxConcurrentFiles = parseIntOrDefault(value, this.maxConcurrentFiles)),
diff --git a/sql/changelog.xml b/sql/changelog.xml
index e86f869665a..b57ec95ca42 100644
--- a/sql/changelog.xml
+++ b/sql/changelog.xml
@@ -119,6 +119,11 @@
+
+
+
+
+