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 c0c3ba739c feat(storage): per-warehouse Iceberg catalog and 
warehouse-scoped URIs (#6944)
c0c3ba739c is described below

commit c0c3ba739ce264215be03461a10417d01465c4e1
Author: Meng Wang <[email protected]>
AuthorDate: Thu Aug 6 18:00:30 2026 -0700

    feat(storage): per-warehouse Iceberg catalog and warehouse-scoped URIs 
(#6944)
    
    ### What changes were proposed in this PR?
    
    First slice of the per-user bring-your-own-S3 warehouse feature
    (umbrella #6870). This is a
    **foundation-only, backward-compatible** change that makes the storage
    layer warehouse-aware
    without changing any behavior yet:
    
    - Replace the single shared Iceberg catalog with a **per-warehouse
    cache** on both the JVM
    (`IcebergCatalogInstance`) and Python (`iceberg_catalog_instance.py`)
    sides, so one worker
    process can hold several REST catalogs — one per warehouse it touches.
    Only the REST
    (Lakekeeper) catalog varies by warehouse; the hadoop/postgres catalogs
    stay warehouse-agnostic.
    - Encode the warehouse as a leading `/wh/<name>` segment in VFS URIs
    (`VFSURIFactory`), so a URI
    fully identifies where its tables live; the read path
    (`DocumentFactory`) derives the warehouse
      back out of the URI.
    - Add an optional `warehouse` field to `WorkflowContext` to carry the
    selection through an execution.
    
    With no warehouse in play (the default), URIs and catalog behavior are
    byte-for-byte identical to
    today, so nothing changes for existing deployments.
    
    ### Any related issues, documentation, discussions?
    
    Closes #6929. Part of #6870 (design discussions #5293 and #6040).
    
    ### How was this PR tested?
    
    Adds unit tests for the new warehouse-aware behavior:
    - `VFSURIFactorySpec` — `warehouseFromURI` extraction, the `/wh/<name>`
    segment (present with a
    warehouse, absent without), and that a warehouse-scoped URI still
    round-trips through `decodeURI`.
    - `DocumentFactorySpec` — creating and finding a document for a
    warehouse-scoped URI against a local
      Iceberg catalog (the `/wh/` prefix is stripped from the storage key).
    - `test_vfs_uri_factory.py` — matching coverage for the Python
    `warehouse_from_uri`.
    
    The existing storage/Iceberg tests continue to cover the default
    (no-warehouse) path unchanged.
    The full build and test suite run in CI.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Opus 4.8
    
    ---------
    
    Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
---
 .../main/python/core/storage/document_factory.py   |  31 +++--
 .../storage/iceberg/iceberg_catalog_instance.py    |  62 +++++----
 .../core/storage/iceberg/iceberg_document.py       |   3 +-
 .../main/python/core/storage/vfs_uri_factory.py    |  60 +++++++-
 .../src/main/python/core/util/virtual_identity.py  |  25 ++--
 .../packaging/test_state_materialization_e2e.py    |   9 +-
 .../iceberg/test_iceberg_catalog_instance.py       | 152 +++++++++++++++++++++
 .../python/core/storage/test_vfs_uri_factory.py    |  84 ++++++++++++
 .../test/python/core/util/test_virtual_identity.py |  11 ++
 .../amber/core/storage/DocumentFactory.scala       |  26 ++--
 .../core/storage/IcebergCatalogInstance.scala      | 108 +++++++++------
 .../texera/amber/core/storage/VFSURIFactory.scala  | 101 ++++++++++++--
 .../storage/result/iceberg/IcebergDocument.scala   |   6 +-
 .../amber/core/workflow/WorkflowContext.scala      |   3 +-
 .../amber/util/serde/GlobalPortIdentitySerde.scala |  11 ++
 .../amber/core/storage/DocumentFactorySpec.scala   |  27 +++-
 .../core/storage/LocalHadoopIcebergCatalog.scala   |  26 +++-
 .../amber/core/storage/VFSURIFactorySpec.scala     | 116 +++++++++++++++-
 18 files changed, 732 insertions(+), 129 deletions(-)

diff --git a/amber/src/main/python/core/storage/document_factory.py 
b/amber/src/main/python/core/storage/document_factory.py
index 25e1d08211..0db1b41392 100644
--- a/amber/src/main/python/core/storage/document_factory.py
+++ b/amber/src/main/python/core/storage/document_factory.py
@@ -15,6 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import re
 import typing
 import urllib
 from typing import Optional
@@ -67,21 +68,27 @@ class DocumentFactory:
         :param uri: Result of urllib.parse.urlparse(). Could be quoted.
         :return: Unquoted and sanitized format of uri.
         """
-        return urllib.parse.unquote(uri.path).lstrip("/").replace("/", "_")
+        path = urllib.parse.unquote(uri.path).lstrip("/")
+        # Strip the optional leading "wh/<name>/" segment so the storage key is
+        # identical regardless of which warehouse the URI targets (matches
+        # DocumentFactory.scala).
+        path = re.sub(r"^wh/[^/]+/", "", path)
+        return path.replace("/", "_")
 
     @staticmethod
     def create_document(uri: str, schema: Schema) -> VirtualDocument:
         parsed_uri = urlparse(uri)
         if parsed_uri.scheme == VFSURIFactory.VFS_FILE_URI_SCHEME:
-            resource_type = VFSURIFactory.decode_uri(uri).resource_type
-            namespace = DocumentFactory._resolve_namespace(resource_type)
+            components = VFSURIFactory.decode_uri(uri)
+            namespace = 
DocumentFactory._resolve_namespace(components.resource_type)
             storage_key = DocumentFactory.sanitize_uri_path(parsed_uri)
+            warehouse = components.warehouse
             # Convert Amber Schema to Iceberg Schema with LARGE_BINARY
             # field name encoding
             iceberg_schema = amber_schema_to_iceberg_schema(schema)
 
             create_table(
-                IcebergCatalogInstance.get_instance(),
+                IcebergCatalogInstance.get_instance(warehouse),
                 namespace,
                 storage_key,
                 iceberg_schema,
@@ -94,6 +101,7 @@ class DocumentFactory:
                 iceberg_schema,
                 amber_tuples_to_arrow_table,
                 arrow_table_to_amber_tuples,
+                warehouse,
             )
 
         else:
@@ -116,10 +124,11 @@ class DocumentFactory:
         """
         parsed_uri = urlparse(uri)
         if parsed_uri.scheme == VFSURIFactory.VFS_FILE_URI_SCHEME:
-            resource_type = VFSURIFactory.decode_uri(uri).resource_type
-            namespace = DocumentFactory._resolve_namespace(resource_type)
+            components = VFSURIFactory.decode_uri(uri)
+            namespace = 
DocumentFactory._resolve_namespace(components.resource_type)
             storage_key = DocumentFactory.sanitize_uri_path(parsed_uri)
-            return IcebergCatalogInstance.get_instance().table_exists(
+            warehouse = components.warehouse
+            return IcebergCatalogInstance.get_instance(warehouse).table_exists(
                 f"{namespace}.{storage_key}"
             )
 
@@ -131,12 +140,13 @@ class DocumentFactory:
     def open_document(uri: str) -> typing.Tuple[VirtualDocument, 
Optional[Schema]]:
         parsed_uri = urlparse(uri)
         if parsed_uri.scheme == VFSURIFactory.VFS_FILE_URI_SCHEME:
-            resource_type = VFSURIFactory.decode_uri(uri).resource_type
-            namespace = DocumentFactory._resolve_namespace(resource_type)
+            components = VFSURIFactory.decode_uri(uri)
+            namespace = 
DocumentFactory._resolve_namespace(components.resource_type)
             storage_key = DocumentFactory.sanitize_uri_path(parsed_uri)
+            warehouse = components.warehouse
 
             table = load_table_metadata(
-                IcebergCatalogInstance.get_instance(),
+                IcebergCatalogInstance.get_instance(warehouse),
                 namespace,
                 storage_key,
             )
@@ -152,6 +162,7 @@ class DocumentFactory:
                 table.schema(),
                 amber_tuples_to_arrow_table,
                 arrow_table_to_amber_tuples,
+                warehouse,
             )
             return document, amber_schema
 
diff --git 
a/amber/src/main/python/core/storage/iceberg/iceberg_catalog_instance.py 
b/amber/src/main/python/core/storage/iceberg/iceberg_catalog_instance.py
index 65a4f6beec..61987c2594 100644
--- a/amber/src/main/python/core/storage/iceberg/iceberg_catalog_instance.py
+++ b/amber/src/main/python/core/storage/iceberg/iceberg_catalog_instance.py
@@ -27,49 +27,59 @@ from core.storage.storage_config import StorageConfig
 
 class IcebergCatalogInstance:
     """
-    IcebergCatalogInstance is a singleton that manages the Iceberg catalog 
instance.
-    Supports postgres SQL catalog and REST catalog.
-    - Provides a single shared catalog for all Iceberg table-related 
operations.
-    - Lazily initializes the catalog on first access.
-    - Supports replacing the catalog instance for testing or reconfiguration.
+    Manages Iceberg catalog instances, cached per warehouse.
+    - REST catalogs are keyed by warehouse so one process can read/write 
tables in
+      many warehouses (Design 2, warehouse-per-execution).
+    - The postgres catalog has no warehouse concept and shares a single entry.
+    - Catalogs are lazily created on first access; entries can be replaced for
+      testing or reconfiguration.
     """
 
-    _instance: Optional[Catalog] = None
+    _catalogs: dict = {}
+    _POSTGRES_KEY = "__postgres__"
 
     @classmethod
-    def get_instance(cls):
+    def get_instance(cls, warehouse: Optional[str] = None) -> Catalog:
         """
-        Retrieves the singleton Iceberg catalog instance.
-        - If the catalog is not initialized, it is lazily created using the 
configured
-        properties.
-        - Supports "postgres" and "rest" catalog types.
+        Retrieves the Iceberg catalog for the given warehouse, creating and 
caching
+        it on first use. For REST catalogs, `warehouse` selects which 
warehouse's
+        catalog to use (defaults to the configured warehouse when None). For 
the
+        postgres catalog, `warehouse` is ignored.
+        :param warehouse: the warehouse name (REST only); None uses the 
default.
         :return: the Iceberg catalog instance.
         """
-        if cls._instance is None:
-            catalog_type = StorageConfig.ICEBERG_CATALOG_TYPE
-            if catalog_type == "postgres":
-                cls._instance = create_postgres_catalog(
+        catalog_type = StorageConfig.ICEBERG_CATALOG_TYPE
+        if catalog_type == "postgres":
+            if cls._POSTGRES_KEY not in cls._catalogs:
+                cls._catalogs[cls._POSTGRES_KEY] = create_postgres_catalog(
                     "texera_iceberg",
                     StorageConfig.ICEBERG_FILE_STORAGE_DIRECTORY_PATH,
                     StorageConfig.ICEBERG_POSTGRES_CATALOG_URI_WITHOUT_SCHEME,
                     StorageConfig.ICEBERG_POSTGRES_CATALOG_USERNAME,
                     StorageConfig.ICEBERG_POSTGRES_CATALOG_PASSWORD,
                 )
-            elif catalog_type == "rest":
-                cls._instance = create_rest_catalog(
+            return cls._catalogs[cls._POSTGRES_KEY]
+        elif catalog_type == "rest":
+            key = warehouse or 
StorageConfig.ICEBERG_REST_CATALOG_WAREHOUSE_NAME
+            if key not in cls._catalogs:
+                cls._catalogs[key] = create_rest_catalog(
                     "texera_iceberg",
-                    StorageConfig.ICEBERG_REST_CATALOG_WAREHOUSE_NAME,
+                    key,
                     StorageConfig.ICEBERG_REST_CATALOG_URI,
                 )
-            else:
-                raise ValueError(f"Unsupported catalog type: {catalog_type}")
-        return cls._instance
+            return cls._catalogs[key]
+        else:
+            raise ValueError(f"Unsupported catalog type: {catalog_type}")
 
     @classmethod
-    def replace_instance(cls, catalog: Catalog):
+    def replace_instance(cls, catalog: Catalog, warehouse: Optional[str] = 
None):
         """
-        Replaces the existing Iceberg catalog instance.
-        - This method is useful for testing or dynamically updating the 
catalog.
-        :param catalog: the new Iceberg catalog instance to replace the 
current one.
+        Replaces the cached catalog for a warehouse (testing or 
reconfiguration).
+        :param catalog: the new Iceberg catalog instance.
+        :param warehouse: the warehouse to replace (REST only); None uses 
default.
         """
-        cls._instance = catalog
+        if StorageConfig.ICEBERG_CATALOG_TYPE == "postgres":
+            key = cls._POSTGRES_KEY
+        else:
+            key = warehouse or 
StorageConfig.ICEBERG_REST_CATALOG_WAREHOUSE_NAME
+        cls._catalogs[key] = catalog
diff --git a/amber/src/main/python/core/storage/iceberg/iceberg_document.py 
b/amber/src/main/python/core/storage/iceberg/iceberg_document.py
index 7a5beda916..797c1ec4e4 100644
--- a/amber/src/main/python/core/storage/iceberg/iceberg_document.py
+++ b/amber/src/main/python/core/storage/iceberg/iceberg_document.py
@@ -63,6 +63,7 @@ class IcebergDocument(VirtualDocument[T]):
         table_schema: Schema,
         serde: Callable[[Schema, Iterable[T]], pa.Table],
         deserde: Callable[[Schema, pa.Table], Iterable[T]],
+        warehouse: Optional[str] = None,
     ):
         self.table_namespace = table_namespace
         self.table_name = table_name
@@ -71,7 +72,7 @@ class IcebergDocument(VirtualDocument[T]):
         self.deserde = deserde
 
         self.lock = rwlock.RWLockFair()
-        self.catalog = IcebergCatalogInstance.get_instance()
+        self.catalog = IcebergCatalogInstance.get_instance(warehouse)
 
     def get_uri(self) -> ParseResult:
         """Returns the URI of the table location."""
diff --git a/amber/src/main/python/core/storage/vfs_uri_factory.py 
b/amber/src/main/python/core/storage/vfs_uri_factory.py
index d0eb3eab7a..37a28cbbf0 100644
--- a/amber/src/main/python/core/storage/vfs_uri_factory.py
+++ b/amber/src/main/python/core/storage/vfs_uri_factory.py
@@ -17,6 +17,7 @@
 
 from enum import Enum
 from typing import NamedTuple, Optional
+import re
 from urllib.parse import urlparse
 
 from core.util.virtual_identity import (
@@ -37,6 +38,10 @@ class VFSResourceType(str, Enum):
     STATE = "state"
 
 
+# See VFSURIFactory._is_valid_warehouse_name.
+_WAREHOUSE_NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]*")
+
+
 class VFSUriComponents(NamedTuple):
     """The named components encoded in a VFS URI, as returned by
     `VFSURIFactory.decode_uri`. A NamedTuple, so positional unpacking keeps
@@ -46,6 +51,10 @@ class VFSUriComponents(NamedTuple):
     execution_id: ExecutionIdentity
     global_port_id: Optional[GlobalPortIdentity]
     resource_type: VFSResourceType
+    # The warehouse whose catalog holds this URI's table, from the optional 
leading
+    # "/wh/<name>" segment; None for non-BYO storage, which uses the configured
+    # default. Last so positional unpacking of the earlier fields still works.
+    warehouse: Optional[str] = None
 
 
 class VFSURIFactory:
@@ -90,15 +99,62 @@ class VFSURIFactory:
             execution_id,
             global_port_id,
             resource_type,
+            VFSURIFactory._warehouse_from(segments),
         )
 
     @staticmethod
-    def create_port_base_uri(workflow_id, execution_id, global_port_id) -> str:
+    def _is_valid_warehouse_name(name: str) -> bool:
+        """A warehouse name becomes a URI path segment, so it may not carry
+        characters that have meaning there: no "/" to add segments, and no "%" 
to
+        smuggle one in percent-encoded form. Mirrors VFSURIFactory (Scala).
+        """
+        return _WAREHOUSE_NAME_RE.fullmatch(name) is not None
+
+    @staticmethod
+    def _warehouse_from(segments: list) -> Optional[str]:
+        """
+        The warehouse encoded in a VFS URI, if present. Reported as part of
+        VFSUriComponents by decode_uri, which is the only way in. Mirrors
+        VFSURIFactory.warehouseFrom (Scala).
+
+        Anchored to the leading segment: a later segment that happens to be 
"wh"
+        -- e.g. inside a user-chosen operator id -- must not select a 
warehouse; it
+        would disagree with document_factory.sanitize_uri_path, which strips 
only a
+        leading one, and would route the write to another user's warehouse. The
+        segments come from the RAW path (see decode_uri), so a percent-encoded 
slash
+        stays inside its own segment, and the name must be a legal warehouse 
name,
+        so anything create_port_base_uri could not have written resolves to no
+        warehouse rather than to a wrong one.
+        """
+        if (
+            len(segments) >= 2
+            and segments[0] == "wh"
+            and VFSURIFactory._is_valid_warehouse_name(segments[1])
+        ):
+            return segments[1]
+        return None
+
+    @staticmethod
+    def create_port_base_uri(
+        workflow_id, execution_id, global_port_id, warehouse: Optional[str] = 
None
+    ) -> str:
         """Base URI for a port. Result and state URIs derive from it via
         `result_uri` / `state_uri`.
+
+        `warehouse` is written as the leading "/wh/<name>" segment, mirroring 
the
+        Scala side; when None the URI is byte-for-byte what it was before 
warehouses
+        existed.
         """
+        if warehouse is not None and not 
VFSURIFactory._is_valid_warehouse_name(
+            warehouse
+        ):
+            raise ValueError(
+                f"warehouse name must match {_WAREHOUSE_NAME_RE.pattern} "
+                f"(it becomes a URI path segment): {warehouse}"
+            )
+        wh_segment = f"/wh/{warehouse}" if warehouse else ""
         return (
-            f"{VFSURIFactory.VFS_FILE_URI_SCHEME}:///wid/{workflow_id.id}"
+            
f"{VFSURIFactory.VFS_FILE_URI_SCHEME}://{wh_segment}/wid/{workflow_id.id}"
             f"/eid/{execution_id.id}/globalportid/"
             f"{serialize_global_port_identity(global_port_id)}"
         )
diff --git a/amber/src/main/python/core/util/virtual_identity.py 
b/amber/src/main/python/core/util/virtual_identity.py
index 6893e7e8f0..0a9de0ccc4 100644
--- a/amber/src/main/python/core/util/virtual_identity.py
+++ b/amber/src/main/python/core/util/virtual_identity.py
@@ -64,24 +64,25 @@ def serialize_global_port_identity(obj: GlobalPortIdentity) 
-> str:
     ``(logicalOpId=<logicalOpId>,layerName=<layerName>,
     portId=<portId.id>,isInternal=<portId.internal>,isInput=<input>)``
 
-    Raises ValueError if `logicalOpId` or `layerName` contains an underscore
-    (VFS URI parsing relies on the absence of '_'), or if `portId` is negative.
+    Raises ValueError if `logicalOpId` or `layerName` contains an underscore 
or a
+    slash (VFS URI parsing relies on the absence of both), or if `portId` is
+    negative. Mirrors GlobalPortIdentitySerde (Scala).
     """
     logical_op_id = obj.op_id.logical_op_id.id
     layer_name = obj.op_id.layer_name
     port_id = obj.port_id.id
     is_internal = obj.port_id.internal
     is_input_port = obj.input
-    if "_" in logical_op_id:
-        raise ValueError(
-            f"logicalOpId must not contain '_' "
-            f"(VFS URI parsing relies on this): {logical_op_id}"
-        )
-    if "_" in layer_name:
-        raise ValueError(
-            f"layerName must not contain '_' "
-            f"(VFS URI parsing relies on this): {layer_name}"
-        )
+    for field, value in (("logicalOpId", logical_op_id), ("layerName", 
layer_name)):
+        # '_' would collide with the separator the storage key is built from, 
and
+        # '/' would add path segments to the VFS URI this string is 
interpolated
+        # into -- letting an id forge structure the URI never meant to have.
+        for forbidden in ("_", "/"):
+            if forbidden in value:
+                raise ValueError(
+                    f"{field} must not contain '{forbidden}' "
+                    f"(VFS URI parsing relies on this): {value}"
+                )
     if port_id < 0:
         raise ValueError(f"portId must be non-negative: {port_id}")
     return (
diff --git 
a/amber/src/test/python/core/architecture/packaging/test_state_materialization_e2e.py
 
b/amber/src/test/python/core/architecture/packaging/test_state_materialization_e2e.py
index 5db9bdf903..84c626f58b 100644
--- 
a/amber/src/test/python/core/architecture/packaging/test_state_materialization_e2e.py
+++ 
b/amber/src/test/python/core/architecture/packaging/test_state_materialization_e2e.py
@@ -89,8 +89,8 @@ def sqlite_iceberg_catalog():
     catalog-agnostic, so the sqlite backend exercises the same code path.
 
     Module-scoped so all tests in this file share one warehouse, and so
-    namespace creation only happens once. We save/restore the original
-    `IcebergCatalogInstance` singleton so other test modules that expect
+    namespace creation only happens once. We save/restore the
+    `IcebergCatalogInstance` cache so other test modules that expect
     a real postgres-backed catalog (e.g. test_iceberg_document.py) are
     not affected by our replacement.
     """
@@ -117,7 +117,7 @@ def sqlite_iceberg_catalog():
             s3_large_binaries_base_uri="s3://texera-large-binaries/objects/0/",
         )
 
-    original_instance = IcebergCatalogInstance._instance
+    original_catalogs = dict(IcebergCatalogInstance._catalogs)
     db_path = f"{_WAREHOUSE_DIR}/catalog.sqlite"
     catalog = SqlCatalog(
         "texera_iceberg_e2e",
@@ -134,7 +134,8 @@ def sqlite_iceberg_catalog():
     try:
         yield catalog
     finally:
-        IcebergCatalogInstance.replace_instance(original_instance)
+        IcebergCatalogInstance._catalogs.clear()
+        IcebergCatalogInstance._catalogs.update(original_catalogs)
 
 
 def _fresh_base_uri() -> str:
diff --git 
a/amber/src/test/python/core/storage/iceberg/test_iceberg_catalog_instance.py 
b/amber/src/test/python/core/storage/iceberg/test_iceberg_catalog_instance.py
new file mode 100644
index 0000000000..92aa565182
--- /dev/null
+++ 
b/amber/src/test/python/core/storage/iceberg/test_iceberg_catalog_instance.py
@@ -0,0 +1,152 @@
+# 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.
+
+from unittest.mock import patch
+
+import pytest
+
+from core.storage.iceberg import iceberg_catalog_instance
+from core.storage.iceberg.iceberg_catalog_instance import 
IcebergCatalogInstance
+from core.storage.storage_config import StorageConfig
+
+
[email protected](autouse=True)
+def _isolated_catalog_cache():
+    """Save/clear/restore the class-level cache so these tests neither see nor
+    leak cached catalogs (mirrors test_state_materialization_e2e.py)."""
+    original = dict(IcebergCatalogInstance._catalogs)
+    IcebergCatalogInstance._catalogs.clear()
+    yield
+    IcebergCatalogInstance._catalogs.clear()
+    IcebergCatalogInstance._catalogs.update(original)
+
+
+def _rest_config():
+    """StorageConfig patched to the `rest` catalog type. No live Lakekeeper is
+    involved anywhere in this file: `create_rest_catalog` is patched out, which
+    is exactly the boundary the cache under test sits on."""
+    return (
+        patch.object(StorageConfig, "ICEBERG_CATALOG_TYPE", "rest"),
+        patch.object(
+            StorageConfig, "ICEBERG_REST_CATALOG_WAREHOUSE_NAME", "default-wh"
+        ),
+        patch.object(
+            StorageConfig, "ICEBERG_REST_CATALOG_URI", "http://localhost:8181";
+        ),
+    )
+
+
+class TestRestCatalogCachedPerWarehouse:
+    """
+    Under the `rest` catalog type the cache is keyed by warehouse name -- the
+    core of the per-warehouse catalog cache (#6870 Phase 0): one process can
+    hold several REST catalogs, one per warehouse it touches, while repeated
+    lookups for the same warehouse share one client.
+    """
+
+    def test_caches_one_catalog_per_warehouse(self):
+        type_p, default_p, uri_p = _rest_config()
+        with (
+            type_p,
+            default_p,
+            uri_p,
+            patch.object(
+                iceberg_catalog_instance,
+                "create_rest_catalog",
+                side_effect=lambda *args: object(),
+            ) as mock_create,
+        ):
+            first = IcebergCatalogInstance.get_instance("wh-a")
+            again = IcebergCatalogInstance.get_instance("wh-a")
+            other = IcebergCatalogInstance.get_instance("wh-b")
+
+        assert first is again, "same warehouse must reuse the cached catalog"
+        assert first is not other, "distinct warehouses must get distinct 
catalogs"
+        assert mock_create.call_count == 2
+        # The warehouse name is what create_rest_catalog is keyed/called with.
+        assert [call.args[1] for call in mock_create.call_args_list] == 
["wh-a", "wh-b"]
+
+    def test_none_falls_back_to_the_configured_default_warehouse(self):
+        type_p, default_p, uri_p = _rest_config()
+        with (
+            type_p,
+            default_p,
+            uri_p,
+            patch.object(
+                iceberg_catalog_instance,
+                "create_rest_catalog",
+                side_effect=lambda *args: object(),
+            ) as mock_create,
+        ):
+            from_none = IcebergCatalogInstance.get_instance(None)
+            from_name = IcebergCatalogInstance.get_instance("default-wh")
+
+        assert from_none is from_name, "None must share the default 
warehouse's entry"
+        assert mock_create.call_count == 1
+        assert mock_create.call_args.args[1] == "default-wh"
+
+    def test_replace_instance_keys_by_warehouse(self):
+        sentinel = object()
+        type_p, default_p, uri_p = _rest_config()
+        with (
+            type_p,
+            default_p,
+            uri_p,
+            patch.object(
+                iceberg_catalog_instance,
+                "create_rest_catalog",
+                side_effect=AssertionError(
+                    "cache was pre-seeded; nothing should be created"
+                ),
+            ),
+        ):
+            IcebergCatalogInstance.replace_instance(sentinel, "wh-a")
+            assert IcebergCatalogInstance.get_instance("wh-a") is sentinel
+
+
+class TestWarehouseAgnosticCatalogTypes:
+    """The postgres catalog has no warehouse concept: every caller shares the
+    one entry under the constant key, mirroring the Scala 
`SharedCatalogKey`."""
+
+    def test_postgres_catalog_is_created_once_and_shared(self):
+        with (
+            patch.object(StorageConfig, "ICEBERG_CATALOG_TYPE", "postgres"),
+            patch.object(
+                iceberg_catalog_instance,
+                "create_postgres_catalog",
+                side_effect=lambda *args: object(),
+            ) as mock_create,
+        ):
+            first = IcebergCatalogInstance.get_instance()
+            again = IcebergCatalogInstance.get_instance("ignored-warehouse")
+
+        assert first is again, "postgres ignores the warehouse and shares one 
entry"
+        assert mock_create.call_count == 1
+
+    def test_replace_instance_uses_the_shared_postgres_key(self):
+        sentinel = object()
+        with patch.object(StorageConfig, "ICEBERG_CATALOG_TYPE", "postgres"):
+            IcebergCatalogInstance.replace_instance(sentinel)
+            assert IcebergCatalogInstance.get_instance() is sentinel
+            assert IcebergCatalogInstance._catalogs == {
+                IcebergCatalogInstance._POSTGRES_KEY: sentinel
+            }
+
+    def test_unsupported_catalog_type_raises(self):
+        with patch.object(StorageConfig, "ICEBERG_CATALOG_TYPE", "bogus"):
+            with pytest.raises(ValueError, match="Unsupported catalog type: 
bogus"):
+                IcebergCatalogInstance.get_instance()
diff --git a/amber/src/test/python/core/storage/test_vfs_uri_factory.py 
b/amber/src/test/python/core/storage/test_vfs_uri_factory.py
index 5422f2ad68..24ad583ae6 100644
--- a/amber/src/test/python/core/storage/test_vfs_uri_factory.py
+++ b/amber/src/test/python/core/storage/test_vfs_uri_factory.py
@@ -133,3 +133,87 @@ class TestDecodeUriErrorPaths:
     def test_rejects_unknown_resource_type(self):
         with pytest.raises(ValueError, match="Unknown resource type: bogus"):
             VFSURIFactory.decode_uri("vfs:///wid/1/eid/1/bogus")
+
+
+class TestWarehouseInDecodedUri:
+    def test_reports_warehouse_from_wh_segment(self):
+        components = 
VFSURIFactory.decode_uri("vfs:///wh/user-2-foo/wid/7/eid/3/result")
+        assert components.warehouse == "user-2-foo"
+
+    def test_warehouse_is_none_when_no_wh_segment(self):
+        # Non-BYO URIs have no /wh/ segment, so the warehouse is absent.
+        assert VFSURIFactory.decode_uri("vfs:///wid/7/eid/3/result").warehouse 
is None
+
+    def test_only_a_leading_wh_segment_counts(self):
+        # A `wh` deeper in the path -- e.g. inside an operator id -- must not 
select
+        # a warehouse; it would disagree with sanitize_uri_path, which strips 
only a
+        # leading one, and would route the write to another user's warehouse.
+        assert (
+            VFSURIFactory.decode_uri(
+                "vfs:///wid/1/eid/2/opid/a/wh/victim/b/result"
+            ).warehouse
+            is None
+        )
+        assert (
+            
VFSURIFactory.decode_uri("vfs:///wid/1/eid/2/opid/wh/result").warehouse
+            is None
+        )
+
+    def test_percent_encoded_name_is_rejected_not_decoded(self):
+        # The raw path is split and the name must be a legal warehouse name, 
so a
+        # percent-encoded name resolves to no warehouse rather than to a 
decoded
+        # one. Decoding first would let "%2F" become a separator and pick the 
wrong
+        # warehouse; it would also diverge from Scala, which splits its raw 
path.
+        assert (
+            VFSURIFactory.decode_uri(
+                "vfs:///wh/user-2%2Dfoo/wid/7/eid/3/result"
+            ).warehouse
+            is None
+        )
+
+    def test_encoded_slash_in_name_cannot_forge_segments(self):
+        # "%2F" must stay inside its own segment: decoded-then-split, this URI 
would
+        # read as /wh/a/wid/999/... handing back "a" as the warehouse, and the 
key
+        # search would find the injected `wid` instead of the real one.
+        components = VFSURIFactory.decode_uri(
+            "vfs:///wh/a%2Fwid%2F999/wid/1/eid/2/result"
+        )
+        assert components.warehouse is None
+        assert components.workflow_id.id == 1
+        assert components.execution_id.id == 2
+
+    def test_builder_rejects_an_unsafe_warehouse_name(self):
+        with pytest.raises(ValueError, match="warehouse name must match"):
+            VFSURIFactory.create_port_base_uri(
+                WorkflowIdentity(id=7), ExecutionIdentity(id=3), _gpi(), "a/b"
+            )
+
+    def test_round_trips_a_warehouse_written_by_the_python_builder(self):
+        # Python can now write the segment, not just read it.
+        uri = VFSURIFactory.create_port_base_uri(
+            WorkflowIdentity(id=7), ExecutionIdentity(id=3), _gpi(), 
"user-2-foo"
+        )
+        assert uri.startswith("vfs:///wh/user-2-foo/wid/7/eid/3/")
+        assert (
+            VFSURIFactory.decode_uri(VFSURIFactory.result_uri(uri)).warehouse
+            == "user-2-foo"
+        )
+
+    def test_builder_without_warehouse_is_unchanged(self):
+        uri = VFSURIFactory.create_port_base_uri(
+            WorkflowIdentity(id=7), ExecutionIdentity(id=3), _gpi()
+        )
+        assert "/wh/" not in uri
+        assert 
VFSURIFactory.decode_uri(VFSURIFactory.result_uri(uri)).warehouse is None
+
+    def test_decode_round_trips_a_warehouse_scoped_uri(self):
+        # The leading /wh/<name> segment must not break decode_uri, which finds
+        # wid/eid by key rather than by position.
+        components = VFSURIFactory.decode_uri(
+            "vfs:///wh/user-2-foo/wid/11/eid/22/result"
+        )
+        assert components.workflow_id.id == 11
+        assert components.execution_id.id == 22
+        assert components.global_port_id is None
+        assert components.resource_type == VFSResourceType.RESULT
+        assert components.warehouse == "user-2-foo"
diff --git a/amber/src/test/python/core/util/test_virtual_identity.py 
b/amber/src/test/python/core/util/test_virtual_identity.py
index 75c8e30a74..ad60391c28 100644
--- a/amber/src/test/python/core/util/test_virtual_identity.py
+++ b/amber/src/test/python/core/util/test_virtual_identity.py
@@ -159,6 +159,17 @@ class TestSerializeGlobalPortIdentity:
         with pytest.raises(ValueError, match="layerName must not contain"):
             serialize_global_port_identity(_gpi(layer="main_source_0_op"))
 
+    def test_rejects_slash_in_logical_op_id(self):
+        # A '/' would add path segments to the VFS URI this string is 
interpolated
+        # into, letting an id forge structure the URI never meant to have. 
Matches
+        # the guard in GlobalPortIdentitySerde (Scala).
+        with pytest.raises(ValueError, match="logicalOpId must not contain"):
+            serialize_global_port_identity(_gpi(op_id="a/wh/victim/b"))
+
+    def test_rejects_slash_in_layer_name(self):
+        with pytest.raises(ValueError, match="layerName must not contain"):
+            serialize_global_port_identity(_gpi(layer="main/wh/victim"))
+
     def test_rejects_negative_port_id(self):
         # Port ids are array indices and must be non-negative.
         with pytest.raises(ValueError, match="portId must be non-negative"):
diff --git 
a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/DocumentFactory.scala
 
b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/DocumentFactory.scala
index 910c1dce44..f84158e0e3 100644
--- 
a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/DocumentFactory.scala
+++ 
b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/DocumentFactory.scala
@@ -38,7 +38,7 @@ object DocumentFactory {
   val ICEBERG = "iceberg"
 
   private def sanitizeURIPath(uri: URI): String =
-    uri.getPath.stripPrefix("/").replace("/", "_")
+    uri.getPath.stripPrefix("/").replaceFirst("^wh/[^/]+/", "").replace("/", 
"_")
 
   private def resolveNamespace(resourceType: VFSResourceType.Value): String =
     resourceType match {
@@ -76,13 +76,15 @@ object DocumentFactory {
   def createDocument(uri: URI, schema: Schema): VirtualDocument[_] = {
     uri.getScheme match {
       case VFS_FILE_URI_SCHEME =>
-        val resourceType = decodeURI(uri).resourceType
+        val components = decodeURI(uri)
+        val warehouse = components.warehouse
+        val resourceType = components.resourceType
         val storageKey = sanitizeURIPath(uri)
         val namespace = resolveNamespace(resourceType)
 
         val icebergSchema = IcebergUtil.toIcebergSchema(schema)
         IcebergUtil.createTable(
-          IcebergCatalogInstance.getInstance(),
+          IcebergCatalogInstance.getInstance(warehouse),
           namespace,
           storageKey,
           icebergSchema,
@@ -97,7 +99,8 @@ object DocumentFactory {
           storageKey,
           icebergSchema,
           serde,
-          deserde
+          deserde,
+          warehouse
         )
       case unsupportedScheme =>
         throw new UnsupportedOperationException(
@@ -119,11 +122,13 @@ object DocumentFactory {
   def documentExists(uri: URI): Boolean = {
     uri.getScheme match {
       case VFS_FILE_URI_SCHEME =>
-        val resourceType = decodeURI(uri).resourceType
+        val components = decodeURI(uri)
+        val warehouse = components.warehouse
+        val resourceType = components.resourceType
         val storageKey = sanitizeURIPath(uri)
         val namespace = resolveNamespace(resourceType)
         IcebergCatalogInstance
-          .getInstance()
+          .getInstance(warehouse)
           .tableExists(TableIdentifier.of(namespace, storageKey))
 
       case unsupportedScheme =>
@@ -165,13 +170,15 @@ object DocumentFactory {
     uri.getScheme match {
       case DATASET_FILE_URI_SCHEME => (new DatasetFileDocument(uri), None)
       case VFS_FILE_URI_SCHEME =>
-        val resourceType = decodeURI(uri).resourceType
+        val components = decodeURI(uri)
+        val warehouse = components.warehouse
+        val resourceType = components.resourceType
         val storageKey = sanitizeURIPath(uri)
         val namespace = resolveNamespace(resourceType)
 
         val table = IcebergUtil
           .loadTableMetadata(
-            IcebergCatalogInstance.getInstance(),
+            IcebergCatalogInstance.getInstance(warehouse),
             namespace,
             storageKey
           )
@@ -190,7 +197,8 @@ object DocumentFactory {
             storageKey,
             table.schema(),
             serde,
-            deserde
+            deserde,
+            warehouse
           ),
           Some(amberSchema)
         )
diff --git 
a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/IcebergCatalogInstance.scala
 
b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/IcebergCatalogInstance.scala
index cd4b3c8796..313772b0fc 100644
--- 
a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/IcebergCatalogInstance.scala
+++ 
b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/IcebergCatalogInstance.scala
@@ -23,57 +23,87 @@ import org.apache.texera.common.config.StorageConfig
 import org.apache.texera.amber.util.IcebergUtil
 import org.apache.iceberg.catalog.Catalog
 
+import scala.collection.mutable
+
 /**
-  * IcebergCatalogInstance is a singleton that manages the Iceberg catalog 
instance.
-  * - Provides a single shared catalog for all Iceberg table-related 
operations in the Texera application.
-  * - Lazily initializes the catalog on first access.
-  * - Supports replacing the catalog instance primarily for testing or 
reconfiguration.
+  * IcebergCatalogInstance manages the Iceberg catalog clients used across the 
Texera application.
+  *
+  * Catalogs are cached per warehouse: each distinct warehouse name gets its 
own lazily-created
+  * catalog client, so a single JVM may hold several catalogs, one per 
warehouse it touches. Callers
+  * that do not specify a warehouse use the configured default, preserving 
single-warehouse (non-BYO)
+  * behavior.
+  *
+  * Only the REST catalog varies by warehouse; the hadoop and postgres 
catalogs are warehouse-agnostic
+  * and ignore the warehouse argument.
+  *
+  * Access is synchronized because the same JVM serves multiple warehouses 
concurrently.
   */
 object IcebergCatalogInstance {
 
-  private var instance: Option[Catalog] = None
+  private val catalogs = mutable.Map.empty[String, Catalog]
+
+  // Cache key for the warehouse-agnostic catalog types. Not a legal warehouse 
name,
+  // so it cannot collide with a REST warehouse.
+  private val SharedCatalogKey = "<shared>"
+
+  private def defaultWarehouse: String = 
StorageConfig.icebergRESTCatalogWarehouseName
 
   /**
-    * Retrieves the singleton Iceberg catalog instance.
-    * - If the catalog is not initialized, it is lazily created using the 
configured properties.
+    * The cache key for a warehouse. Only the REST catalog is scoped to a 
warehouse;
+    * hadoop and postgres ignore it, so they must share one entry. Keying them 
by
+    * warehouse name would build a second, fully equivalent catalog per 
distinct name
+    * -- for postgres a second JdbcCatalog with its own connection pool, all 
pointing
+    * at the same database. Mirrors the Python side, which keys those under a 
constant.
+    */
+  private def cacheKey(warehouse: String): String =
+    StorageConfig.icebergCatalogType match {
+      case "rest" => warehouse
+      case _      => SharedCatalogKey
+    }
+
+  /**
+    * Retrieves the catalog for the given warehouse, creating and caching it 
on first access.
     *
-    * @return the Iceberg catalog instance.
+    * @param warehouse the warehouse to obtain a catalog for; `None` uses the 
configured
+    *                  default, mirroring the Python side's `Optional[str]`.
+    * @return the Iceberg catalog for that warehouse.
     */
-  def getInstance(): Catalog = {
-    instance match {
-      case Some(catalog) => catalog
-      case None =>
-        val catalog = StorageConfig.icebergCatalogType match {
-          case "hadoop" =>
-            IcebergUtil.createHadoopCatalog(
-              "texera_iceberg",
-              StorageConfig.fileStorageDirectoryPath
-            )
-          case "rest" =>
-            IcebergUtil.createRestCatalog(
-              "texera_iceberg",
-              StorageConfig.icebergRESTCatalogWarehouseName
-            )
-          case "postgres" =>
-            IcebergUtil.createPostgresCatalog(
-              "texera_iceberg",
-              StorageConfig.fileStorageDirectoryPath
-            )
-          case unsupported =>
-            throw new IllegalArgumentException(s"Unsupported catalog type: 
$unsupported")
-        }
-        instance = Some(catalog)
-        catalog
+  def getInstance(warehouse: Option[String] = None): Catalog = {
+    val name = warehouse.getOrElse(defaultWarehouse)
+    synchronized {
+      catalogs.getOrElseUpdate(cacheKey(name), createCatalog(name))
     }
   }
 
+  private def createCatalog(warehouse: String): Catalog =
+    StorageConfig.icebergCatalogType match {
+      case "hadoop" =>
+        IcebergUtil.createHadoopCatalog(
+          "texera_iceberg",
+          StorageConfig.fileStorageDirectoryPath
+        )
+      case "rest" =>
+        IcebergUtil.createRestCatalog(
+          "texera_iceberg",
+          warehouse
+        )
+      case "postgres" =>
+        IcebergUtil.createPostgresCatalog(
+          "texera_iceberg",
+          StorageConfig.fileStorageDirectoryPath
+        )
+      case unsupported =>
+        throw new IllegalArgumentException(s"Unsupported catalog type: 
$unsupported")
+    }
+
   /**
-    * Replaces the existing Iceberg catalog instance.
-    * - This method is useful for testing or dynamically updating the catalog.
+    * Replaces the cached catalog for a warehouse, primarily for testing or 
reconfiguration.
     *
-    * @param catalog the new Iceberg catalog instance to replace the current 
one.
+    * @param catalog   the catalog to cache.
+    * @param warehouse the warehouse to cache it under; `None` uses the 
configured default.
     */
-  def replaceInstance(catalog: Catalog): Unit = {
-    instance = Some(catalog)
-  }
+  def replaceInstance(catalog: Catalog, warehouse: Option[String] = None): 
Unit =
+    synchronized {
+      catalogs(cacheKey(warehouse.getOrElse(defaultWarehouse))) = catalog
+    }
 }
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 5124c745aa..79e36e77f4 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
@@ -45,12 +45,61 @@ case class VFSUriComponents(
     workflowId: WorkflowIdentity,
     executionId: ExecutionIdentity,
     globalPortId: Option[GlobalPortIdentity],
-    resourceType: VFSResourceType.Value
+    resourceType: VFSResourceType.Value,
+    // The warehouse whose catalog holds this URI's table, from the optional 
leading
+    // `/wh/<name>` segment; None for non-BYO storage, which uses the 
configured
+    // default. Last so positional unpacking of the earlier fields still works.
+    warehouse: Option[String] = None
 )
 
 object VFSURIFactory {
   val VFS_FILE_URI_SCHEME = "vfs"
 
+  // A warehouse name becomes a URI path segment, so it is restricted to 
characters
+  // that carry no meaning there: no '/' to add segments, and no '%' to 
smuggle one
+  // in percent-encoded form. Registration applies a stricter rule still; this 
is the
+  // 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 =
+    warehouseNamePattern.pattern.matcher(name).matches()
+
+  // Warehouse is carried as a leading `/wh/<name>` path segment so a storage 
URI
+  // fully identifies which warehouse its table lives in. Absent for non-BYO 
storage.
+  private def warehousePathSegment(warehouse: Option[String]): String =
+    warehouse
+      .map { name =>
+        require(
+          isValidWarehouseName(name),
+          s"warehouse name must match ${warehouseNamePattern.regex} " +
+            s"(it becomes a URI path segment): $name"
+        )
+        s"/wh/$name"
+      }
+      .getOrElse("")
+
+  /**
+    * The warehouse encoded in a VFS URI, if present. Reported as part of
+    * [[VFSUriComponents]] by [[decodeURI]], which is the only way in.
+    *
+    * Anchored to the leading segment on purpose. The warehouse is written as a
+    * leading `/wh/<name>` prefix and `DocumentFactory` strips only a leading 
one,
+    * so scanning the whole path would disagree with the stripper: a later 
segment
+    * that happens to be `wh` -- e.g. inside a user-chosen operator id in a
+    * console-messages URI -- would select a warehouse the URI was never built 
for.
+    *
+    * The segments come from the RAW path (see `decodeURI`), so a 
percent-encoded
+    * slash stays inside the segment that contains it instead of becoming a
+    * separator. The name is then required to be a legal warehouse name, so 
anything
+    * that could not have been written by `warehousePathSegment` resolves to no
+    * warehouse rather than to a wrong one.
+    */
+  private def warehouseFrom(segments: List[String]): Option[String] =
+    segments match {
+      case "wh" :: name :: _ if isValidWarehouseName(name) => Some(name)
+      case _                                               => None
+    }
+
   /**
     * Parses a VFS URI and extracts its components
     *
@@ -63,7 +112,11 @@ object VFSURIFactory {
       throw new IllegalArgumentException(s"Invalid URI scheme: 
${uri.getScheme}")
     }
 
-    val segments = uri.getPath.stripPrefix("/").split("/").toList
+    // Raw path, for the same reason as warehouseFromURI: keys are located by
+    // searching the segments, so a percent-encoded slash inside a segment 
must not
+    // split it and shift which `wid`/`eid` the search finds. Python's 
decode_uri
+    // splits the raw path too, so both languages read a URI identically.
+    val segments = uri.getRawPath.stripPrefix("/").split("/").toList
 
     def extractValue(key: String): String = {
       val index = segments.indexOf(key)
@@ -86,7 +139,13 @@ object VFSURIFactory {
       .find(_.toString.toLowerCase == resourceTypeStr)
       .getOrElse(throw new IllegalArgumentException(s"Unknown resource type: 
$resourceTypeStr"))
 
-    VFSUriComponents(workflowId, executionId, globalPortIdOption, resourceType)
+    VFSUriComponents(
+      workflowId,
+      executionId,
+      globalPortIdOption,
+      resourceType,
+      warehouseFrom(segments)
+    )
   }
 
   /**
@@ -96,10 +155,11 @@ object VFSURIFactory {
   def createPortBaseURI(
       workflowId: WorkflowIdentity,
       executionId: ExecutionIdentity,
-      globalPortId: GlobalPortIdentity
+      globalPortId: GlobalPortIdentity,
+      warehouse: Option[String] = None
   ): URI =
     new URI(
-      s"$VFS_FILE_URI_SCHEME:///wid/${workflowId.id}/eid/${executionId.id}" +
+      
s"$VFS_FILE_URI_SCHEME://${warehousePathSegment(warehouse)}/wid/${workflowId.id}/eid/${executionId.id}"
 +
         s"/globalportid/${globalPortId.serializeAsString}"
     )
 
@@ -115,12 +175,14 @@ object VFSURIFactory {
     */
   def createRuntimeStatisticsURI(
       workflowId: WorkflowIdentity,
-      executionId: ExecutionIdentity
+      executionId: ExecutionIdentity,
+      warehouse: Option[String] = None
   ): URI = {
     createNonResultVFSURI(
       VFSResourceType.RUNTIME_STATISTICS,
       workflowId,
-      executionId
+      executionId,
+      warehouse = warehouse
     )
   }
 
@@ -130,13 +192,15 @@ object VFSURIFactory {
   def createConsoleMessagesURI(
       workflowId: WorkflowIdentity,
       executionId: ExecutionIdentity,
-      operatorId: OperatorIdentity
+      operatorId: OperatorIdentity,
+      warehouse: Option[String] = None
   ): URI = {
     createNonResultVFSURI(
       VFSResourceType.CONSOLE_MESSAGES,
       workflowId,
       executionId,
-      Some(operatorId)
+      Some(operatorId),
+      warehouse
     )
   }
 
@@ -156,7 +220,8 @@ object VFSURIFactory {
       resourceType: VFSResourceType.Value,
       workflowId: WorkflowIdentity,
       executionId: ExecutionIdentity,
-      operatorId: Option[OperatorIdentity] = None
+      operatorId: Option[OperatorIdentity] = None,
+      warehouse: Option[String] = None
   ): URI = {
 
     if (resourceType == VFSResourceType.RESULT) {
@@ -177,10 +242,22 @@ object VFSURIFactory {
       )
     }
 
+    // The operator id is user-supplied (it comes straight off the workflow 
JSON) and
+    // is interpolated into the URI path below. A '/' in it would add path 
segments,
+    // letting it forge structure the URI never meant to have -- e.g. a 
`wh/<name>`
+    // pair that would then be read back as a warehouse.
+    operatorId.foreach { opId =>
+      require(
+        !opId.id.contains('/'),
+        s"operatorId must not contain '/' (VFS URI parsing relies on this): 
${opId.id}"
+      )
+    }
+
+    val whSegment = warehousePathSegment(warehouse)
     val baseUri = operatorId match {
       case Some(opId) =>
-        
s"$VFS_FILE_URI_SCHEME:///wid/${workflowId.id}/eid/${executionId.id}/opid/${opId.id}"
-      case None => 
s"$VFS_FILE_URI_SCHEME:///wid/${workflowId.id}/eid/${executionId.id}"
+        
s"$VFS_FILE_URI_SCHEME://$whSegment/wid/${workflowId.id}/eid/${executionId.id}/opid/${opId.id}"
+      case None => 
s"$VFS_FILE_URI_SCHEME://$whSegment/wid/${workflowId.id}/eid/${executionId.id}"
     }
 
     new URI(s"$baseUri/${resourceType.toString.toLowerCase}")
diff --git 
a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/result/iceberg/IcebergDocument.scala
 
b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/result/iceberg/IcebergDocument.scala
index 182da2baac..cc414825d9 100644
--- 
a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/result/iceberg/IcebergDocument.scala
+++ 
b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/result/iceberg/IcebergDocument.scala
@@ -57,6 +57,7 @@ object Constants {
   * @param tableSchema    schema of the table.
   * @param serde          function to serialize T into an Iceberg Record.
   * @param deserde        function to deserialize an Iceberg Record into T.
+  * @param warehouse      the warehouse whose catalog backs this table; `None` 
uses the configured default.
   * @tparam T type of the data items stored in the Iceberg table.
   */
 private[storage] class IcebergDocument[T >: Null <: AnyRef](
@@ -64,13 +65,14 @@ private[storage] class IcebergDocument[T >: Null <: AnyRef](
     val tableName: String,
     val tableSchema: org.apache.iceberg.Schema,
     val serde: (org.apache.iceberg.Schema, T) => Record,
-    val deserde: (org.apache.iceberg.Schema, Record) => T
+    val deserde: (org.apache.iceberg.Schema, Record) => T,
+    val warehouse: Option[String] = None
 ) extends VirtualDocument[T]
     with OnIceberg {
 
   private val lock = new ReentrantReadWriteLock()
 
-  @transient lazy val catalog: Catalog = IcebergCatalogInstance.getInstance()
+  @transient lazy val catalog: Catalog = 
IcebergCatalogInstance.getInstance(warehouse)
 
   /**
     * Returns the URI of the table location.
diff --git 
a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/workflow/WorkflowContext.scala
 
b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/workflow/WorkflowContext.scala
index df6944ed88..5d12daf18a 100644
--- 
a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/workflow/WorkflowContext.scala
+++ 
b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/workflow/WorkflowContext.scala
@@ -35,5 +35,6 @@ class WorkflowContext(
     var workflowId: WorkflowIdentity = DEFAULT_WORKFLOW_ID,
     var executionId: ExecutionIdentity = DEFAULT_EXECUTION_ID,
     var workflowSettings: WorkflowSettings = DEFAULT_WORKFLOW_SETTINGS,
-    var cuid: Option[Int] = None
+    var cuid: Option[Int] = None,
+    var warehouse: Option[String] = None
 )
diff --git 
a/common/workflow-core/src/main/scala/org/apache/texera/amber/util/serde/GlobalPortIdentitySerde.scala
 
b/common/workflow-core/src/main/scala/org/apache/texera/amber/util/serde/GlobalPortIdentitySerde.scala
index c8fd8e1a36..65a836bf7a 100644
--- 
a/common/workflow-core/src/main/scala/org/apache/texera/amber/util/serde/GlobalPortIdentitySerde.scala
+++ 
b/common/workflow-core/src/main/scala/org/apache/texera/amber/util/serde/GlobalPortIdentitySerde.scala
@@ -53,6 +53,17 @@ object GlobalPortIdentitySerde {
         !layerName.contains('_'),
         s"layerName must not contain '_' (VFS URI parsing relies on this): 
$layerName"
       )
+      // A '/' would add path segments to the VFS URI these ids are 
interpolated
+      // into, letting a user-chosen id forge structure the URI never meant to 
have
+      // (e.g. a `wh/<name>` pair). Rejected for the same reason as '_'.
+      require(
+        !logicalOpId.contains('/'),
+        s"logicalOpId must not contain '/' (VFS URI parsing relies on this): 
$logicalOpId"
+      )
+      require(
+        !layerName.contains('/'),
+        s"layerName must not contain '/' (VFS URI parsing relies on this): 
$layerName"
+      )
       require(portId >= 0, s"portId must be non-negative: $portId")
       
s"(logicalOpId=$logicalOpId,layerName=$layerName,portId=$portId,isInternal=$isInternal,isInput=$isInput)"
     }
diff --git 
a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/DocumentFactorySpec.scala
 
b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/DocumentFactorySpec.scala
index ad8b5580c5..ecb5f5c54f 100644
--- 
a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/DocumentFactorySpec.scala
+++ 
b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/DocumentFactorySpec.scala
@@ -61,7 +61,11 @@ class DocumentFactorySpec extends AnyFlatSpec with Matchers 
with BeforeAndAfterA
 
   override def beforeAll(): Unit = {
     super.beforeAll()
-    LocalHadoopIcebergCatalog.ensure()
+    // "wh-test" is registered explicitly so the warehouse-scoped case 
resolves to
+    // this local catalog instead of reaching for a live REST catalog under the
+    // default (`rest`) config -- otherwise the case would pass or fail 
according to
+    // the machine's catalog type rather than the code under test.
+    LocalHadoopIcebergCatalog.ensure("wh-test")
   }
 
   // 
---------------------------------------------------------------------------
@@ -235,6 +239,27 @@ class DocumentFactorySpec extends AnyFlatSpec with 
Matchers with BeforeAndAfterA
     DocumentFactory.documentExists(stateUri) shouldBe true
   }
 
+  it should "create + find a document for a warehouse-scoped vfs URI (the /wh/ 
segment is stripped from the storage key)" in {
+    val base = VFSURIFactory.createPortBaseURI(
+      WorkflowIdentity(0),
+      ExecutionIdentity(0),
+      GlobalPortIdentity(
+        PhysicalOpIdentity(
+          logicalOpId = 
OperatorIdentity(s"op-${UUID.randomUUID().toString.replace("-", "")}"),
+          layerName = "main"
+        ),
+        PortIdentity()
+      ),
+      warehouse = Some("wh-test")
+    )
+    val vfsUri = VFSURIFactory.resultURI(base)
+    vfsUri.getPath should startWith("/wh/wh-test/")
+
+    val doc = DocumentFactory.createDocument(vfsUri, vfsSchema)
+    doc shouldBe an[IcebergDocument[_]]
+    DocumentFactory.documentExists(vfsUri) shouldBe true
+  }
+
   "documentExists" should "report false before creation and true after for a 
vfs URI" in {
     val vfsUri = freshResultURI()
     DocumentFactory.documentExists(vfsUri) shouldBe false
diff --git 
a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/LocalHadoopIcebergCatalog.scala
 
b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/LocalHadoopIcebergCatalog.scala
index 3b92b763a3..df3f0367d6 100644
--- 
a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/LocalHadoopIcebergCatalog.scala
+++ 
b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/LocalHadoopIcebergCatalog.scala
@@ -19,6 +19,7 @@
 
 package org.apache.texera.amber.core.storage
 
+import org.apache.iceberg.catalog.Catalog
 import org.apache.texera.amber.util.IcebergUtil
 
 import java.nio.file.{Files, Path}
@@ -42,11 +43,21 @@ import java.util.Comparator
   */
 object LocalHadoopIcebergCatalog {
 
-  private var initialized = false
+  private var catalog: Option[Catalog] = None
 
-  def ensure(): Unit =
+  /**
+    * Installs the shared local catalog, additionally registering it under 
each of
+    * `warehouses`.
+    *
+    * A suite that exercises a warehouse-scoped URI must name that warehouse 
here:
+    * under the configured `rest` catalog type the cache is keyed by warehouse 
name,
+    * so an unregistered name would miss and try to build a *live* REST 
catalog,
+    * making the suite pass or fail according to the machine's catalog config 
rather
+    * than the code under test.
+    */
+  def ensure(warehouses: String*): Unit =
     synchronized {
-      if (!initialized) {
+      val installed = catalog.getOrElse {
         val warehouse = Files.createTempDirectory("wfcore-iceberg-shared")
         // Best-effort recursive cleanup of the temp warehouse on JVM exit so 
test runs
         // don't leave wfcore-iceberg-shared* directories behind on dev 
machines / CI.
@@ -57,10 +68,11 @@ object LocalHadoopIcebergCatalog {
             .forEach((p: Path) => Files.deleteIfExists(p))
           catch { case _: Throwable => () }
         }
-        IcebergCatalogInstance.replaceInstance(
-          IcebergUtil.createHadoopCatalog("wfcore-test", warehouse)
-        )
-        initialized = true
+        val created = IcebergUtil.createHadoopCatalog("wfcore-test", warehouse)
+        catalog = Some(created)
+        IcebergCatalogInstance.replaceInstance(created)
+        created
       }
+      warehouses.foreach(name => 
IcebergCatalogInstance.replaceInstance(installed, Some(name)))
     }
 }
diff --git 
a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/VFSURIFactorySpec.scala
 
b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/VFSURIFactorySpec.scala
index aa3eb0dfdb..f163a0658e 100644
--- 
a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/VFSURIFactorySpec.scala
+++ 
b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/VFSURIFactorySpec.scala
@@ -60,7 +60,7 @@ class VFSURIFactorySpec extends AnyFlatSpec {
     assert(resultURI.getPath.endsWith("/result"))
     assert(stateURI.getPath.endsWith("/state"))
 
-    val VFSUriComponents(wid, eid, globalPortIdOpt, resourceType) =
+    val VFSUriComponents(wid, eid, globalPortIdOpt, resourceType, _) =
       VFSURIFactory.decodeURI(resultURI)
     assert(wid == workflowId)
     assert(eid == executionId)
@@ -75,7 +75,7 @@ class VFSURIFactorySpec extends AnyFlatSpec {
     assert(path.endsWith("/runtimestatistics"))
     assert(!path.contains("/opid/"))
 
-    val VFSUriComponents(wid, eid, globalPortIdOpt, resourceType) = 
VFSURIFactory.decodeURI(uri)
+    val VFSUriComponents(wid, eid, globalPortIdOpt, resourceType, _) = 
VFSURIFactory.decodeURI(uri)
     assert(wid == workflowId)
     assert(eid == executionId)
     assert(globalPortIdOpt.isEmpty)
@@ -90,7 +90,7 @@ class VFSURIFactorySpec extends AnyFlatSpec {
 
     // The current `decodeURI` does not extract the operator id (it has no
     // "opid" branch), so we only round-trip wid/eid/resourceType here.
-    val VFSUriComponents(wid, eid, globalPortIdOpt, resourceType) = 
VFSURIFactory.decodeURI(uri)
+    val VFSUriComponents(wid, eid, globalPortIdOpt, resourceType, _) = 
VFSURIFactory.decodeURI(uri)
     assert(wid == workflowId)
     assert(eid == executionId)
     assert(globalPortIdOpt.isEmpty)
@@ -121,4 +121,114 @@ class VFSURIFactorySpec extends AnyFlatSpec {
       VFSURIFactory.decodeURI(new URI("vfs:///eid/2/wid"))
     }
   }
+
+  "decodeURI" should "report the warehouse from a leading /wh/<name> segment" 
in {
+    val uri =
+      VFSURIFactory.createPortBaseURI(
+        workflowId,
+        executionId,
+        portId,
+        warehouse = Some("user-2-foo")
+      )
+    assert(uri.getPath.startsWith("/wh/user-2-foo/"))
+    // A base URI has no resource segment, so decode the derived result URI.
+    assert(
+      
VFSURIFactory.decodeURI(VFSURIFactory.resultURI(uri)).warehouse.contains("user-2-foo")
+    )
+  }
+
+  it should "return None when no /wh/ segment is present (non-BYO URIs are 
unchanged)" in {
+    val uri = VFSURIFactory.createPortBaseURI(workflowId, executionId, portId)
+    assert(!uri.getPath.contains("/wh/"))
+    
assert(VFSURIFactory.decodeURI(VFSURIFactory.resultURI(uri)).warehouse.isEmpty)
+  }
+
+  it should "only honour a LEADING wh segment, never one deeper in the path" 
in {
+    // A `wh` appearing later -- e.g. inside an operator id -- must not select 
a
+    // warehouse: it would disagree with DocumentFactory, which strips only a
+    // leading `wh/<name>/`, and would route the write to another user's 
warehouse.
+    assert(
+      VFSURIFactory
+        .decodeURI(new 
URI("vfs:///wid/1/eid/2/opid/a/wh/victim/b/consolemessages"))
+        .warehouse
+        .isEmpty
+    )
+    // An operator literally named `wh` is likewise not a warehouse.
+    assert(
+      VFSURIFactory
+        .decodeURI(new URI("vfs:///wid/1/eid/2/opid/wh/consolemessages"))
+        .warehouse
+        .isEmpty
+    )
+  }
+
+  it should "not let a percent-encoded slash in the name forge extra segments" 
in {
+    // Decoded before splitting, this path would read as /wh/a/wid/999/... -- 
handing
+    // back "a" as the warehouse and shifting which `wid` the parser sees. 
Splitting
+    // the raw path keeps `%2F` inside its own segment, and the name is then 
rejected
+    // as illegal, so the URI resolves to no warehouse rather than to a wrong 
one.
+    assert(
+      VFSURIFactory
+        .decodeURI(new URI("vfs:///wh/a%2Fwid%2F999/wid/1/eid/2/result"))
+        .warehouse
+        .isEmpty
+    )
+    assert(
+      VFSURIFactory
+        .decodeURI(new URI("vfs:///wh/user-2%2Dfoo/wid/7/eid/3/result"))
+        .warehouse
+        .isEmpty
+    )
+  }
+
+  it should "locate wid/eid by raw segment, so an encoded slash cannot shift 
them" in {
+    // Decoded before splitting, this path reads as /wh/a/wid/999/wid/1/... 
and the
+    // key search finds the injected `wid` first -- resolving to execution 999 
and
+    // landing this execution's data under another's storage key. Python's
+    // decode_uri splits the raw path, so decoding here would also make the two
+    // languages disagree about which execution a URI belongs to.
+    val components =
+      VFSURIFactory.decodeURI(new 
URI("vfs:///wh/a%2Fwid%2F999/wid/1/eid/2/result"))
+    assert(components.workflowId == WorkflowIdentity(1))
+    assert(components.executionId == ExecutionIdentity(2))
+  }
+
+  "VFSURIFactory" should "reject an operatorId containing '/' rather than let 
it forge URI segments" in {
+    assertThrows[IllegalArgumentException] {
+      VFSURIFactory.createConsoleMessagesURI(
+        workflowId,
+        executionId,
+        OperatorIdentity("a/wh/victim/b")
+      )
+    }
+  }
+
+  it should "reject a warehouse name that is not safe as a URI path segment" 
in {
+    Seq("a/b", "a%2Fb", "", "-lead", "sp ace").foreach { bad =>
+      withClue(s"warehouse name '$bad' should be rejected: ") {
+        assertThrows[IllegalArgumentException] {
+          VFSURIFactory.createPortBaseURI(workflowId, executionId, portId, 
Some(bad))
+        }
+      }
+    }
+  }
+
+  "A warehouse-scoped URI" should
+    "still round-trip through decodeURI (wid/eid/port/resource resolved 
despite the /wh/ prefix)" in {
+    val base =
+      VFSURIFactory.createPortBaseURI(
+        workflowId,
+        executionId,
+        portId,
+        warehouse = Some("user-2-foo")
+      )
+    val resultURI = VFSURIFactory.resultURI(base)
+    assert(VFSURIFactory.decodeURI(resultURI).warehouse.contains("user-2-foo"))
+
+    val components = VFSURIFactory.decodeURI(resultURI)
+    assert(components.workflowId == workflowId)
+    assert(components.executionId == executionId)
+    assert(components.globalPortId.contains(portId))
+    assert(components.resourceType == VFSResourceType.RESULT)
+  }
 }

Reply via email to