This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/main by this push:
     new b44f7db7cb feat(storage): warehouse REST API, Lakekeeper client, and 
per-execution injection (#7473)
b44f7db7cb is described below

commit b44f7db7cb0962f06fab1bb4353e6bfa353d50e6
Author: Meng Wang <[email protected]>
AuthorDate: Mon Aug 10 21:50:57 2026 -0700

    feat(storage): warehouse REST API, Lakekeeper client, and per-execution 
injection (#7473)
    
    ### What changes were proposed in this PR?
    
    The backend of the per-user warehouse feature (umbrella #6870), all
    gated by `warehouseEnabled` (default off — nothing changes for existing
    deployments):
    
    - **Warehouse management API** — `WarehouseResource`: `GET
    /warehouse/status` (always answers, so the frontend can hide the
    feature), `POST /warehouse` and `DELETE /warehouse/{whid}` (403 while
    the flag is off). Create validates the URI-safe name, mints the catalog
    name `user-<uid>-<name>`, creates in Lakekeeper first and records the
    row after — a failed creation leaves no orphaned state.
    - **`LakekeeperClient`** — management-API create (Local flavor: storage
    profile on the deployment's own object store, per-warehouse key prefix,
    STS off) and **empty-first delete**: drop every table with
    `purgeRequested=true`, then the namespaces, then the warehouse entity.
    - **Per-execution injection** — `WorkflowExecuteRequest` gains
    `warehouseId: Option[Int]`; `WorkflowService.resolveWarehouseName`
    checks ownership and refuses an explicit pick while the feature is off
    (never a silent fallback — #6930); the resolved name rides
    `WorkflowContext.warehouse` into every storage URI (results, runtime
    statistics, console messages); the chosen `whid` is recorded on
    `workflow_executions` (as `cuid` is today) so the picker can preselect
    the workflow's last-used warehouse. `whid` is `ON DELETE SET NULL`:
    deleting a warehouse purges its data, never the execution history.
    - **Explicit read failure** — `WarehouseReadGuard`: paginating a
    `/wh/<name>/…` result while the feature is off fails naming the
    warehouse, instead of resolving against the shared warehouse and
    surfacing "table not found" (#6930).
    
    ### Any related issues, documentation, discussions?
    
    Closes #6932. Part of #6870 (design discussions #5293 and #6040). Builds
    on #6944, #7359 and #7386.
    
    ### How was this PR tested?
    
    Five specs, 30 cases green locally (`sbt
    "WorkflowExecutionService/testOnly *LakekeeperClientSpec
    *WarehouseResourceSpec *WarehouseReadGuardSpec
    *WorkflowServiceWarehouseSpec *ExecutionsMetadataPersistServiceSpec"`):
    the Lakekeeper client runs against an in-process HTTP stub
    (create-payload shape; the purge → namespace → warehouse delete order);
    the resource spec covers the disabled gate and the create/list/delete
    flow on MockTexeraDB with a stubbed client; resolution pins ownership
    and the no-silent-fallback rule; the read guard pins the explicit
    failure message; the executions spec gains whid recording and the
    SET-NULL-on-delete case. A delete-order assertion was deliberately
    broken once to confirm it fails red.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (claude-fable-5)
---
 .../scheduling/CostBasedScheduleGenerator.scala    |   3 +-
 .../ExpansionGreedyScheduleGenerator.scala         |   3 +-
 .../apache/texera/web/TexeraWebApplication.scala   |   2 +
 .../websocket/request/WorkflowExecuteRequest.scala |   4 +-
 .../web/resource/SyncExecutionResource.scala       |  16 +-
 .../user/warehouse/WarehouseResource.scala         | 203 ++++++++++++++++++
 .../user/workflow/WorkflowExecutionsResource.scala |  11 +-
 .../dashboard/user/workflow/WorkflowResource.scala |   6 +-
 .../web/service/ExecutionConsoleService.scala      |   7 +-
 .../web/service/ExecutionResultService.scala       |   3 +
 .../texera/web/service/ExecutionStatsService.scala |   3 +-
 .../service/ExecutionsMetadataPersistService.scala |   5 +-
 .../texera/web/service/LakekeeperClient.scala      | 178 ++++++++++++++++
 .../texera/web/service/ResultExportService.scala   |   6 +-
 .../texera/web/service/WarehouseReadGuard.scala    |  75 +++++++
 .../texera/web/service/WorkflowService.scala       |  84 ++++++--
 .../user/warehouse/WarehouseResourceSpec.scala     | 233 +++++++++++++++++++++
 .../workflow/WorkflowExecutionsResourceSpec.scala  |  24 ++-
 .../ExecutionsMetadataPersistServiceSpec.scala     |  55 +++++
 .../texera/web/service/LakekeeperClientSpec.scala  | 171 +++++++++++++++
 .../web/service/WarehouseReadGuardSpec.scala       | 106 ++++++++++
 .../web/service/WorkflowExecutionServiceSpec.scala |   3 +-
 .../web/service/WorkflowServiceWarehouseSpec.scala |  99 +++++++++
 .../texera/common/config/StorageConfig.scala       |   2 +
 .../texera/amber/core/storage/VFSURIFactory.scala  |   7 +-
 sql/changelog.xml                                  |   5 +
 sql/texera_ddl.sql                                 |   4 +-
 .../updates/34.sql                                 |  28 +--
 28 files changed, 1291 insertions(+), 55 deletions(-)

diff --git 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/CostBasedScheduleGenerator.scala
 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/CostBasedScheduleGenerator.scala
index 57564ef73f..dd43eb99aa 100644
--- 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/CostBasedScheduleGenerator.scala
+++ 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/CostBasedScheduleGenerator.scala
@@ -194,7 +194,8 @@ class CostBasedScheduleGenerator(
             val portBaseURI = createPortBaseURI(
               workflowId = workflowContext.workflowId,
               executionId = workflowContext.executionId,
-              globalPortId = gpid
+              globalPortId = gpid,
+              warehouse = workflowContext.warehouse
             )
             gpid -> OutputPortConfig(portBaseURI)
           }.toMap
diff --git 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/ExpansionGreedyScheduleGenerator.scala
 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/ExpansionGreedyScheduleGenerator.scala
index 304e1496f8..4df5ab80fa 100644
--- 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/ExpansionGreedyScheduleGenerator.scala
+++ 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/ExpansionGreedyScheduleGenerator.scala
@@ -334,7 +334,8 @@ class ExpansionGreedyScheduleGenerator(
     createPortBaseURI(
       workflowId = workflowContext.workflowId,
       executionId = workflowContext.executionId,
-      globalPortId = outputPortId
+      globalPortId = outputPortId,
+      warehouse = workflowContext.warehouse
     )
   }
 
diff --git 
a/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala 
b/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala
index 73e473ba7a..6dd624de60 100644
--- a/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala
+++ b/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala
@@ -39,6 +39,7 @@ import 
org.apache.texera.web.resource.dashboard.admin.execution.AdminExecutionRe
 import org.apache.texera.web.resource.dashboard.admin.user.AdminUserResource
 import org.apache.texera.web.resource.dashboard.hub.HubResource
 import org.apache.texera.web.resource.dashboard.user.UserResource
+import 
org.apache.texera.web.resource.dashboard.user.warehouse.WarehouseResource
 import org.apache.texera.web.resource.dashboard.user.project.{
   ProjectAccessResource,
   ProjectResource,
@@ -160,6 +161,7 @@ class TexeraWebApplication
     environment.jersey.register(classOf[UserQuotaResource])
     environment.jersey.register(classOf[AIAssistantResource])
     environment.jersey.register(classOf[HuggingFaceModelResource])
+    environment.jersey.register(classOf[WarehouseResource])
 
     AuthResource.createAdminUser()
 
diff --git 
a/amber/src/main/scala/org/apache/texera/web/model/websocket/request/WorkflowExecuteRequest.scala
 
b/amber/src/main/scala/org/apache/texera/web/model/websocket/request/WorkflowExecuteRequest.scala
index 0059af1c1a..fac3f67b32 100644
--- 
a/amber/src/main/scala/org/apache/texera/web/model/websocket/request/WorkflowExecuteRequest.scala
+++ 
b/amber/src/main/scala/org/apache/texera/web/model/websocket/request/WorkflowExecuteRequest.scala
@@ -36,5 +36,7 @@ case class WorkflowExecuteRequest(
     replayFromExecution: Option[ReplayExecutionInfo], // contains execution 
Id, interaction Id.
     workflowSettings: WorkflowSettings,
     emailNotificationEnabled: Boolean,
-    computingUnitId: Int
+    computingUnitId: Int,
+    // The user_warehouse this run writes into; absent = the shared default 
warehouse.
+    warehouseId: Option[Int]
 ) extends TexeraWebSocketRequest
diff --git 
a/amber/src/main/scala/org/apache/texera/web/resource/SyncExecutionResource.scala
 
b/amber/src/main/scala/org/apache/texera/web/resource/SyncExecutionResource.scala
index afcd7f63a1..cd528a6cf5 100644
--- 
a/amber/src/main/scala/org/apache/texera/web/resource/SyncExecutionResource.scala
+++ 
b/amber/src/main/scala/org/apache/texera/web/resource/SyncExecutionResource.scala
@@ -50,6 +50,7 @@ import org.apache.texera.dao.SqlServer
 import org.apache.texera.dao.jooq.generated.Tables.OPERATOR_EXECUTIONS
 import org.apache.texera.common.compiler.model.LogicalPlanPojo
 import org.apache.texera.web.model.websocket.request.WorkflowExecuteRequest
+import org.apache.texera.web.service.{WarehouseReadGuard, 
WarehouseUnavailableException}
 import org.apache.texera.common.compiler.model.LogicalLink
 import org.apache.texera.common.compiler.{CompilationErrorHandling, 
WorkflowCompiler}
 import 
org.apache.texera.web.resource.dashboard.user.workflow.WorkflowExecutionsResource
@@ -167,7 +168,8 @@ class SyncExecutionResource extends LazyLogging {
             WorkflowSettings(dataTransferBatchSize = 
ApplicationConfig.defaultDataTransferBatchSize)
           ),
         emailNotificationEnabled = false,
-        computingUnitId = computingUnitId
+        computingUnitId = computingUnitId,
+        warehouseId = None
       )
 
       workflowService.initExecutionService(
@@ -535,6 +537,8 @@ class SyncExecutionResource extends LazyLogging {
 
       storageUriOption match {
         case Some(storageUri) =>
+          // Refuse to read a per-user-warehouse result while the feature is 
off (#6930).
+          WarehouseReadGuard.assertReadable(storageUri)
           val document = DocumentFactory
             .openDocument(storageUri)
             ._1
@@ -694,6 +698,9 @@ class SyncExecutionResource extends LazyLogging {
           ("table", None, None, None, None)
       }
     } catch {
+      // A kill-switch refusal must reach the caller instead of degrading into 
an
+      // empty result (#6930); every other failure keeps the existing behavior.
+      case e: WarehouseUnavailableException => throw e
       case e: Exception =>
         logger.warn(s"Error collecting result for operator $opId: 
${e.getMessage}", e)
         ("table", None, None, None, None)
@@ -769,6 +776,8 @@ class SyncExecutionResource extends LazyLogging {
       val uriOption = getConsoleMessageUri(executionId, OperatorIdentity(opId))
 
       uriOption.flatMap { uri =>
+        // Refuse to read per-user-warehouse console messages while the 
feature is off (#6930).
+        WarehouseReadGuard.assertReadable(uri)
         val document = DocumentFactory
           .openDocument(uri)
           ._1
@@ -793,7 +802,10 @@ class SyncExecutionResource extends LazyLogging {
         if (messages.nonEmpty) Some(messages) else None
       }
     } catch {
-      case _: Exception => None
+      // A kill-switch refusal must reach the caller instead of degrading into 
an
+      // empty result (#6930); every other failure keeps the existing behavior.
+      case e: WarehouseUnavailableException => throw e
+      case _: Exception                     => None
     }
   }
 
diff --git 
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/warehouse/WarehouseResource.scala
 
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/warehouse/WarehouseResource.scala
new file mode 100644
index 0000000000..d4aed3ccdc
--- /dev/null
+++ 
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/warehouse/WarehouseResource.scala
@@ -0,0 +1,203 @@
+/*
+ * 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.web.resource.dashboard.user.warehouse
+
+import com.typesafe.scalalogging.LazyLogging
+import io.dropwizard.auth.Auth
+import org.apache.texera.amber.core.storage.VFSURIFactory
+import org.apache.texera.auth.SessionUser
+import org.apache.texera.common.config.StorageConfig
+import org.apache.texera.dao.SqlServer
+import org.apache.texera.dao.jooq.generated.Tables.USER_WAREHOUSE
+import org.apache.texera.dao.jooq.generated.enums.UserWarehouseFlavorEnum
+import org.apache.texera.dao.jooq.generated.tables.records.UserWarehouseRecord
+import 
org.apache.texera.web.resource.dashboard.user.warehouse.WarehouseResource._
+import org.apache.texera.web.service.LakekeeperClient
+
+import javax.annotation.security.RolesAllowed
+import javax.ws.rs._
+import javax.ws.rs.core.MediaType
+
+object WarehouseResource {
+  private def context =
+    SqlServer
+      .getInstance()
+      .createDSLContext()
+
+  // A warehouse's user-facing name becomes part of the Lakekeeper catalog name
+  // `user-<uid>-<name>`, which in turn becomes a VFS URI path segment — so the
+  // character rule is delegated to VFSURIFactory (the layer that parses it); 
the
+  // length cap is this registration layer's own constraint.
+  private[warehouse] def isValidWarehouseName(name: String): Boolean =
+    name.length <= 64 && VFSURIFactory.isValidWarehouseName(name)
+
+  case class DashboardWarehouse(
+      whid: Integer,
+      name: String,
+      warehouseName: String,
+      flavor: String,
+      createdAtMillis: Long
+  )
+
+  private def toDashboardWarehouse(row: UserWarehouseRecord): 
DashboardWarehouse =
+    DashboardWarehouse(
+      row.getWhid,
+      row.getName,
+      row.getWarehouseName,
+      row.getFlavor.getLiteral,
+      row.getCreatedAt.toInstant.toEpochMilli
+    )
+
+  case class WarehouseStatus(enabled: Boolean, warehouses: 
List[DashboardWarehouse])
+
+  case class CreateWarehouseRequest(name: String)
+}
+
+/**
+  * Per-user warehouse management (#6870): list the feature state and the 
caller's
+  * warehouses, create a Local-flavor warehouse on the deployment's own object 
store,
+  * and delete one (empty-first in Lakekeeper, purging its data files).
+  *
+  * Everything except `/status` is gated by the warehouse feature flag; the 
mutating
+  * endpoints return 403 while it is off. `/status` always answers so the 
frontend can
+  * decide whether to show the feature at all.
+  */
+@Path("/warehouse")
+@Produces(Array(MediaType.APPLICATION_JSON))
+class WarehouseResource(client: LakekeeperClient, enabled: Boolean) extends 
LazyLogging {
+
+  // Jersey builds the resource through this constructor. The flag is captured 
once,
+  // which is equivalent to reading it per call: storage.conf is resolved at 
class load
+  // and never changes at runtime.
+  def this() = this(new LakekeeperClient(), StorageConfig.warehouseEnabled)
+
+  @GET
+  @Path("/status")
+  def status(@Auth current_user: SessionUser): WarehouseStatus = {
+    if (!enabled) {
+      return WarehouseStatus(enabled = false, warehouses = List())
+    }
+    val warehouses = context
+      .selectFrom(USER_WAREHOUSE)
+      .where(USER_WAREHOUSE.UID.eq(current_user.getUid))
+      .orderBy(USER_WAREHOUSE.CREATED_AT.asc())
+      .fetch()
+      .map(row => toDashboardWarehouse(row))
+    WarehouseStatus(
+      enabled = true,
+      warehouses = warehouses.toArray(Array[DashboardWarehouse]()).toList
+    )
+  }
+
+  @POST
+  @RolesAllowed(Array("REGULAR", "ADMIN"))
+  def create(
+      request: CreateWarehouseRequest,
+      @Auth current_user: SessionUser
+  ): DashboardWarehouse = {
+    requireEnabled()
+    val name = Option(request.name).map(_.trim).getOrElse("")
+    if (!isValidWarehouseName(name)) {
+      throw new BadRequestException(
+        "warehouse name must start with a letter or digit and contain only 
letters, " +
+          "digits, '-' and '_' (at most 64 characters)"
+      )
+    }
+    val uid = current_user.getUid
+    if (
+      context.fetchExists(
+        context
+          .selectFrom(USER_WAREHOUSE)
+          .where(USER_WAREHOUSE.UID.eq(uid).and(USER_WAREHOUSE.NAME.eq(name)))
+      )
+    ) {
+      throw new WebApplicationException(s"a warehouse named '$name' already 
exists", 409)
+    }
+
+    val warehouseName = s"user-$uid-$name"
+    // Create in Lakekeeper first, record after: a failed creation leaves no 
orphaned row.
+    val warehouseId =
+      try {
+        client.createWarehouse(warehouseName)
+      } catch {
+        case e: Exception =>
+          throw new WebApplicationException(e.getMessage, 502)
+      }
+
+    val row = context.newRecord(USER_WAREHOUSE)
+    row.setUid(uid)
+    row.setName(name)
+    row.setWarehouseName(warehouseName)
+    row.setLakekeeperWarehouseId(warehouseId)
+    row.setFlavor(UserWarehouseFlavorEnum.local)
+    row.setS3Bucket(StorageConfig.icebergRESTCatalogS3Bucket)
+    row.setS3Endpoint(StorageConfig.s3Endpoint)
+    row.setS3Region(StorageConfig.s3Region)
+    try {
+      row.store()
+      // created_at is filled by the DB default; fetch it back before 
serializing.
+      row.refresh()
+    } catch {
+      case e: Exception =>
+        // Compensate: without the row the user could neither list nor delete 
the
+        // just-created warehouse, so remove it (it is empty at this point).
+        try {
+          client.deleteWarehouseEmptyFirst(warehouseId)
+        } catch {
+          case cleanup: Exception =>
+            logger.error(
+              s"failed to clean up Lakekeeper warehouse $warehouseId after a 
failed create",
+              cleanup
+            )
+        }
+        throw new WebApplicationException(e.getMessage, 500)
+    }
+    toDashboardWarehouse(row)
+  }
+
+  @DELETE
+  @Path("/{whid}")
+  @RolesAllowed(Array("REGULAR", "ADMIN"))
+  def delete(@PathParam("whid") whid: Integer, @Auth current_user: 
SessionUser): Unit = {
+    requireEnabled()
+    val row = context
+      .selectFrom(USER_WAREHOUSE)
+      
.where(USER_WAREHOUSE.WHID.eq(whid).and(USER_WAREHOUSE.UID.eq(current_user.getUid)))
+      .fetchOne()
+    if (row == null) {
+      throw new NotFoundException(s"no warehouse with id $whid")
+    }
+    try {
+      client.deleteWarehouseEmptyFirst(row.getLakekeeperWarehouseId)
+    } catch {
+      case e: Exception =>
+        throw new WebApplicationException(e.getMessage, 502)
+    }
+    context
+      .deleteFrom(USER_WAREHOUSE)
+      .where(USER_WAREHOUSE.WHID.eq(whid))
+      .execute()
+  }
+
+  private def requireEnabled(): Unit =
+    if (!enabled) {
+      throw new ForbiddenException("per-user warehouses are disabled in this 
deployment")
+    }
+}
diff --git 
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowExecutionsResource.scala
 
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowExecutionsResource.scala
index cca18443b7..7e847c43c0 100644
--- 
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowExecutionsResource.scala
+++ 
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowExecutionsResource.scala
@@ -42,6 +42,7 @@ import org.apache.texera.dao.jooq.generated.enums.UserRoleEnum
 import org.apache.texera.dao.jooq.generated.tables.daos.WorkflowExecutionsDao
 import org.apache.texera.dao.jooq.generated.tables.pojos.{WorkflowExecutions, 
User => UserPojo}
 import org.apache.texera.web.model.http.request.result.ResultExportRequest
+import org.apache.texera.web.service.WarehouseReadGuard
 import 
org.apache.texera.web.resource.dashboard.user.workflow.WorkflowExecutionsResource._
 import org.apache.texera.web.service.{ExecutionsMetadataPersistService, 
ResultExportService}
 import org.jooq.DSLContext
@@ -320,6 +321,7 @@ object WorkflowExecutionsResource {
         WORKFLOW_EXECUTIONS.EID,
         WORKFLOW_EXECUTIONS.VID,
         WORKFLOW_EXECUTIONS.CUID,
+        WORKFLOW_EXECUTIONS.WHID,
         USER.NAME,
         USER.AVATAR,
         WORKFLOW_EXECUTIONS.STATUS,
@@ -379,8 +381,9 @@ object WorkflowExecutionsResource {
       .where(WORKFLOW_EXECUTIONS.EID.in(eIdsList))
       .execute()
 
-    // Clear corresponding Iceberg documents
-    uris.foreach { uri =>
+    // Clear corresponding Iceberg documents. While per-user warehouses are 
disabled,
+    // cleanup must not reach into them (#6930) — those URIs are skipped.
+    uris.filterNot(WarehouseReadGuard.skipWhileDisabled(_)).foreach { uri =>
       try {
         DocumentFactory.openDocument(uri)._1.clear()
       } catch {
@@ -523,6 +526,7 @@ object WorkflowExecutionsResource {
       eId: Integer,
       vId: Integer,
       cuId: Integer,
+      whId: Integer,
       userName: String,
       googleAvatar: String,
       status: Byte,
@@ -581,6 +585,7 @@ class WorkflowExecutionsResource {
             WORKFLOW_EXECUTIONS.EID,
             WORKFLOW_EXECUTIONS.VID,
             WORKFLOW_EXECUTIONS.CUID,
+            WORKFLOW_EXECUTIONS.WHID,
             USER.NAME,
             USER.AVATAR,
             WORKFLOW_EXECUTIONS.STATUS,
@@ -721,6 +726,8 @@ class WorkflowExecutionsResource {
     }
 
     val uri: URI = new URI(uriString)
+    // Refuse to read per-user-warehouse statistics while the feature is off 
(#6930).
+    WarehouseReadGuard.assertReadable(uri)
     val document = DocumentFactory.openDocument(uri)._1
 
     // Read all records from Iceberg and convert to WorkflowRuntimeStatistics
diff --git 
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala
 
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala
index 72d70d5cf7..eaf4460b29 100644
--- 
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala
+++ 
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala
@@ -38,6 +38,7 @@ import org.apache.texera.dao.jooq.generated.tables.daos.{
 import org.apache.texera.dao.jooq.generated.tables.pojos._
 import org.apache.texera.service.util.LargeBinaryManager
 import org.apache.texera.web.resource.dashboard.hub.EntityType
+import org.apache.texera.web.service.WarehouseReadGuard
 import 
org.apache.texera.web.resource.dashboard.hub.HubResource.recordCloneAction
 import 
org.apache.texera.web.resource.dashboard.user.workflow.WorkflowAccessResource.hasReadAccess
 import 
org.apache.texera.web.resource.dashboard.user.workflow.WorkflowResource._
@@ -654,9 +655,10 @@ class WorkflowResource extends LazyLogging {
       // removed. Done after the transaction (like the document cleanup below).
       eids.foreach(eid => 
LargeBinaryManager.deleteByExecution(eid.longValue()))
 
-      // Clean up document storage
+      // Clean up document storage. While per-user warehouses are disabled, 
cleanup must
+      // not reach into them (#6930) — those URIs are skipped.
       try {
-        uris.foreach { uri =>
+        uris.filterNot(WarehouseReadGuard.skipWhileDisabled(_)).foreach { uri 
=>
           try {
             val (document, _) = DocumentFactory.openDocument(uri)
             document.clear()
diff --git 
a/amber/src/main/scala/org/apache/texera/web/service/ExecutionConsoleService.scala
 
b/amber/src/main/scala/org/apache/texera/web/service/ExecutionConsoleService.scala
index 3aae5f2b89..55f72c35d8 100644
--- 
a/amber/src/main/scala/org/apache/texera/web/service/ExecutionConsoleService.scala
+++ 
b/amber/src/main/scala/org/apache/texera/web/service/ExecutionConsoleService.scala
@@ -144,7 +144,12 @@ class ExecutionConsoleService(
     consoleMessageOpIdToWriterMap.getOrElseUpdate(
       opId.id, {
         val uri = VFSURIFactory
-          .createConsoleMessagesURI(workflowContext.workflowId, 
workflowContext.executionId, opId)
+          .createConsoleMessagesURI(
+            workflowContext.workflowId,
+            workflowContext.executionId,
+            opId,
+            warehouse = workflowContext.warehouse
+          )
         val writer = DocumentFactory
           .createDocument(uri, ResultSchema.consoleMessagesSchema)
           .writer("console_messages")
diff --git 
a/amber/src/main/scala/org/apache/texera/web/service/ExecutionResultService.scala
 
b/amber/src/main/scala/org/apache/texera/web/service/ExecutionResultService.scala
index c2ffadb9f1..2fa63f6b86 100644
--- 
a/amber/src/main/scala/org/apache/texera/web/service/ExecutionResultService.scala
+++ 
b/amber/src/main/scala/org/apache/texera/web/service/ExecutionResultService.scala
@@ -481,6 +481,9 @@ class ExecutionResultService(
       PortIdentity()
     )
 
+    // Refuse to read a per-user-warehouse result while the feature is off 
(#6930).
+    storageUriOption.foreach(WarehouseReadGuard.assertReadable(_))
+
     storageUriOption match {
       case Some(storageUri) =>
         val (document, schemaOption) = DocumentFactory.openDocument(storageUri)
diff --git 
a/amber/src/main/scala/org/apache/texera/web/service/ExecutionStatsService.scala
 
b/amber/src/main/scala/org/apache/texera/web/service/ExecutionStatsService.scala
index f112f4f65d..ac33478451 100644
--- 
a/amber/src/main/scala/org/apache/texera/web/service/ExecutionStatsService.scala
+++ 
b/amber/src/main/scala/org/apache/texera/web/service/ExecutionStatsService.scala
@@ -78,7 +78,8 @@ class ExecutionStatsService(
     val thread = Executors.newSingleThreadExecutor()
     val uri = VFSURIFactory.createRuntimeStatisticsURI(
       workflowContext.workflowId,
-      workflowContext.executionId
+      workflowContext.executionId,
+      warehouse = workflowContext.warehouse
     )
     val writer = DocumentFactory
       .createDocument(uri, ResultSchema.runtimeStatisticsSchema)
diff --git 
a/amber/src/main/scala/org/apache/texera/web/service/ExecutionsMetadataPersistService.scala
 
b/amber/src/main/scala/org/apache/texera/web/service/ExecutionsMetadataPersistService.scala
index bafe7a178a..b3e4edf985 100644
--- 
a/amber/src/main/scala/org/apache/texera/web/service/ExecutionsMetadataPersistService.scala
+++ 
b/amber/src/main/scala/org/apache/texera/web/service/ExecutionsMetadataPersistService.scala
@@ -56,7 +56,8 @@ object ExecutionsMetadataPersistService extends LazyLogging {
       uid: Integer,
       executionName: String,
       environmentVersion: String,
-      computingUnitId: Integer
+      computingUnitId: Integer,
+      warehouseId: Option[Int] = None
   ): ExecutionIdentity = {
     // first retrieve the latest version of this workflow
     val vid = getLatestVersion(workflowId.id.toInt)
@@ -71,6 +72,8 @@ object ExecutionsMetadataPersistService extends LazyLogging {
 
     // Set computing unit ID if provided
     newExecution.setCuid(computingUnitId)
+    // The warehouse this run writes into (#6870); null = the shared default 
warehouse.
+    warehouseId.foreach(whid => newExecution.setWhid(whid))
 
     try {
       workflowExecutionsDao.insert(newExecution)
diff --git 
a/amber/src/main/scala/org/apache/texera/web/service/LakekeeperClient.scala 
b/amber/src/main/scala/org/apache/texera/web/service/LakekeeperClient.scala
new file mode 100644
index 0000000000..7fdafc0df7
--- /dev/null
+++ b/amber/src/main/scala/org/apache/texera/web/service/LakekeeperClient.scala
@@ -0,0 +1,178 @@
+/*
+ * 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.web.service
+
+import com.fasterxml.jackson.databind.{JsonNode, ObjectMapper}
+import kong.unirest.Unirest
+import org.apache.texera.common.config.StorageConfig
+
+import java.net.URLEncoder
+import java.nio.charset.StandardCharsets
+import java.util.UUID
+import scala.collection.mutable.ListBuffer
+import scala.jdk.CollectionConverters.IteratorHasAsScala
+
+/**
+  * Client for the Lakekeeper APIs used to manage per-user warehouses (#6870).
+  *
+  * Two API families are involved: the **management** API 
(`/management/v1/...`) creates and
+  * deletes warehouse entities, and the **catalog** API 
(`/catalog/v1/{warehouseId}/...`) lists
+  * and drops the namespaces/tables inside one. The channel is unauthenticated 
today;
+  * catalog-side authentication is Phase 2 (#6040).
+  *
+  * @param catalogUri the Iceberg REST catalog uri (ends with `/catalog`), 
from which the
+  *                   management base is derived. Overridable for tests.
+  */
+class LakekeeperClient(catalogUri: String = 
StorageConfig.icebergRESTCatalogUri) {
+
+  // Lakekeeper's default project; single-project deployments (ours) use the 
nil UUID.
+  private val DefaultProjectId = "00000000-0000-0000-0000-000000000000"
+
+  private val managementBase: String = catalogUri.stripSuffix("/catalog") + 
"/management/v1"
+  private val catalogBase: String = catalogUri + "/v1"
+
+  private val mapper = new ObjectMapper()
+
+  private def urlEncode(segment: String): String =
+    URLEncoder.encode(segment, StandardCharsets.UTF_8)
+
+  private def failOn(status: Int, body: String, action: String): Unit =
+    if (status < 200 || status >= 300) {
+      throw new RuntimeException(s"Lakekeeper $action failed (HTTP $status): 
$body")
+    }
+
+  /**
+    * Creates a warehouse backed by this deployment's own object store (the 
Local flavor):
+    * the storage profile points at the configured MinIO/S3 endpoint and 
bucket, with the
+    * platform's static credentials and STS off.
+    *
+    * @return the Lakekeeper-assigned warehouse id.
+    */
+  def createWarehouse(warehouseName: String): UUID = {
+    val payload = mapper.createObjectNode()
+    payload.put("warehouse-name", warehouseName)
+    payload.put("project-id", DefaultProjectId)
+
+    val profile = payload.putObject("storage-profile")
+    profile.put("type", "s3")
+    profile.put("bucket", StorageConfig.icebergRESTCatalogS3Bucket)
+    profile.put("region", StorageConfig.s3Region)
+    profile.put("endpoint", StorageConfig.s3Endpoint)
+    profile.put("path-style-access", true)
+    // The warehouse name doubles as the key prefix, so each warehouse owns a 
distinct
+    // subtree of the shared bucket.
+    profile.put("key-prefix", warehouseName)
+    profile.put("flavor", "s3-compat")
+    profile.put("sts-enabled", false)
+
+    val credential = payload.putObject("storage-credential")
+    credential.put("type", "s3")
+    credential.put("credential-type", "access-key")
+    credential.put("aws-access-key-id", StorageConfig.s3Username)
+    credential.put("aws-secret-access-key", StorageConfig.s3Password)
+
+    val response = Unirest
+      .post(s"$managementBase/warehouse")
+      .header("Content-Type", "application/json")
+      .body(payload.toString)
+      .asString()
+    failOn(response.getStatus, response.getBody, s"create warehouse 
'$warehouseName'")
+    
UUID.fromString(mapper.readTree(response.getBody).get("warehouse-id").asText())
+  }
+
+  /**
+    * Deletes a warehouse **empty-first** (Lakekeeper refuses to drop a 
non-empty one):
+    * every table is dropped with `purgeRequested=true` — so the underlying 
data files are
+    * purged along with it, matching how execution results are deleted today — 
then the
+    * namespaces, then the warehouse entity itself.
+    */
+  def deleteWarehouseEmptyFirst(warehouseId: UUID): Unit = {
+    // 404 anywhere below means the entity is already gone — the goal state. 
Tolerating
+    // it makes this method idempotent, so a retry after a partial failure 
(e.g. the DB
+    // delete failing after the Lakekeeper delete succeeded) heals instead of 
wedging.
+    listNamespaces(warehouseId).foreach { namespace =>
+      listTables(warehouseId, namespace).foreach { table =>
+        val response = Unirest
+          .delete(
+            
s"$catalogBase/$warehouseId/namespaces/${urlEncode(namespace)}/tables/${urlEncode(table)}"
+          )
+          .queryString("purgeRequested", "true")
+          .asString()
+        if (response.getStatus != 404) {
+          failOn(response.getStatus, response.getBody, s"drop table 
'$namespace.$table'")
+        }
+      }
+      val response = Unirest
+        
.delete(s"$catalogBase/$warehouseId/namespaces/${urlEncode(namespace)}")
+        .asString()
+      if (response.getStatus != 404) {
+        failOn(response.getStatus, response.getBody, s"drop namespace 
'$namespace'")
+      }
+    }
+    val response = 
Unirest.delete(s"$managementBase/warehouse/$warehouseId").asString()
+    if (response.getStatus != 404) {
+      failOn(response.getStatus, response.getBody, "delete warehouse")
+    }
+  }
+
+  /** Top-level namespaces in the warehouse. Texera's execution namespaces are 
single-level. */
+  private def listNamespaces(warehouseId: UUID): List[String] =
+    fetchAllPages(s"$catalogBase/$warehouseId/namespaces", "namespaces", "list 
namespaces")(parts =>
+      parts.get(0).asText()
+    )
+
+  private def listTables(warehouseId: UUID, namespace: String): List[String] =
+    fetchAllPages(
+      s"$catalogBase/$warehouseId/namespaces/${urlEncode(namespace)}/tables",
+      "identifiers",
+      s"list tables of '$namespace'"
+    )(identifier => identifier.get("name").asText())
+
+  /**
+    * Follows `next-page-token` until exhausted: the Iceberg REST list 
endpoints may
+    * return partial pages, and the empty-first delete depends on seeing 
everything.
+    * A 404 ends the listing with what was gathered — the entity is already 
gone,
+    * which the idempotent delete treats as its goal state.
+    */
+  private def fetchAllPages(url: String, field: String, action: String)(
+      extract: JsonNode => String
+  ): List[String] = {
+    val results = ListBuffer[String]()
+    var pageToken: Option[String] = None
+    var more = true
+    while (more) {
+      val request = Unirest.get(url)
+      pageToken.foreach(request.queryString("pageToken", _))
+      val response = request.asString()
+      if (response.getStatus == 404) {
+        return results.toList
+      }
+      failOn(response.getStatus, response.getBody, action)
+      val tree = mapper.readTree(response.getBody)
+      tree.get(field).iterator().asScala.foreach(node => results += 
extract(node))
+      pageToken = Option(tree.get("next-page-token"))
+        .filterNot(_.isNull)
+        .map(_.asText())
+        .filter(_.nonEmpty)
+      more = pageToken.isDefined
+    }
+    results.toList
+  }
+}
diff --git 
a/amber/src/main/scala/org/apache/texera/web/service/ResultExportService.scala 
b/amber/src/main/scala/org/apache/texera/web/service/ResultExportService.scala
index 4e73a0e655..cf4fb56983 100644
--- 
a/amber/src/main/scala/org/apache/texera/web/service/ResultExportService.scala
+++ 
b/amber/src/main/scala/org/apache/texera/web/service/ResultExportService.scala
@@ -490,7 +490,11 @@ class ResultExportService(workflowIdentity: 
WorkflowIdentity, computingUnitId: I
     )
 
     storageUri
-      .map(uri => 
DocumentFactory.openDocument(uri)._1.asInstanceOf[VirtualDocument[Tuple]])
+      .map(uri => {
+        // Refuse to export a per-user-warehouse result while the feature is 
off (#6930).
+        WarehouseReadGuard.assertReadable(uri)
+        
DocumentFactory.openDocument(uri)._1.asInstanceOf[VirtualDocument[Tuple]]
+      })
       .orNull
   }
 
diff --git 
a/amber/src/main/scala/org/apache/texera/web/service/WarehouseReadGuard.scala 
b/amber/src/main/scala/org/apache/texera/web/service/WarehouseReadGuard.scala
new file mode 100644
index 0000000000..1e98f86749
--- /dev/null
+++ 
b/amber/src/main/scala/org/apache/texera/web/service/WarehouseReadGuard.scala
@@ -0,0 +1,75 @@
+/*
+ * 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.web.service
+
+import org.apache.texera.amber.core.storage.VFSURIFactory
+import org.apache.texera.common.config.StorageConfig
+
+import java.net.URI
+
+/**
+  * Signals that a read was refused because it targets a per-user warehouse 
the deployment
+  * cannot serve. A distinct type so callers with a catch-all (e.g. 
SyncExecutionResource,
+  * which degrades other failures into empty results) can let this one 
propagate: a kill
+  * switch that silently returns "no data" is exactly the failure mode #6930 
forbids.
+  */
+class WarehouseUnavailableException(message: String) extends 
IllegalStateException(message)
+
+/**
+  * Guards reads of results that live in a per-user warehouse while the 
feature is off (#6930).
+  *
+  * The warehouse switch is a kill switch: turning it off must disable reads 
too, and it must
+  * fail *explicitly*. Without this guard a `/wh/<name>/…` URI would resolve 
to the shared
+  * default warehouse and surface "table not found" — indistinguishable from 
data loss. No data
+  * is lost; re-enabling the switch restores access.
+  */
+object WarehouseReadGuard {
+
+  def assertReadable(
+      uri: URI,
+      enabled: Boolean = StorageConfig.warehouseEnabled
+  ): Unit = {
+    val warehouse = VFSURIFactory.decodeURI(uri).warehouse
+    // A `/wh/` prefix that decodes to no warehouse is unresolvable (illegal 
name); opening
+    // it would silently fall back to the shared warehouse — refuse instead 
(#6930).
+    if (warehouse.isEmpty && hasWarehousePrefix(uri)) {
+      throw new WarehouseUnavailableException(s"unresolvable warehouse URI: 
$uri")
+    }
+    warehouse.filterNot(_ => enabled).foreach { name =>
+      throw new WarehouseUnavailableException(
+        s"this result is stored in warehouse '$name'; per-user warehouses are 
disabled in this deployment"
+      )
+    }
+  }
+
+  /**
+    * Whether a cleanup path should skip this URI: while the feature is off, 
best-effort
+    * cleanup must not reach into (and delete from) a disabled per-user 
warehouse. Loud
+    * failure is wrong here — cleanup runs on unrelated actions — so callers 
skip instead.
+    */
+  def skipWhileDisabled(
+      uri: URI,
+      enabled: Boolean = StorageConfig.warehouseEnabled
+  ): Boolean =
+    !enabled && (VFSURIFactory.decodeURI(uri).warehouse.isDefined || 
hasWarehousePrefix(uri))
+
+  private def hasWarehousePrefix(uri: URI): Boolean =
+    Option(uri.getRawPath).exists(_.startsWith("/wh/"))
+}
diff --git 
a/amber/src/main/scala/org/apache/texera/web/service/WorkflowService.scala 
b/amber/src/main/scala/org/apache/texera/web/service/WorkflowService.scala
index 51d5b5677d..a1cc08727b 100644
--- a/amber/src/main/scala/org/apache/texera/web/service/WorkflowService.scala
+++ b/amber/src/main/scala/org/apache/texera/web/service/WorkflowService.scala
@@ -23,7 +23,9 @@ import com.google.protobuf.timestamp.Timestamp
 import com.typesafe.scalalogging.LazyLogging
 import io.reactivex.rxjava3.disposables.{CompositeDisposable, Disposable}
 import io.reactivex.rxjava3.subjects.BehaviorSubject
-import org.apache.texera.common.config.ApplicationConfig
+import org.apache.texera.common.config.{ApplicationConfig, StorageConfig}
+import org.apache.texera.dao.SqlServer
+import org.apache.texera.dao.jooq.generated.Tables.USER_WAREHOUSE
 import org.apache.texera.amber.core.WorkflowRuntimeException
 import org.apache.texera.amber.core.storage.DocumentFactory
 import org.apache.texera.amber.core.storage.result.iceberg.OnIceberg
@@ -67,6 +69,39 @@ import scala.jdk.CollectionConverters.IterableHasAsScala
 
 object WorkflowService {
   private val workflowServiceMapping = new ConcurrentHashMap[String, 
WorkflowService]()
+
+  /**
+    * Maps an execution's chosen warehouse (`whid`) to its Lakekeeper 
warehouse name,
+    * checking that the requesting user owns it. `None` (no explicit pick) 
keeps the
+    * shared default warehouse. With warehouses disabled, an explicit pick is 
refused
+    * loudly rather than silently routed into the shared warehouse (#6930).
+    */
+  def resolveWarehouseName(
+      warehouseId: Option[Int],
+      uid: Integer,
+      enabled: Boolean = StorageConfig.warehouseEnabled
+  ): Option[String] = {
+    if (!enabled) {
+      warehouseId.foreach(_ =>
+        throw new IllegalArgumentException(
+          "per-user warehouses are disabled in this deployment"
+        )
+      )
+      return None
+    }
+    warehouseId.map(whid => {
+      val row = SqlServer
+        .getInstance()
+        .createDSLContext()
+        .selectFrom(USER_WAREHOUSE)
+        .where(USER_WAREHOUSE.WHID.eq(whid).and(USER_WAREHOUSE.UID.eq(uid)))
+        .fetchOne()
+      if (row == null) {
+        throw new IllegalArgumentException(s"no warehouse with id $whid owned 
by this user")
+      }
+      row.getWarehouseName
+    })
+  }
   val cleanUpDeadlineInSeconds: Int = 
ApplicationConfig.executionStateCleanUpInSecs
 
   def getAllWorkflowServices: Iterable[WorkflowService] = 
workflowServiceMapping.values().asScala
@@ -198,6 +233,7 @@ class WorkflowService(
     )
 
     val workflowContext: WorkflowContext = createWorkflowContext()
+    workflowContext.warehouse = 
WorkflowService.resolveWarehouseName(req.warehouseId, uid)
     var coordinatorConf = CoordinatorConfig.default
 
     // clean up results from previous run
@@ -212,7 +248,8 @@ class WorkflowService(
       uid,
       req.executionName,
       convertToJson(req.engineVersion),
-      req.computingUnitId
+      req.computingUnitId,
+      req.warehouseId
     )
 
     if (ApplicationConfig.faultToleranceLogRootFolder.isDefined) {
@@ -333,30 +370,37 @@ class WorkflowService(
     // Remove references from registry first
     WorkflowExecutionsResource.deleteConsoleMessageAndExecutionResultUris(eid)
 
-    // Clean up all result and console message documents
+    // Clean up all result and console message documents. While per-user 
warehouses are
+    // disabled, cleanup must not reach into them (#6930) — those URIs are 
skipped.
     (resultUris ++ consoleMessagesUris).foreach { uri =>
-      try DocumentFactory.openDocument(uri)._1.clear()
-      catch {
-        case error: Throwable =>
-          logger.debug(s"Error processing document at $uri: 
${error.getMessage}")
-      }
+      if (WarehouseReadGuard.skipWhileDisabled(uri)) {
+        logger.info(s"skipping cleanup of $uri: per-user warehouses are 
disabled")
+      } else
+        try DocumentFactory.openDocument(uri)._1.clear()
+        catch {
+          case error: Throwable =>
+            logger.debug(s"Error processing document at $uri: 
${error.getMessage}")
+        }
     }
 
     // Expire any Iceberg snapshots for runtime statistics
     WorkflowExecutionsResource.getRuntimeStatsUriByExecutionId(eid).foreach { 
uri =>
-      try {
-        DocumentFactory.openDocument(uri)._1 match {
-          case iceberg: OnIceberg => iceberg.expireSnapshots()
-          case other =>
-            logger.error(
-              s"Cannot expire snapshots: document from URI [$uri] is of type 
${other.getClass.getName}. " +
-                s"Expected an instance of ${classOf[OnIceberg].getName}."
-            )
+      if (WarehouseReadGuard.skipWhileDisabled(uri)) {
+        logger.info(s"skipping snapshot expiry of $uri: per-user warehouses 
are disabled")
+      } else
+        try {
+          DocumentFactory.openDocument(uri)._1 match {
+            case iceberg: OnIceberg => iceberg.expireSnapshots()
+            case other =>
+              logger.error(
+                s"Cannot expire snapshots: document from URI [$uri] is of type 
${other.getClass.getName}. " +
+                  s"Expected an instance of ${classOf[OnIceberg].getName}."
+              )
+          }
+        } catch {
+          case error: Throwable =>
+            logger.debug(s"Error processing document at $uri: 
${error.getMessage}")
         }
-      } catch {
-        case error: Throwable =>
-          logger.debug(s"Error processing document at $uri: 
${error.getMessage}")
-      }
     }
     // Delete this execution's large binaries
     LargeBinaryManager.deleteByExecution(eid.id)
diff --git 
a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/warehouse/WarehouseResourceSpec.scala
 
b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/warehouse/WarehouseResourceSpec.scala
new file mode 100644
index 0000000000..1d3aeb9eed
--- /dev/null
+++ 
b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/warehouse/WarehouseResourceSpec.scala
@@ -0,0 +1,233 @@
+/*
+ * 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.web.resource.dashboard.user.warehouse
+
+import org.apache.texera.auth.SessionUser
+import org.apache.texera.common.config.StorageConfig
+import org.apache.texera.dao.MockTexeraDB
+import org.apache.texera.dao.jooq.generated.Tables.USER_WAREHOUSE
+import org.apache.texera.dao.jooq.generated.tables.daos.UserDao
+import org.apache.texera.dao.jooq.generated.tables.pojos.User
+import 
org.apache.texera.web.resource.dashboard.user.warehouse.WarehouseResource.CreateWarehouseRequest
+import org.apache.texera.web.service.LakekeeperClient
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach}
+
+import java.util.UUID
+import javax.ws.rs.{
+  BadRequestException,
+  ForbiddenException,
+  NotFoundException,
+  WebApplicationException
+}
+import scala.collection.mutable
+
+/**
+  * Spec for [[WarehouseResource]] (#6932): the disabled-gate behavior, and the
+  * create/list/delete flow against MockTexeraDB with a stubbed 
[[LakekeeperClient]].
+  *
+  * The feature flag is a constructor dependency, so the two gate states are 
two resource
+  * instances — no global state is touched, and suites cannot interfere with 
each other.
+  */
+class WarehouseResourceSpec
+    extends AnyFlatSpec
+    with Matchers
+    with BeforeAndAfterAll
+    with BeforeAndAfterEach
+    with MockTexeraDB {
+
+  private val createdNames = mutable.Buffer[String]()
+  private val deletedIds = mutable.Buffer[UUID]()
+  private val stubWarehouseId = UUID.randomUUID()
+
+  @volatile private var createFailure: Option[Exception] = None
+  @volatile private var deleteFailure: Option[Exception] = None
+
+  private val stubClient: LakekeeperClient = new LakekeeperClient() {
+    override def createWarehouse(warehouseName: String): UUID = {
+      createFailure.foreach(throw _)
+      createdNames += warehouseName
+      stubWarehouseId
+    }
+    override def deleteWarehouseEmptyFirst(warehouseId: UUID): Unit = {
+      deleteFailure.foreach(throw _)
+      deletedIds += warehouseId
+    }
+  }
+
+  private val resource = new WarehouseResource(stubClient, enabled = true)
+  private val disabledResource = new WarehouseResource(stubClient, enabled = 
false)
+  private var sessionUser: SessionUser = _
+  private var otherUser: SessionUser = _
+
+  override protected def beforeAll(): Unit = {
+    initializeDBAndReplaceDSLContext()
+
+    val userDao = new UserDao(getDSLContext.configuration())
+    val user = new User
+    user.setName("warehouse_spec_user")
+    user.setEmail(s"user_${UUID.randomUUID()}@example.com")
+    userDao.insert(user)
+    sessionUser = new SessionUser(user)
+
+    val other = new User
+    other.setName("warehouse_spec_other")
+    other.setEmail(s"user_${UUID.randomUUID()}@example.com")
+    userDao.insert(other)
+    otherUser = new SessionUser(other)
+  }
+
+  override protected def afterAll(): Unit = closeConnectionPool()
+
+  override protected def beforeEach(): Unit = {
+    createFailure = None
+    deleteFailure = None
+    createdNames.clear()
+    deletedIds.clear()
+    getDSLContext.deleteFrom(USER_WAREHOUSE).execute()
+  }
+
+  // 
---------------------------------------------------------------------------
+  // Disabled gate
+  // 
---------------------------------------------------------------------------
+
+  "status" should "report disabled with no warehouses while the flag is off" 
in {
+    val status = disabledResource.status(sessionUser)
+    status.enabled shouldBe false
+    status.warehouses shouldBe empty
+  }
+
+  "create and delete" should "be refused while the flag is off" in {
+    a[ForbiddenException] should be thrownBy
+      disabledResource.create(CreateWarehouseRequest("mybucket"), sessionUser)
+    a[ForbiddenException] should be thrownBy disabledResource.delete(1, 
sessionUser)
+  }
+
+  // 
---------------------------------------------------------------------------
+  // Create / list / delete
+  // 
---------------------------------------------------------------------------
+
+  "create" should "create in Lakekeeper, record the row, and mint 
user-<uid>-<name>" in {
+    val created = resource.create(CreateWarehouseRequest("mybucket"), 
sessionUser)
+
+    created.name shouldBe "mybucket"
+    created.warehouseName shouldBe s"user-${sessionUser.getUid}-mybucket"
+    created.flavor shouldBe "local"
+    createdNames.toList shouldBe List(s"user-${sessionUser.getUid}-mybucket")
+
+    val status = resource.status(sessionUser)
+    status.enabled shouldBe true
+    status.warehouses.map(_.whid) shouldBe List(created.whid)
+  }
+
+  it should "reject an unsafe or duplicate name" in {
+    a[BadRequestException] should be thrownBy
+      resource.create(CreateWarehouseRequest("a/b"), sessionUser)
+
+    resource.create(CreateWarehouseRequest("dup"), sessionUser)
+    val conflict = intercept[WebApplicationException] {
+      resource.create(CreateWarehouseRequest("dup"), sessionUser)
+    }
+    conflict.getResponse.getStatus shouldBe 409
+  }
+
+  "delete" should "empty the warehouse in Lakekeeper and remove the row" in {
+    val created = resource.create(CreateWarehouseRequest("doomed"), 
sessionUser)
+
+    resource.delete(created.whid, sessionUser)
+
+    deletedIds.toList shouldBe List(stubWarehouseId)
+    resource.status(sessionUser).warehouses shouldBe empty
+  }
+
+  "a failed record write after Lakekeeper creation" should "compensate by 
deleting the warehouse" in {
+    // Pre-claim the catalog name under the other user so our store() trips 
the global
+    // UNIQUE(warehouse_name) after the (stubbed) Lakekeeper creation 
succeeded.
+    val squatter = getDSLContext.newRecord(USER_WAREHOUSE)
+    squatter.setUid(otherUser.getUid)
+    squatter.setName("unrelated")
+    squatter.setWarehouseName(s"user-${sessionUser.getUid}-boom")
+    squatter.setLakekeeperWarehouseId(UUID.randomUUID())
+    squatter.setFlavor(
+      org.apache.texera.dao.jooq.generated.enums.UserWarehouseFlavorEnum.local
+    )
+    squatter.store()
+
+    val error = intercept[WebApplicationException] {
+      resource.create(CreateWarehouseRequest("boom"), sessionUser)
+    }
+    error.getResponse.getStatus shouldBe 500
+    // The just-created (empty) Lakekeeper warehouse must not be orphaned.
+    deletedIds.toList shouldBe List(stubWarehouseId)
+    resource.status(sessionUser).warehouses shouldBe empty
+  }
+
+  "a Lakekeeper failure" should "surface as 502 on create and delete" in {
+    createFailure = Some(new RuntimeException("Lakekeeper create failed (HTTP 
500): boom"))
+    val createError = intercept[WebApplicationException] {
+      resource.create(CreateWarehouseRequest("unlucky"), sessionUser)
+    }
+    createError.getResponse.getStatus shouldBe 502
+    resource.status(sessionUser).warehouses shouldBe empty
+
+    createFailure = None
+    val created = resource.create(CreateWarehouseRequest("undeletable"), 
sessionUser)
+    deleteFailure = Some(new RuntimeException("Lakekeeper delete failed (HTTP 
500): boom"))
+    val deleteError = intercept[WebApplicationException] {
+      resource.delete(created.whid, sessionUser)
+    }
+    deleteError.getResponse.getStatus shouldBe 502
+    // The row must survive a failed Lakekeeper delete, so the user can retry.
+    resource.status(sessionUser).warehouses.map(_.whid) shouldBe 
List(created.whid)
+  }
+
+  "a failed compensation" should "be logged and still surface the original 
failure" in {
+    val squatter = getDSLContext.newRecord(USER_WAREHOUSE)
+    squatter.setUid(otherUser.getUid)
+    squatter.setName("unrelated-2")
+    squatter.setWarehouseName(s"user-${sessionUser.getUid}-doublefault")
+    squatter.setLakekeeperWarehouseId(UUID.randomUUID())
+    squatter.setFlavor(
+      org.apache.texera.dao.jooq.generated.enums.UserWarehouseFlavorEnum.local
+    )
+    squatter.store()
+
+    deleteFailure = Some(new RuntimeException("cleanup also failed"))
+    val error = intercept[WebApplicationException] {
+      resource.create(CreateWarehouseRequest("doublefault"), sessionUser)
+    }
+    error.getResponse.getStatus shouldBe 500
+    resource.status(sessionUser).warehouses shouldBe empty
+  }
+
+  "the no-arg constructor" should "read the configured flag and client" in {
+    // Jersey instantiates the resource reflectively via this constructor; 
storage.conf
+    // keeps the feature off by default, so this exercises it without a 
Lakekeeper call.
+    new WarehouseResource().status(sessionUser).enabled shouldBe 
StorageConfig.warehouseEnabled
+  }
+
+  it should "not let a user delete someone else's warehouse" in {
+    val created = resource.create(CreateWarehouseRequest("mine"), sessionUser)
+
+    a[NotFoundException] should be thrownBy resource.delete(created.whid, 
otherUser)
+    resource.status(sessionUser).warehouses.map(_.whid) shouldBe 
List(created.whid)
+  }
+}
diff --git 
a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowExecutionsResourceSpec.scala
 
b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowExecutionsResourceSpec.scala
index a0a3622e06..48973699cf 100644
--- 
a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowExecutionsResourceSpec.scala
+++ 
b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowExecutionsResourceSpec.scala
@@ -30,6 +30,7 @@ import 
org.apache.texera.amber.core.workflow.{GlobalPortIdentity, PortIdentity}
 import org.apache.texera.amber.util.serde.GlobalPortIdentitySerde.SerdeOps
 import org.apache.texera.auth.SessionUser
 import org.apache.texera.dao.MockTexeraDB
+import org.apache.texera.dao.jooq.generated.enums.UserWarehouseFlavorEnum
 import org.apache.texera.dao.jooq.generated.Tables._
 import org.apache.texera.dao.jooq.generated.enums.{PrivilegeEnum, 
WorkflowComputingUnitTypeEnum}
 import org.apache.texera.dao.jooq.generated.tables.daos.{
@@ -217,7 +218,8 @@ class WorkflowExecutionsResourceSpec
       startOffsetMillis: Long = 0L,
       lastUpdateOffsetMillis: Option[Long] = None,
       cuid: Integer = null,
-      runtimeStatsUri: String = null
+      runtimeStatsUri: String = null,
+      whid: Integer = null
   ): WorkflowExecutions = {
     val execution = new WorkflowExecutions
     execution.setVid(testVersion.getVid)
@@ -232,6 +234,7 @@ class WorkflowExecutionsResourceSpec
     execution.setName(name)
     execution.setEnvironmentVersion("test-env-1.0")
     execution.setCuid(cuid)
+    execution.setWhid(whid)
     execution.setRuntimeStatsUri(runtimeStatsUri)
     workflowExecutionsDao.insert(execution)
     execution
@@ -988,6 +991,25 @@ class WorkflowExecutionsResourceSpec
     assert(entry.name == "second")
   }
 
+  it should "expose the execution's warehouse (whId) for last-used 
preselection" in {
+    grantReadAccess()
+    val warehouse = getDSLContext.newRecord(USER_WAREHOUSE)
+    warehouse.setUid(testUser.getUid)
+    warehouse.setName("latest-entry-warehouse")
+    
warehouse.setWarehouseName(s"user-${testUser.getUid}-latest-entry-warehouse")
+    warehouse.setLakekeeperWarehouseId(UUID.randomUUID())
+    warehouse.setFlavor(UserWarehouseFlavorEnum.local)
+    warehouse.store()
+
+    insertExecution(name = "warehouse-run", whid = warehouse.getWhid)
+    val entry = resource.retrieveLatestExecutionEntry(testWorkflowWid, 
session(testUser))
+    assert(entry.whId == warehouse.getWhid)
+
+    insertExecution(name = "default-run")
+    val defaultEntry = resource.retrieveLatestExecutionEntry(testWorkflowWid, 
session(testUser))
+    assert(defaultEntry.whId == null)
+  }
+
   "retrieveInteractionHistory" should "return an empty list when the user 
lacks read access" in {
     val result =
       resource.retrieveInteractionHistory(
diff --git 
a/amber/src/test/scala/org/apache/texera/web/service/ExecutionsMetadataPersistServiceSpec.scala
 
b/amber/src/test/scala/org/apache/texera/web/service/ExecutionsMetadataPersistServiceSpec.scala
index ca704861fe..bc49d67e56 100644
--- 
a/amber/src/test/scala/org/apache/texera/web/service/ExecutionsMetadataPersistServiceSpec.scala
+++ 
b/amber/src/test/scala/org/apache/texera/web/service/ExecutionsMetadataPersistServiceSpec.scala
@@ -25,6 +25,7 @@ import 
org.apache.texera.amber.engine.common.Utils.maptoStatusCode
 import 
org.apache.texera.amber.engine.common.executionruntimestate.ExecutionMetadataStore
 import org.apache.texera.dao.MockTexeraDB
 import org.apache.texera.dao.jooq.generated.Tables._
+import org.apache.texera.dao.jooq.generated.enums.UserWarehouseFlavorEnum
 import org.apache.texera.dao.jooq.generated.tables.daos.{
   UserDao,
   WorkflowComputingUnitDao,
@@ -222,6 +223,60 @@ class ExecutionsMetadataPersistServiceSpec
     workflowExecutionsDao.fetchByVid(seededVid).size() shouldBe before
   }
 
+  it should "record the chosen warehouse and leave it null when none is 
picked" in {
+    val row = getDSLContext.newRecord(USER_WAREHOUSE)
+    row.setUid(testUid)
+    row.setName("exec-spec-warehouse")
+    row.setWarehouseName(s"user-$testUid-exec-spec-warehouse")
+    row.setLakekeeperWarehouseId(UUID.randomUUID())
+    row.setFlavor(UserWarehouseFlavorEnum.local)
+    row.store()
+
+    val withWarehouse = ExecutionsMetadataPersistService.insertNewExecution(
+      WorkflowIdentity(testWid.toLong),
+      testUid,
+      executionName = "warehouse-run",
+      environmentVersion = "env-4",
+      computingUnitId = seededCuid,
+      warehouseId = Some(row.getWhid)
+    )
+    workflowExecutionsDao.fetchOneByEid(withWarehouse.id.toInt).getWhid 
shouldBe row.getWhid
+
+    val withoutWarehouse = ExecutionsMetadataPersistService.insertNewExecution(
+      WorkflowIdentity(testWid.toLong),
+      testUid,
+      executionName = "default-run",
+      environmentVersion = "env-4",
+      computingUnitId = seededCuid
+    )
+    workflowExecutionsDao.fetchOneByEid(withoutWarehouse.id.toInt).getWhid 
shouldBe null
+  }
+
+  it should "keep execution history when its warehouse is deleted (whid SET 
NULL)" in {
+    val row = getDSLContext.newRecord(USER_WAREHOUSE)
+    row.setUid(testUid)
+    row.setName("doomed-warehouse")
+    row.setWarehouseName(s"user-$testUid-doomed-warehouse")
+    row.setLakekeeperWarehouseId(UUID.randomUUID())
+    row.setFlavor(UserWarehouseFlavorEnum.local)
+    row.store()
+
+    val id = ExecutionsMetadataPersistService.insertNewExecution(
+      WorkflowIdentity(testWid.toLong),
+      testUid,
+      executionName = "history-run",
+      environmentVersion = "env-5",
+      computingUnitId = seededCuid,
+      warehouseId = Some(row.getWhid)
+    )
+
+    
getDSLContext.deleteFrom(USER_WAREHOUSE).where(USER_WAREHOUSE.WHID.eq(row.getWhid)).execute()
+
+    val stored = workflowExecutionsDao.fetchOneByEid(id.id.toInt)
+    stored should not be null
+    stored.getWhid shouldBe null
+  }
+
   // -- tryGetExistingExecution 
------------------------------------------------
 
   "tryGetExistingExecution" should "return Some(row) for a known eid" in {
diff --git 
a/amber/src/test/scala/org/apache/texera/web/service/LakekeeperClientSpec.scala 
b/amber/src/test/scala/org/apache/texera/web/service/LakekeeperClientSpec.scala
new file mode 100644
index 0000000000..ac047c590c
--- /dev/null
+++ 
b/amber/src/test/scala/org/apache/texera/web/service/LakekeeperClientSpec.scala
@@ -0,0 +1,171 @@
+/*
+ * 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.web.service
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.sun.net.httpserver.{HttpExchange, HttpServer}
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach}
+
+import java.net.InetSocketAddress
+import java.nio.charset.StandardCharsets
+import java.util.UUID
+import scala.collection.mutable
+
+/**
+  * Exercises [[LakekeeperClient]] end-to-end against an in-process HTTP stub 
standing in
+  * for Lakekeeper (same approach as AsterixDBConnUtilSpec). No network 
dependency; the
+  * stub binds port 0 to pick a free ephemeral port.
+  */
+class LakekeeperClientSpec
+    extends AnyFlatSpec
+    with Matchers
+    with BeforeAndAfterAll
+    with BeforeAndAfterEach {
+
+  private val mapper = new ObjectMapper()
+  private val warehouseId = UUID.randomUUID()
+
+  // Every request in arrival order, as "METHOD path?query" plus the body for 
POSTs.
+  private val requests = mutable.Buffer[String]()
+  @volatile private var lastCreateBody: String = ""
+
+  private val server: HttpServer = HttpServer.create(new InetSocketAddress(0), 
0)
+
+  private def record(exchange: HttpExchange): String = {
+    val query = Option(exchange.getRequestURI.getQuery).map("?" + 
_).getOrElse("")
+    val line = s"${exchange.getRequestMethod} 
${exchange.getRequestURI.getPath}$query"
+    requests.synchronized { requests += line }
+    line
+  }
+
+  private def respond(exchange: HttpExchange, status: Int, body: String): Unit 
= {
+    val bytes = body.getBytes(StandardCharsets.UTF_8)
+    exchange.getResponseHeaders.add("Content-Type", "application/json")
+    exchange.sendResponseHeaders(status, bytes.length)
+    exchange.getResponseBody.write(bytes)
+    exchange.close()
+  }
+
+  server.createContext(
+    "/management/v1/warehouse",
+    (exchange: HttpExchange) => {
+      record(exchange)
+      if (exchange.getRequestMethod == "POST") {
+        lastCreateBody = new String(exchange.getRequestBody.readAllBytes(), 
StandardCharsets.UTF_8)
+        respond(exchange, 201, s"""{"warehouse-id": "$warehouseId"}""")
+      } else {
+        respond(exchange, 200, "{}")
+      }
+    }
+  )
+  server.createContext(
+    s"/catalog/v1/$warehouseId/namespaces",
+    (exchange: HttpExchange) => {
+      val line = record(exchange)
+      val query = Option(exchange.getRequestURI.getQuery).getOrElse("")
+      (exchange.getRequestMethod, exchange.getRequestURI.getPath) match {
+        case ("GET", path) if path.endsWith("/namespaces") =>
+          respond(exchange, 200, """{"namespaces": 
[["operator-port-result"]]}""")
+        // The tables listing is served in TWO pages so the client's 
next-page-token
+        // loop is pinned: missing the second page would leave the warehouse 
non-empty.
+        case ("GET", path) if path.endsWith("/tables") && 
!query.contains("pageToken") =>
+          respond(
+            exchange,
+            200,
+            """{"identifiers": [{"namespace": ["operator-port-result"], 
"name": "wid_1_eid_2_result"}],
+               |"next-page-token": "page-2"}""".stripMargin
+          )
+        case ("GET", path) if path.endsWith("/tables") =>
+          respond(
+            exchange,
+            200,
+            """{"identifiers": [{"namespace": ["operator-port-result"], 
"name": "wid_1_eid_3_result"}]}"""
+          )
+        case ("DELETE", _) =>
+          respond(exchange, 204, "")
+        case _ =>
+          respond(exchange, 500, s"""{"error": "unexpected $line"}""")
+      }
+    }
+  )
+  private val erroringWarehouseId = UUID.randomUUID()
+  server.createContext(
+    s"/catalog/v1/$erroringWarehouseId/namespaces",
+    (exchange: HttpExchange) => {
+      record(exchange)
+      respond(exchange, 500, """{"error": "internal"}""")
+    }
+  )
+  server.start()
+
+  private val client = new LakekeeperClient(
+    s"http://localhost:${server.getAddress.getPort}/catalog";
+  )
+
+  override protected def beforeEach(): Unit = {
+    requests.synchronized { requests.clear() }
+    lastCreateBody = ""
+  }
+
+  override protected def afterAll(): Unit = server.stop(0)
+
+  "createWarehouse" should "post the Local storage profile and return the 
assigned id" in {
+    client.createWarehouse("user-7-mybucket") shouldBe warehouseId
+
+    val payload = mapper.readTree(lastCreateBody)
+    payload.get("warehouse-name").asText() shouldBe "user-7-mybucket"
+    val profile = payload.get("storage-profile")
+    profile.get("type").asText() shouldBe "s3"
+    profile.get("sts-enabled").asBoolean() shouldBe false
+    profile.get("path-style-access").asBoolean() shouldBe true
+    // Each warehouse owns its own key prefix inside the shared bucket.
+    profile.get("key-prefix").asText() shouldBe "user-7-mybucket"
+    payload.get("storage-credential").get("credential-type").asText() shouldBe 
"access-key"
+  }
+
+  "deleteWarehouseEmptyFirst" should "purge every page of tables, then 
namespaces, then the warehouse" in {
+    client.deleteWarehouseEmptyFirst(warehouseId)
+
+    val deletes = requests.synchronized { 
requests.filter(_.startsWith("DELETE")).toList }
+    deletes shouldBe List(
+      s"DELETE 
/catalog/v1/$warehouseId/namespaces/operator-port-result/tables/wid_1_eid_2_result?purgeRequested=true",
+      s"DELETE 
/catalog/v1/$warehouseId/namespaces/operator-port-result/tables/wid_1_eid_3_result?purgeRequested=true",
+      s"DELETE /catalog/v1/$warehouseId/namespaces/operator-port-result",
+      s"DELETE /management/v1/warehouse/$warehouseId"
+    )
+  }
+
+  it should "treat an already-gone warehouse as deleted (404s tolerated, 
idempotent)" in {
+    val goneId = UUID.randomUUID()
+    // No stub context matches this warehouse → every request 404s. A retry 
after a
+    // partial failure must heal rather than wedge on \"not found\".
+    noException should be thrownBy client.deleteWarehouseEmptyFirst(goneId)
+  }
+
+  it should "surface a Lakekeeper failure with its status and body" in {
+    val error = intercept[RuntimeException] {
+      client.deleteWarehouseEmptyFirst(erroringWarehouseId)
+    }
+    error.getMessage should include("Lakekeeper")
+    error.getMessage should include("500")
+  }
+}
diff --git 
a/amber/src/test/scala/org/apache/texera/web/service/WarehouseReadGuardSpec.scala
 
b/amber/src/test/scala/org/apache/texera/web/service/WarehouseReadGuardSpec.scala
new file mode 100644
index 0000000000..0046507797
--- /dev/null
+++ 
b/amber/src/test/scala/org/apache/texera/web/service/WarehouseReadGuardSpec.scala
@@ -0,0 +1,106 @@
+/*
+ * 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.web.service
+
+import org.scalatest.flatspec.AnyFlatSpec
+
+import java.net.URI
+import org.scalatest.matchers.should.Matchers
+
+class WarehouseReadGuardSpec extends AnyFlatSpec with Matchers {
+
+  private def uri(path: String) = new URI(s"vfs://$path")
+
+  "assertReadable" should "pass results that live in the shared default 
warehouse" in {
+    noException should be thrownBy
+      WarehouseReadGuard.assertReadable(uri("/wid/1/eid/2/result"), enabled = 
false)
+    noException should be thrownBy
+      WarehouseReadGuard.assertReadable(uri("/wid/1/eid/2/result"), enabled = 
true)
+  }
+
+  it should "pass warehouse results while the feature is enabled" in {
+    noException should be thrownBy
+      WarehouseReadGuard.assertReadable(
+        uri("/wh/user-7-mybucket/wid/1/eid/2/result"),
+        enabled = true
+      )
+  }
+
+  it should "refuse a warehouse result explicitly while the feature is off" in 
{
+    // Naming the situation matters: resolving the URI against the shared 
warehouse
+    // would surface "table not found" — indistinguishable from data loss 
(#6930).
+    val error = intercept[IllegalStateException] {
+      WarehouseReadGuard.assertReadable(
+        uri("/wh/user-7-mybucket/wid/1/eid/2/result"),
+        enabled = false
+      )
+    }
+    error.getMessage should include("user-7-mybucket")
+    error.getMessage should include("disabled")
+  }
+
+  it should "refuse an unresolvable /wh/ prefix instead of falling back 
silently" in {
+    // decodeURI reports None for an illegal warehouse name; opening such a 
URI would
+    // silently resolve against the shared warehouse — in either flag state.
+    an[IllegalStateException] should be thrownBy
+      WarehouseReadGuard.assertReadable(uri("/wh/a%2Fb/wid/1/eid/2/result"), 
enabled = true)
+    an[IllegalStateException] should be thrownBy
+      WarehouseReadGuard.assertReadable(uri("/wh/a%2Fb/wid/1/eid/2/result"), 
enabled = false)
+  }
+
+  it should "refuse with a typed exception callers can let through their 
catch-alls" in {
+    // SyncExecutionResource degrades other failures into empty results; the 
kill-switch
+    // refusal is typed so it can be rethrown there instead of vanishing 
(#6930).
+    a[WarehouseUnavailableException] should be thrownBy
+      WarehouseReadGuard.assertReadable(
+        uri("/wh/user-7-mybucket/wid/1/eid/2/result"),
+        enabled = false
+      )
+    a[WarehouseUnavailableException] should be thrownBy
+      WarehouseReadGuard.assertReadable(uri("/wh/a%2Fb/wid/1/eid/2/result"), 
enabled = true)
+  }
+
+  "the default enabled argument" should "read the configured flag" in {
+    // Covers the default-argument methods; a default-warehouse URI passes and 
is
+    // never skipped in either flag state, so this is deterministic regardless 
of
+    // the configured value.
+    noException should be thrownBy 
WarehouseReadGuard.assertReadable(uri("/wid/1/eid/2/result"))
+    WarehouseReadGuard.skipWhileDisabled(uri("/wid/1/eid/2/result")) shouldBe 
false
+  }
+
+  "skipWhileDisabled" should "skip exactly the warehouse-scoped URIs while the 
feature is off" in {
+    WarehouseReadGuard.skipWhileDisabled(
+      uri("/wh/user-7-mybucket/wid/1/eid/2/result"),
+      enabled = false
+    ) shouldBe true
+    WarehouseReadGuard.skipWhileDisabled(
+      uri("/wh/a%2Fb/wid/1/eid/2/result"),
+      enabled = false
+    ) shouldBe true
+    WarehouseReadGuard.skipWhileDisabled(
+      uri("/wid/1/eid/2/result"),
+      enabled = false
+    ) shouldBe false
+    WarehouseReadGuard.skipWhileDisabled(
+      uri("/wh/user-7-mybucket/wid/1/eid/2/result"),
+      enabled = true
+    ) shouldBe false
+  }
+}
diff --git 
a/amber/src/test/scala/org/apache/texera/web/service/WorkflowExecutionServiceSpec.scala
 
b/amber/src/test/scala/org/apache/texera/web/service/WorkflowExecutionServiceSpec.scala
index 2edc102234..d8af24de4b 100644
--- 
a/amber/src/test/scala/org/apache/texera/web/service/WorkflowExecutionServiceSpec.scala
+++ 
b/amber/src/test/scala/org/apache/texera/web/service/WorkflowExecutionServiceSpec.scala
@@ -68,7 +68,8 @@ class WorkflowExecutionServiceSpec extends AnyFlatSpec with 
Matchers {
       replayFromExecution = None,
       workflowSettings = WorkflowSettings(),
       emailNotificationEnabled = false,
-      computingUnitId = 0
+      computingUnitId = 0,
+      warehouseId = None
     )
     new WorkflowExecutionService(
       null,
diff --git 
a/amber/src/test/scala/org/apache/texera/web/service/WorkflowServiceWarehouseSpec.scala
 
b/amber/src/test/scala/org/apache/texera/web/service/WorkflowServiceWarehouseSpec.scala
new file mode 100644
index 0000000000..ef7d8cfb7c
--- /dev/null
+++ 
b/amber/src/test/scala/org/apache/texera/web/service/WorkflowServiceWarehouseSpec.scala
@@ -0,0 +1,99 @@
+/*
+ * 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.web.service
+
+import org.apache.texera.dao.MockTexeraDB
+import org.apache.texera.dao.jooq.generated.Tables.USER_WAREHOUSE
+import org.apache.texera.dao.jooq.generated.enums.UserWarehouseFlavorEnum
+import org.apache.texera.dao.jooq.generated.tables.daos.UserDao
+import org.apache.texera.dao.jooq.generated.tables.pojos.User
+import org.scalatest.BeforeAndAfterAll
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+import java.util.UUID
+
+/**
+  * Spec for [[WorkflowService.resolveWarehouseName]] (#6932): the ownership 
check and the
+  * never-silently-fall-back rule for an execution's chosen warehouse.
+  */
+class WorkflowServiceWarehouseSpec
+    extends AnyFlatSpec
+    with Matchers
+    with BeforeAndAfterAll
+    with MockTexeraDB {
+
+  private var ownerUid: Integer = _
+  private var intruderUid: Integer = _
+  private var whid: Integer = _
+
+  override protected def beforeAll(): Unit = {
+    initializeDBAndReplaceDSLContext()
+
+    val userDao = new UserDao(getDSLContext.configuration())
+    val owner = new User
+    owner.setName("resolve_spec_owner")
+    owner.setEmail(s"user_${UUID.randomUUID()}@example.com")
+    userDao.insert(owner)
+    ownerUid = owner.getUid
+
+    val intruder = new User
+    intruder.setName("resolve_spec_intruder")
+    intruder.setEmail(s"user_${UUID.randomUUID()}@example.com")
+    userDao.insert(intruder)
+    intruderUid = intruder.getUid
+
+    val row = getDSLContext.newRecord(USER_WAREHOUSE)
+    row.setUid(ownerUid)
+    row.setName("mybucket")
+    row.setWarehouseName(s"user-$ownerUid-mybucket")
+    row.setLakekeeperWarehouseId(UUID.randomUUID())
+    row.setFlavor(UserWarehouseFlavorEnum.local)
+    row.store()
+    whid = row.getWhid
+  }
+
+  override protected def afterAll(): Unit = closeConnectionPool()
+
+  "resolveWarehouseName" should "keep the shared default warehouse when 
nothing is picked" in {
+    WorkflowService.resolveWarehouseName(None, ownerUid, enabled = true) 
shouldBe None
+    WorkflowService.resolveWarehouseName(None, ownerUid, enabled = false) 
shouldBe None
+  }
+
+  it should "resolve an owned warehouse to its Lakekeeper name" in {
+    WorkflowService.resolveWarehouseName(Some(whid), ownerUid, enabled = true) 
shouldBe
+      Some(s"user-$ownerUid-mybucket")
+  }
+
+  it should "refuse another user's warehouse" in {
+    val error = intercept[IllegalArgumentException] {
+      WorkflowService.resolveWarehouseName(Some(whid), intruderUid, enabled = 
true)
+    }
+    error.getMessage should include(whid.toString)
+  }
+
+  it should "refuse an explicit pick while warehouses are disabled" in {
+    // Never route the run into the shared warehouse silently (#6930).
+    val error = intercept[IllegalArgumentException] {
+      WorkflowService.resolveWarehouseName(Some(whid), ownerUid, enabled = 
false)
+    }
+    error.getMessage should include("disabled")
+  }
+}
diff --git 
a/common/config/src/main/scala/org/apache/texera/common/config/StorageConfig.scala
 
b/common/config/src/main/scala/org/apache/texera/common/config/StorageConfig.scala
index 2f9a33c291..9a98108e19 100644
--- 
a/common/config/src/main/scala/org/apache/texera/common/config/StorageConfig.scala
+++ 
b/common/config/src/main/scala/org/apache/texera/common/config/StorageConfig.scala
@@ -39,6 +39,8 @@ object StorageConfig {
   val icebergRESTCatalogUri: String = 
conf.getString("storage.iceberg.catalog.rest.uri")
   val icebergRESTCatalogWarehouseName: String =
     conf.getString("storage.iceberg.catalog.rest.warehouse-name")
+  val icebergRESTCatalogS3Bucket: String =
+    conf.getString("storage.iceberg.catalog.rest.s3-bucket")
 
   // Iceberg Postgres specifics
   val icebergPostgresCatalogUriWithoutScheme: String =
diff --git 
a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/VFSURIFactory.scala
 
b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/VFSURIFactory.scala
index 79e36e77f4..d0e6145983 100644
--- 
a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/VFSURIFactory.scala
+++ 
b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/VFSURIFactory.scala
@@ -61,7 +61,12 @@ object VFSURIFactory {
   // invariant the URI layer itself depends on.
   private val warehouseNamePattern = "[A-Za-z0-9][A-Za-z0-9_-]*".r
 
-  private def isValidWarehouseName(name: String): Boolean =
+  /**
+    * Whether `name` is safe as a URI path segment. Public so registration-time
+    * validation (WarehouseResource) enforces the exact rule this layer parses 
by,
+    * instead of keeping a drifting copy.
+    */
+  def isValidWarehouseName(name: String): Boolean =
     warehouseNamePattern.pattern.matcher(name).matches()
 
   // Warehouse is carried as a leading `/wh/<name>` path segment so a storage 
URI
diff --git a/sql/changelog.xml b/sql/changelog.xml
index 586618f39b..05d1bb8ded 100644
--- a/sql/changelog.xml
+++ b/sql/changelog.xml
@@ -79,6 +79,11 @@
         <sqlFile path="sql/updates/33.sql"/>
     </changeSet>
 
+    <!-- Record the per-execution warehouse on workflow_executions (#6870) -->
+    <changeSet id="34" author="mengw15">
+        <sqlFile path="sql/updates/34.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 45ade5b910..f991bf9b62 100644
--- a/sql/texera_ddl.sql
+++ b/sql/texera_ddl.sql
@@ -298,9 +298,11 @@ CREATE TABLE IF NOT EXISTS workflow_executions
     log_location        TEXT,
     runtime_stats_uri   TEXT,
     runtime_stats_size  BIGINT DEFAULT 0,
+    whid                INT,
     FOREIGN KEY (vid) REFERENCES workflow_version(vid) ON DELETE CASCADE,
     FOREIGN KEY (uid) REFERENCES "user"(uid) ON DELETE CASCADE,
-    FOREIGN KEY (cuid) REFERENCES workflow_computing_unit(cuid) ON DELETE 
CASCADE
+    FOREIGN KEY (cuid) REFERENCES workflow_computing_unit(cuid) ON DELETE 
CASCADE,
+    FOREIGN KEY (whid) REFERENCES user_warehouse(whid) ON DELETE SET NULL
 );
 
 -- public_project
diff --git 
a/amber/src/main/scala/org/apache/texera/web/model/websocket/request/WorkflowExecuteRequest.scala
 b/sql/updates/34.sql
similarity index 52%
copy from 
amber/src/main/scala/org/apache/texera/web/model/websocket/request/WorkflowExecuteRequest.scala
copy to sql/updates/34.sql
index 0059af1c1a..38286c75af 100644
--- 
a/amber/src/main/scala/org/apache/texera/web/model/websocket/request/WorkflowExecuteRequest.scala
+++ b/sql/updates/34.sql
@@ -17,24 +17,16 @@
  * under the License.
  */
 
-package org.apache.texera.web.model.websocket.request
+\c texera_db
 
-import com.fasterxml.jackson.databind.annotation.JsonDeserialize
-import org.apache.texera.amber.core.workflow.WorkflowSettings
-import org.apache.texera.common.compiler.model.LogicalPlanPojo
+SET search_path TO texera_db;
 
-case class ReplayExecutionInfo(
-    @JsonDeserialize(contentAs = classOf[java.lang.Long])
-    eid: Long,
-    interaction: String
-)
+BEGIN;
 
-case class WorkflowExecuteRequest(
-    executionName: String,
-    engineVersion: String,
-    logicalPlan: LogicalPlanPojo,
-    replayFromExecution: Option[ReplayExecutionInfo], // contains execution 
Id, interaction Id.
-    workflowSettings: WorkflowSettings,
-    emailNotificationEnabled: Boolean,
-    computingUnitId: Int
-) extends TexeraWebSocketRequest
+-- Record the per-execution warehouse (#6870): which user_warehouse an 
execution wrote
+-- into, mirroring cuid. SET NULL on warehouse deletion — dropping a warehouse 
purges
+-- its data but must not erase execution history.
+ALTER TABLE workflow_executions
+    ADD COLUMN whid INT REFERENCES user_warehouse (whid) ON DELETE SET NULL;
+
+COMMIT;

Reply via email to