This is an automated email from the ASF dual-hosted git repository.
FreeOnePlus pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-mcp-server.git
The following commit(s) were added to refs/heads/master by this push:
new c873a85 feat: add Lakehouse domain runtime (#180)
c873a85 is described below
commit c873a85388b2591c926d37fe0d6dacfbc23a68fa
Author: Yijia Su <[email protected]>
AuthorDate: Fri Jul 31 20:34:53 2026 +0800
feat: add Lakehouse domain runtime (#180)
---
.env.example | 15 +
CHANGELOG.md | 11 +
README.md | 22 +
doris_mcp_server/main.py | 33 +
doris_mcp_server/tools/capability_detector.py | 78 ++
doris_mcp_server/tools/domain_dispatcher.py | 29 +
doris_mcp_server/tools/doris_feature_matrix.py | 21 +-
doris_mcp_server/tools/lakehouse_handlers.py | 83 ++
doris_mcp_server/tools/tools_manager.py | 3 +
doris_mcp_server/utils/config.py | 85 ++
doris_mcp_server/utils/lakehouse_runtime.py | 1722 ++++++++++++++++++++++++
test/integration/test_real_doris_transports.py | 173 +++
test/protocol/test_multiworker_config.py | 90 ++
test/tools/test_capability_detector.py | 58 +
test/tools/test_capability_registry.py | 133 ++
test/tools/test_domain_dispatcher.py | 43 +
test/tools/test_doris_feature_matrix.py | 45 +
test/tools/test_lakehouse_handlers.py | 163 +++
test/utils/test_lakehouse_runtime.py | 519 +++++++
19 files changed, 3317 insertions(+), 9 deletions(-)
diff --git a/.env.example b/.env.example
index 2694f5e..b090727 100644
--- a/.env.example
+++ b/.env.example
@@ -86,6 +86,21 @@ CAPABILITY_SNAPSHOT_TTL_SECONDS=300
CAPABILITY_PROBE_TIMEOUT_SECONDS=5
CAPABILITY_STALE_GRACE_SECONDS=900
+# Read-only Governance runtime bounds and optional native lineage store.
+GOVERNANCE_MAX_SAMPLE_RATIO=0.25
+GOVERNANCE_MAX_AUDIT_WINDOW_DAYS=30
+GOVERNANCE_MAX_LINEAGE_EDGES=500
+GOVERNANCE_LINEAGE_STORE_TABLE=
+GOVERNANCE_LINEAGE_RECENT_EVENT_MINUTES=1440
+
+# Read-only Lakehouse catalog, snapshot, partition, and Variant bounds.
+LAKEHOUSE_MAX_CATALOG_OBJECTS=50
+LAKEHOUSE_MAX_CATALOG_DATABASES=20
+LAKEHOUSE_MAX_SNAPSHOTS=50
+LAKEHOUSE_MAX_PARTITIONS=100
+LAKEHOUSE_MAX_VARIANT_SAMPLE_ROWS=20
+LAKEHOUSE_MAX_VARIANT_PATHS=200
+
# ===================================================================
# Security Configuration
# ===================================================================
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dac5f85..0b044b2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -62,6 +62,9 @@ under **Unreleased** until a new version is selected and
published.
patterns, audit events, user-defined functions, and authentication mappings.
- An explicit queryable lineage-provider contract and canonical Doris lineage
event-store schema for companion-plugin deployments.
+- A read-only Lakehouse domain with capability-gated external-catalog,
+ lakehouse-table, snapshot, partition, pushdown, and Variant-shape
+ inspection.
- Real Doris process tests covering Streamable HTTP and stdio.
### Changed
@@ -102,6 +105,9 @@ under **Unreleased** until a new version is selected and
published.
versions and live plugin, store, and audit evidence: Doris 4.0.6 and later
can use native companion-plugin events, while audit inference remains the
primary path before 4.0.6 and an explicit degraded fallback afterward.
+- Selected Lakehouse and Variant capability variants from observed Doris
+ component versions and live metadata probes, with 4.1 lifecycle and
+ advanced Variant facets reported separately from target-level evidence.
### Fixed
@@ -124,6 +130,11 @@ under **Unreleased** until a new version is selected and
published.
- Emitted lineage edges only from attributable native events or conservative
direct-column audit evidence, without placeholder sources or invented
numeric confidence scores.
+- Kept catalog property values, storage locations, raw plans, and sampled
+ Variant values out of model-facing output while bounding object, snapshot,
+ partition, path, and type-shape evidence.
+- Preserved configured Governance and Lakehouse runtime limits across the
+ multi-worker parent-to-worker environment handoff.
- Excluded explicitly dead Doris components from active version gating while
preserving them in node inventory, and kept live runtime manifests within
the 16 KiB domain budget.
diff --git a/README.md b/README.md
index b7e9a79..51dd681 100644
--- a/README.md
+++ b/README.md
@@ -340,6 +340,28 @@ cp .env.example .env
probe phase (default: 5; range: 1-60)
* `CAPABILITY_STALE_GRACE_SECONDS`: Bounded interval in which a failed
refresh may reuse stale private evidence (default: 900; range: 0-86400)
+ * `GOVERNANCE_MAX_SAMPLE_RATIO`: Maximum live column-analysis sample
+ ratio (default: 0.25; range: greater than 0 through 1)
+ * `GOVERNANCE_MAX_AUDIT_WINDOW_DAYS`: Maximum audit-analysis window
+ (default: 30; range: 1-365)
+ * `GOVERNANCE_MAX_LINEAGE_EDGES`: Maximum returned lineage edges
+ (default: 500; range: 1-5000)
+ * `GOVERNANCE_LINEAGE_STORE_TABLE`: Optional canonical queryable
+ companion-plugin lineage event table
+ * `GOVERNANCE_LINEAGE_RECENT_EVENT_MINUTES`: Native lineage-provider
+ recency window (default: 1440; range: 1-525600)
+ * `LAKEHOUSE_MAX_CATALOG_OBJECTS`: Maximum sampled relations per
+ external-catalog inspection (default: 50; range: 1-500)
+ * `LAKEHOUSE_MAX_CATALOG_DATABASES`: Maximum sampled databases per
+ external catalog (default: 20; range: 1-100)
+ * `LAKEHOUSE_MAX_SNAPSHOTS`: Maximum returned lakehouse snapshots
+ (default: 50; range: 1-500)
+ * `LAKEHOUSE_MAX_PARTITIONS`: Maximum returned lakehouse partitions
+ (default: 100; range: 1-1000)
+ * `LAKEHOUSE_MAX_VARIANT_SAMPLE_ROWS`: Maximum rows inspected through
+ `VARIANT_TYPE` without returning values (default: 20; range: 1-500)
+ * `LAKEHOUSE_MAX_VARIANT_PATHS`: Maximum returned Variant type-shape
+ paths (default: 200; range: 1-2000)
* `MCP_STATE_HANDLE_SECRET`: Optional shared high-entropy key (at least
32 bytes) used to authenticate explicit cross-call state handles
* `MCP_STATE_HANDLE_TTL_SECONDS`: Lifetime of an explicit state handle
diff --git a/doris_mcp_server/main.py b/doris_mcp_server/main.py
index 9439ac4..a4b7a68 100644
--- a/doris_mcp_server/main.py
+++ b/doris_mcp_server/main.py
@@ -101,6 +101,39 @@ def _multiworker_environment(
"CAPABILITY_STALE_GRACE_SECONDS": str(
config.capability.stale_grace_seconds
),
+ "GOVERNANCE_MAX_SAMPLE_RATIO": str(
+ config.governance.max_sample_ratio
+ ),
+ "GOVERNANCE_MAX_AUDIT_WINDOW_DAYS": str(
+ config.governance.max_audit_window_days
+ ),
+ "GOVERNANCE_MAX_LINEAGE_EDGES": str(
+ config.governance.max_lineage_edges
+ ),
+ "GOVERNANCE_LINEAGE_STORE_TABLE": (
+ config.governance.lineage_store_table
+ ),
+ "GOVERNANCE_LINEAGE_RECENT_EVENT_MINUTES": str(
+ config.governance.lineage_recent_event_minutes
+ ),
+ "LAKEHOUSE_MAX_CATALOG_OBJECTS": str(
+ config.lakehouse.max_catalog_objects
+ ),
+ "LAKEHOUSE_MAX_CATALOG_DATABASES": str(
+ config.lakehouse.max_catalog_databases
+ ),
+ "LAKEHOUSE_MAX_SNAPSHOTS": str(
+ config.lakehouse.max_snapshots
+ ),
+ "LAKEHOUSE_MAX_PARTITIONS": str(
+ config.lakehouse.max_partitions
+ ),
+ "LAKEHOUSE_MAX_VARIANT_SAMPLE_ROWS": str(
+ config.lakehouse.max_variant_sample_rows
+ ),
+ "LAKEHOUSE_MAX_VARIANT_PATHS": str(
+ config.lakehouse.max_variant_paths
+ ),
"MCP_STATE_HANDLE_SECRET": config.mcp_state_handle_secret,
"MCP_STATE_HANDLE_TTL_SECONDS":
str(config.mcp_state_handle_ttl_seconds),
"SERVER_NAME": config.server_name,
diff --git a/doris_mcp_server/tools/capability_detector.py
b/doris_mcp_server/tools/capability_detector.py
index eafc49f..09fe0c2 100644
--- a/doris_mcp_server/tools/capability_detector.py
+++ b/doris_mcp_server/tools/capability_detector.py
@@ -347,6 +347,26 @@ _DOMAIN_PROBES: Mapping[str, tuple[tuple[str, tuple[str,
...]], ...]] = {
("lineage_plugin_config_readable",),
),
),
+ "doris_lakehouse": (
+ (
+ "SHOW CATALOGS",
+ ("external_catalog_metadata_readable",),
+ ),
+ (
+ (
+ "SELECT TABLE_CATALOG, TABLE_SCHEMA, TABLE_NAME "
+ "FROM information_schema.tables LIMIT 1"
+ ),
+ ("lakehouse_table_metadata_readable",),
+ ),
+ (
+ (
+ "SELECT COLUMN_NAME, DATA_TYPE "
+ "FROM information_schema.columns LIMIT 1"
+ ),
+ ("variant_column_type_readable",),
+ ),
+ ),
}
@@ -490,6 +510,10 @@ class DorisCapabilityDetector:
probes.update(
_combine_governance_evidence_probes(probes)
)
+ elif domain_name == "doris_lakehouse":
+ probes.update(
+ _combine_lakehouse_evidence_probes(probes)
+ )
completed_route = self.route_identity(auth_context)
if completed_route.fingerprint != base.route.fingerprint:
raise CapabilityRouteChangedError(
@@ -1905,6 +1929,60 @@ def _combine_governance_evidence_probes(
return derived
+def _combine_lakehouse_evidence_probes(
+ probes: Mapping[str, CapabilityProbeEvidence],
+) -> dict[str, CapabilityProbeEvidence]:
+ """Derive target-sensitive 4.1 facets from readable metadata surfaces."""
+ derived: dict[str, CapabilityProbeEvidence] = {}
+ table_metadata = probes.get("lakehouse_table_metadata_readable")
+ if table_metadata is not None:
+ for probe_id in (
+ "lakehouse_snapshot_features_readable",
+ "iceberg_deletion_vector",
+ "iceberg_row_lineage",
+ ):
+ derived[probe_id] = CapabilityProbeEvidence(
+ probe_id=probe_id,
+ status=(
+ CapabilityProbeStatus.DEGRADED
+ if table_metadata.status is CapabilityProbeStatus.SUPPORTED
+ else table_metadata.status
+ ),
+ reason_code=(
+ "TARGET_LAKEHOUSE_FORMAT_REQUIRES_CALL_TIME_VALIDATION"
+ if table_metadata.status is CapabilityProbeStatus.SUPPORTED
+ else table_metadata.reason_code
+ ),
+ evidence_sources=table_metadata.evidence_sources,
+ )
+ variant_metadata = probes.get("variant_column_type_readable")
+ if variant_metadata is not None:
+ for probe_id in (
+ "variant_advanced_properties_readable",
+ "variant_sparse_sharding",
+ "variant_sparse_cache",
+ "variant_doc_mode",
+ "storage_v3",
+ ):
+ derived[probe_id] = CapabilityProbeEvidence(
+ probe_id=probe_id,
+ status=(
+ CapabilityProbeStatus.DEGRADED
+ if variant_metadata.status
+ is CapabilityProbeStatus.SUPPORTED
+ else variant_metadata.status
+ ),
+ reason_code=(
+ "TARGET_VARIANT_PROPERTIES_REQUIRE_CALL_TIME_VALIDATION"
+ if variant_metadata.status
+ is CapabilityProbeStatus.SUPPORTED
+ else variant_metadata.reason_code
+ ),
+ evidence_sources=variant_metadata.evidence_sources,
+ )
+ return derived
+
+
def _combine_all_runtime_probes(
probe_id: str,
probes: Mapping[str, CapabilityProbeEvidence],
diff --git a/doris_mcp_server/tools/domain_dispatcher.py
b/doris_mcp_server/tools/domain_dispatcher.py
index 802498b..2144e16 100644
--- a/doris_mcp_server/tools/domain_dispatcher.py
+++ b/doris_mcp_server/tools/domain_dispatcher.py
@@ -45,6 +45,7 @@ from ..state_handles import StateHandleCodec, StateHandleError
from ..utils.catalog_metadata import CatalogMetadataFailure
from ..utils.cluster_runtime import ClusterRuntimeFailure
from ..utils.governance_runtime import GovernanceRuntimeFailure
+from ..utils.lakehouse_runtime import LakehouseRuntimeFailure
from ..utils.logger import get_audit_logger, get_logger
from ..utils.pipeline_runtime import PipelineRuntimeFailure
from ..utils.query_runtime import QueryRuntimeFailure
@@ -736,6 +737,34 @@ class DomainDispatcher:
"status_code": exc.status_code,
},
)
+ except LakehouseRuntimeFailure as exc:
+ self._audit(feature_id, arguments, "error", started)
+ return self._error(
+ domain.name,
+ (
+ DomainErrorCode.CHILD_ARGUMENTS_INVALID
+ if exc.reason_code
+ in {
+ "LAKEHOUSE_ARGUMENT_INVALID",
+ "LAKEHOUSE_CATALOG_NOT_FOUND",
+ "LAKEHOUSE_CATALOG_NOT_EXTERNAL",
+ "LAKEHOUSE_TABLE_NOT_FOUND",
+ "LAKEHOUSE_TABLE_FORMAT_UNSUPPORTED",
+ "LAKEHOUSE_VARIANT_COLUMN_NOT_FOUND",
+ "LAKEHOUSE_COLUMN_NOT_VARIANT",
+ }
+ else DomainErrorCode.CHILD_EXECUTION_FAILED
+ ),
+ str(exc),
+ child_tool=child.name,
+ manifest_version=manifest.manifest_version,
+ retryable=exc.retryable,
+ details={
+ "rediscover": False,
+ "reason_code": exc.reason_code,
+ "status_code": exc.status_code,
+ },
+ )
except ToolOutputValidationError:
logger.exception("Formal child output validation failed for %s",
feature_id)
self._audit(
diff --git a/doris_mcp_server/tools/doris_feature_matrix.py
b/doris_mcp_server/tools/doris_feature_matrix.py
index 1b09ac4..5480eff 100644
--- a/doris_mcp_server/tools/doris_feature_matrix.py
+++ b/doris_mcp_server/tools/doris_feature_matrix.py
@@ -1468,27 +1468,25 @@ FEATURE_DEFINITIONS = (
"doris_lakehouse",
"inspect_lakehouse_table",
A,
- _variant(
- "lakehouse_table_metadata",
- providers=("external_catalog_provider",),
- probes=("lakehouse_table_metadata_readable",),
- ),
_variant(
"lakehouse_lifecycle_4_1",
ranges=(">=4.1.0",),
+ providers=("external_catalog_provider",),
features=("iceberg_deletion_vector", "iceberg_row_lineage"),
probes=("lakehouse_snapshot_features_readable",),
+ callable_when_degraded=True,
sources=("DORIS_RELEASE_4_1_0",),
),
+ _variant(
+ "lakehouse_table_metadata",
+ providers=("external_catalog_provider",),
+ probes=("lakehouse_table_metadata_readable",),
+ ),
),
_feature(
"doris_lakehouse",
"inspect_variant_column",
A,
- _variant(
- "variant_type",
- probes=("variant_column_type_readable",),
- ),
_variant(
"variant_advanced_4_1",
ranges=(">=4.1.0",),
@@ -1499,8 +1497,13 @@ FEATURE_DEFINITIONS = (
"storage_v3",
),
probes=("variant_advanced_properties_readable",),
+ callable_when_degraded=True,
sources=("DORIS_RELEASE_4_1_0",),
),
+ _variant(
+ "variant_type",
+ probes=("variant_column_type_readable",),
+ ),
),
_feature(
"doris_semantic",
diff --git a/doris_mcp_server/tools/lakehouse_handlers.py
b/doris_mcp_server/tools/lakehouse_handlers.py
new file mode 100644
index 0000000..d6c7b4f
--- /dev/null
+++ b/doris_mcp_server/tools/lakehouse_handlers.py
@@ -0,0 +1,83 @@
+# 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.
+
+"""Formal Lakehouse-domain handlers backed by one strict runtime."""
+
+from __future__ import annotations
+
+from typing import Any, Protocol, cast
+
+from ..utils.db import DorisConnectionManager
+from ..utils.lakehouse_runtime import DorisLakehouseRuntime
+
+
+class _LakehouseHandlerOwner(Protocol):
+ """State supplied by ``DorisToolsManager`` to the mixin."""
+
+ lakehouse_runtime: DorisLakehouseRuntime
+
+
+class LakehouseToolHandlersMixin:
+ """Route every Lakehouse child through the read-only runtime."""
+
+ def _initialize_lakehouse_handlers(
+ self: _LakehouseHandlerOwner,
+ connection_manager: DorisConnectionManager,
+ ) -> None:
+ self.lakehouse_runtime = DorisLakehouseRuntime(connection_manager)
+
+ async def _formal_doris_lakehouse_inspect_external_catalog_tool(
+ self: _LakehouseHandlerOwner,
+ arguments: dict[str, Any],
+ ) -> dict[str, Any]:
+ return await self.lakehouse_runtime.inspect_external_catalog(
+ catalog=cast(str, arguments.get("catalog")),
+ include_objects=bool(arguments.get("include_objects", False)),
+ object_limit=cast(int | None, arguments.get("object_limit")),
+ )
+
+ async def _formal_doris_lakehouse_inspect_lakehouse_table_tool(
+ self: _LakehouseHandlerOwner,
+ arguments: dict[str, Any],
+ ) -> dict[str, Any]:
+ return await self.lakehouse_runtime.inspect_lakehouse_table(
+ catalog=cast(str, arguments.get("catalog")),
+ database=cast(str, arguments.get("database")),
+ table=cast(str, arguments.get("table")),
+ include_snapshots=bool(
+ arguments.get("include_snapshots", False)
+ ),
+ include_partitions=bool(
+ arguments.get("include_partitions", False)
+ ),
+ )
+
+ async def _formal_doris_lakehouse_inspect_variant_column_tool(
+ self: _LakehouseHandlerOwner,
+ arguments: dict[str, Any],
+ ) -> dict[str, Any]:
+ return await self.lakehouse_runtime.inspect_variant_column(
+ catalog=cast(str | None, arguments.get("catalog")),
+ database=cast(str, arguments.get("database")),
+ table=cast(str, arguments.get("table")),
+ column=cast(str, arguments.get("column")),
+ path=cast(str | None, arguments.get("path")),
+ sample_rows=cast(int | None, arguments.get("sample_rows")),
+ )
+
+
+__all__ = ["LakehouseToolHandlersMixin"]
diff --git a/doris_mcp_server/tools/tools_manager.py
b/doris_mcp_server/tools/tools_manager.py
index ec65c75..9cfeb3c 100644
--- a/doris_mcp_server/tools/tools_manager.py
+++ b/doris_mcp_server/tools/tools_manager.py
@@ -64,6 +64,7 @@ from .domain_manifest import (
)
from .doris_feature_matrix import DORIS_FEATURE_MATRIX
from .governance_handlers import GovernanceToolHandlersMixin
+from .lakehouse_handlers import LakehouseToolHandlersMixin
from .pipeline_handlers import PipelineToolHandlersMixin
from .query_handlers import QueryToolHandlersMixin
from .search_handlers import SearchToolHandlersMixin
@@ -80,6 +81,7 @@ class DorisToolsManager(
PipelineToolHandlersMixin,
SearchToolHandlersMixin,
GovernanceToolHandlersMixin,
+ LakehouseToolHandlersMixin,
DomainManifestManagerMixin,
):
"""Apache Doris Tools Manager"""
@@ -129,6 +131,7 @@ class DorisToolsManager(
self.query_runtime,
)
self._initialize_governance_handlers(connection_manager)
+ self._initialize_lakehouse_handlers(connection_manager)
self._capability_registry: CapabilityRegistry | None = None
if domain_availability_provider is None:
bound_handlers = BoundHandlerAvailabilityProvider(self)
diff --git a/doris_mcp_server/utils/config.py b/doris_mcp_server/utils/config.py
index 3caa4fb..45c035b 100644
--- a/doris_mcp_server/utils/config.py
+++ b/doris_mcp_server/utils/config.py
@@ -876,6 +876,18 @@ class GovernanceConfig:
lineage_recent_event_minutes: int = 1440
+@dataclass
+class LakehouseConfig:
+ """Read-only Lakehouse runtime collection and sampling limits."""
+
+ max_catalog_objects: int = 50
+ max_catalog_databases: int = 20
+ max_snapshots: int = 50
+ max_partitions: int = 100
+ max_variant_sample_rows: int = 20
+ max_variant_paths: int = 200
+
+
@dataclass
class DorisConfig:
"""Doris MCP Server complete configuration"""
@@ -916,6 +928,7 @@ class DorisConfig:
default_factory=CapabilityConfig
)
governance: GovernanceConfig = field(default_factory=GovernanceConfig)
+ lakehouse: LakehouseConfig = field(default_factory=LakehouseConfig)
# Custom configuration
custom_config: dict[str, Any] = field(default_factory=dict)
@@ -1614,6 +1627,36 @@ class DorisConfig:
"GOVERNANCE_LINEAGE_RECENT_EVENT_MINUTES",
config.governance.lineage_recent_event_minutes,
)
+ if "LAKEHOUSE_MAX_CATALOG_OBJECTS" in os.environ:
+ config.lakehouse.max_catalog_objects = _env_int(
+ "LAKEHOUSE_MAX_CATALOG_OBJECTS",
+ config.lakehouse.max_catalog_objects,
+ )
+ if "LAKEHOUSE_MAX_CATALOG_DATABASES" in os.environ:
+ config.lakehouse.max_catalog_databases = _env_int(
+ "LAKEHOUSE_MAX_CATALOG_DATABASES",
+ config.lakehouse.max_catalog_databases,
+ )
+ if "LAKEHOUSE_MAX_SNAPSHOTS" in os.environ:
+ config.lakehouse.max_snapshots = _env_int(
+ "LAKEHOUSE_MAX_SNAPSHOTS",
+ config.lakehouse.max_snapshots,
+ )
+ if "LAKEHOUSE_MAX_PARTITIONS" in os.environ:
+ config.lakehouse.max_partitions = _env_int(
+ "LAKEHOUSE_MAX_PARTITIONS",
+ config.lakehouse.max_partitions,
+ )
+ if "LAKEHOUSE_MAX_VARIANT_SAMPLE_ROWS" in os.environ:
+ config.lakehouse.max_variant_sample_rows = _env_int(
+ "LAKEHOUSE_MAX_VARIANT_SAMPLE_ROWS",
+ config.lakehouse.max_variant_sample_rows,
+ )
+ if "LAKEHOUSE_MAX_VARIANT_PATHS" in os.environ:
+ config.lakehouse.max_variant_paths = _env_int(
+ "LAKEHOUSE_MAX_VARIANT_PATHS",
+ config.lakehouse.max_variant_paths,
+ )
if "MCP_STATE_HANDLE_SECRET" in os.environ:
config.mcp_state_handle_secret = os.getenv(
"MCP_STATE_HANDLE_SECRET",
@@ -1732,6 +1775,12 @@ class DorisConfig:
if hasattr(config.governance, key):
setattr(config.governance, key, value)
+ if "lakehouse" in config_data:
+ lakehouse_config = config_data["lakehouse"]
+ for key, value in lakehouse_config.items():
+ if hasattr(config.lakehouse, key):
+ setattr(config.lakehouse, key, value)
+
# Custom configuration
config.custom_config = config_data.get("custom", {})
@@ -1776,6 +1825,18 @@ class DorisConfig:
self.governance.lineage_recent_event_minutes
),
},
+ "lakehouse": {
+ "max_catalog_objects": self.lakehouse.max_catalog_objects,
+ "max_catalog_databases": (
+ self.lakehouse.max_catalog_databases
+ ),
+ "max_snapshots": self.lakehouse.max_snapshots,
+ "max_partitions": self.lakehouse.max_partitions,
+ "max_variant_sample_rows": (
+ self.lakehouse.max_variant_sample_rows
+ ),
+ "max_variant_paths": self.lakehouse.max_variant_paths,
+ },
"database": {
"host": self.database.host,
"hosts": self.database.hosts,
@@ -2034,6 +2095,30 @@ class DorisConfig:
"Governance recent lineage event window must be in the range "
"1-525600 minutes"
)
+ if not 1 <= self.lakehouse.max_catalog_objects <= 500:
+ errors.append(
+ "Lakehouse catalog object limit must be in the range 1-500"
+ )
+ if not 1 <= self.lakehouse.max_catalog_databases <= 100:
+ errors.append(
+ "Lakehouse catalog database limit must be in the range 1-100"
+ )
+ if not 1 <= self.lakehouse.max_snapshots <= 500:
+ errors.append(
+ "Lakehouse snapshot limit must be in the range 1-500"
+ )
+ if not 1 <= self.lakehouse.max_partitions <= 1000:
+ errors.append(
+ "Lakehouse partition limit must be in the range 1-1000"
+ )
+ if not 1 <= self.lakehouse.max_variant_sample_rows <= 500:
+ errors.append(
+ "Lakehouse Variant sample limit must be in the range 1-500"
+ )
+ if not 1 <= self.lakehouse.max_variant_paths <= 2000:
+ errors.append(
+ "Lakehouse Variant path limit must be in the range 1-2000"
+ )
raw_tool_providers: Any = self.mcp_tool_providers
if not isinstance(raw_tool_providers, list):
diff --git a/doris_mcp_server/utils/lakehouse_runtime.py
b/doris_mcp_server/utils/lakehouse_runtime.py
new file mode 100644
index 0000000..35e161f
--- /dev/null
+++ b/doris_mcp_server/utils/lakehouse_runtime.py
@@ -0,0 +1,1722 @@
+# 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.
+
+"""Bounded, read-only runtime for external lakehouse and Variant evidence."""
+
+from __future__ import annotations
+
+import json
+import re
+import uuid
+from collections import Counter, defaultdict
+from collections.abc import Mapping, Sequence
+from datetime import date, datetime
+from typing import Any
+
+from ..tools.doris_version import (
+ DORIS_VERSION_COMMENT_QUERY,
+ DorisVersion,
+ parse_doris_version_comment,
+)
+from .db import DorisConnectionManager
+from .redaction import redact_sensitive_data
+from .security import get_current_auth_context
+from .sql_security_utils import (
+ SQLSecurityError,
+ build_table_reference,
+ quote_identifier,
+ validate_identifier,
+)
+
+_MAX_BYTES = 2 * 1024 * 1024
+_DEFAULT_CATALOG_OBJECTS = 50
+_DEFAULT_CATALOG_DATABASES = 20
+_DEFAULT_SNAPSHOTS = 50
+_DEFAULT_PARTITIONS = 100
+_DEFAULT_VARIANT_SAMPLE_ROWS = 20
+_DEFAULT_VARIANT_PATHS = 200
+_ABSOLUTE_CATALOG_OBJECTS = 500
+_ABSOLUTE_CATALOG_DATABASES = 100
+_ABSOLUTE_SNAPSHOTS = 500
+_ABSOLUTE_PARTITIONS = 1_000
+_ABSOLUTE_VARIANT_SAMPLE_ROWS = 500
+_ABSOLUTE_VARIANT_PATHS = 2_000
+_MAX_COLUMNS = 1_000
+_MAX_PROPERTY_KEYS = 128
+_MAX_TYPED_PATHS = 256
+_DORIS_4_1 = parse_doris_version_comment("Doris version doris-4.1.0")
+_LAKEHOUSE_FORMATS = frozenset({"iceberg", "hudi", "paimon", "delta"})
+_SNAPSHOT_SYSTEM_TABLE_FORMATS = frozenset({"iceberg", "paimon"})
+_SYSTEM_TABLE_SUFFIXES = frozenset({"snapshots", "partitions"})
+_SENSITIVE_PROPERTY_PARTS = (
+ "password",
+ "secret",
+ "token",
+ "credential",
+ "keytab",
+ "access_key",
+ "secret_key",
+ "private_key",
+ "jdbc_url",
+ "endpoint",
+ "uri",
+)
+_SAFE_VARIANT_PROPERTIES = frozenset(
+ {
+ "storage_format",
+ "variant_doc_hash_shard_count",
+ "variant_doc_materialization_min_rows",
+ "variant_enable_doc_mode",
+ "variant_enable_typed_paths_to_sparse",
+ "variant_flatten_nested",
+ "variant_max_sparse_column_statistics_size",
+ "variant_max_subcolumns_count",
+ "variant_sparse_hash_shard_count",
+ }
+)
+_CATALOG_CAPABILITIES: Mapping[str, tuple[str, ...]] = {
+ "iceberg": (
+ "metadata_discovery",
+ "partition_pruning",
+ "predicate_pushdown",
+ "snapshots",
+ "system_tables",
+ "time_travel",
+ ),
+ "paimon": (
+ "incremental_read",
+ "metadata_discovery",
+ "partition_pruning",
+ "predicate_pushdown",
+ "snapshots",
+ "system_tables",
+ "time_travel",
+ ),
+ "hudi": (
+ "incremental_read",
+ "metadata_discovery",
+ "partition_pruning",
+ "predicate_pushdown",
+ "time_travel",
+ ),
+ "hms": (
+ "metadata_discovery",
+ "partition_pruning",
+ "predicate_pushdown",
+ ),
+ "hive": (
+ "metadata_discovery",
+ "partition_pruning",
+ "predicate_pushdown",
+ ),
+ "delta": (
+ "metadata_discovery",
+ "partition_pruning",
+ "predicate_pushdown",
+ "time_travel",
+ ),
+ "jdbc": ("metadata_discovery", "predicate_pushdown"),
+ "es": ("metadata_discovery", "predicate_pushdown"),
+}
+_SIMPLE_PATH_SEGMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_-]{0,254}")
+_VARIANT_PROPERTY = re.compile(
+ r"""(?P<key>storage_format|variant_[a-z0-9_]+)
+ ["']?\s*=\s*["'](?P<value>[^"']{0,256})["']""",
+ re.IGNORECASE | re.VERBOSE,
+)
+_TYPED_PATH = re.compile(
+ r"""["'](?P<path>[^"']{1,255})["']\s*:\s*
+ (?P<type>[A-Za-z][A-Za-z0-9_]*(?:\s*\([^)]*\))?)""",
+ re.VERBOSE,
+)
+
+
+class LakehouseRuntimeFailure(RuntimeError):
+ """Sanitized failure carrying a stable Lakehouse reason code."""
+
+ def __init__(
+ self,
+ message: str,
+ *,
+ reason_code: str,
+ status_code: int,
+ retryable: bool = False,
+ ) -> None:
+ super().__init__(message)
+ self.reason_code = reason_code
+ self.status_code = status_code
+ self.retryable = retryable
+
+
+class DorisLakehouseRuntime:
+ """Inspect external lakehouse metadata without exposing source secrets."""
+
+ def __init__(self, connection_manager: DorisConnectionManager) -> None:
+ self._connection_manager = connection_manager
+ config = getattr(
+ getattr(connection_manager, "config", None),
+ "lakehouse",
+ None,
+ )
+ self._max_catalog_objects = _configured_limit(
+ getattr(config, "max_catalog_objects", None),
+ default=_DEFAULT_CATALOG_OBJECTS,
+ absolute=_ABSOLUTE_CATALOG_OBJECTS,
+ )
+ self._max_catalog_databases = _configured_limit(
+ getattr(config, "max_catalog_databases", None),
+ default=_DEFAULT_CATALOG_DATABASES,
+ absolute=_ABSOLUTE_CATALOG_DATABASES,
+ )
+ self._max_snapshots = _configured_limit(
+ getattr(config, "max_snapshots", None),
+ default=_DEFAULT_SNAPSHOTS,
+ absolute=_ABSOLUTE_SNAPSHOTS,
+ )
+ self._max_partitions = _configured_limit(
+ getattr(config, "max_partitions", None),
+ default=_DEFAULT_PARTITIONS,
+ absolute=_ABSOLUTE_PARTITIONS,
+ )
+ self._max_variant_sample_rows = _configured_limit(
+ getattr(config, "max_variant_sample_rows", None),
+ default=_DEFAULT_VARIANT_SAMPLE_ROWS,
+ absolute=_ABSOLUTE_VARIANT_SAMPLE_ROWS,
+ )
+ self._max_variant_paths = _configured_limit(
+ getattr(config, "max_variant_paths", None),
+ default=_DEFAULT_VARIANT_PATHS,
+ absolute=_ABSOLUTE_VARIANT_PATHS,
+ )
+ self._session_prefix = f"lakehouse_{uuid.uuid4().hex[:8]}"
+
+ async def inspect_external_catalog(
+ self,
+ *,
+ catalog: str,
+ include_objects: bool = False,
+ object_limit: int | None = None,
+ ) -> dict[str, Any]:
+ """Return sanitized catalog metadata and an optional bounded sample."""
+ catalog_name = _identifier(catalog, "catalog name")
+ limit = _bounded_int(
+ object_limit,
+ default=min(_DEFAULT_CATALOG_OBJECTS, self._max_catalog_objects),
+ maximum=self._max_catalog_objects,
+ field="object limit",
+ )
+ (
+ catalog_item,
+ properties,
+ evidence,
+ catalog_warning,
+ ) = await self._external_catalog(catalog_name)
+ catalog_type = _canonical_catalog_type(catalog_item.get("type"))
+ warnings = [catalog_warning] if catalog_warning else []
+ object_sample: dict[str, Any] = {
+ "databases": [],
+ "relations": [],
+ "truncated": False,
+ }
+ if include_objects:
+ object_sample, object_evidence, object_warnings = (
+ await self._catalog_object_sample(
+ catalog_name,
+ relation_limit=limit,
+ )
+ )
+ evidence.extend(object_evidence)
+ warnings.extend(object_warnings)
+
+ property_summary = _catalog_property_summary(properties)
+ data = {
+ "catalog": catalog_name,
+ "scope": "external",
+ "reported_type": catalog_type,
+ "visibility": "visible_to_caller",
+ "is_current": _as_bool(catalog_item.get("is_current")),
+ "created_at": _json_value(catalog_item.get("create_time")),
+ "last_updated_at": _json_value(
+ catalog_item.get("last_update_time")
+ ),
+ "declared_capabilities": list(
+ _CATALOG_CAPABILITIES.get(
+ catalog_type,
+ ("metadata_discovery",),
+ )
+ ),
+ "capability_evidence_quality": "inferred_from_reported_type",
+ "configuration": property_summary,
+ "object_sample": object_sample,
+ }
+ return _result(
+ data,
+ source="SHOW CATALOGS + SHOW CATALOG",
+ warnings=warnings,
+ evidence=evidence,
+ metadata={
+ "property_values_returned": False,
+ "object_limit": limit,
+ },
+ )
+
+ async def inspect_lakehouse_table(
+ self,
+ *,
+ catalog: str,
+ database: str,
+ table: str,
+ include_snapshots: bool = False,
+ include_partitions: bool = False,
+ ) -> dict[str, Any]:
+ """Inspect one external table with format-specific recorded
evidence."""
+ catalog_name = _identifier(catalog, "catalog name")
+ database_name = _identifier(database, "database name")
+ table_name = _identifier(table, "table name")
+ (
+ catalog_item,
+ properties,
+ evidence,
+ catalog_warning,
+ ) = await self._external_catalog(catalog_name)
+ del properties
+ warnings = [catalog_warning] if catalog_warning else []
+ relation = build_table_reference(
+ table_name,
+ database_name,
+ catalog_name,
+ )
+ # SQL sink audit: relation contains only strictly validated and quoted
+ # identifiers before _execute sends this read-only metadata command.
+ columns = await self._execute(
+ f"SHOW FULL COLUMNS FROM {relation}", # nosec B608
+ max_rows=_MAX_COLUMNS,
+ )
+ if not columns:
+ raise LakehouseRuntimeFailure(
+ "The requested external table is unavailable.",
+ reason_code="LAKEHOUSE_TABLE_NOT_FOUND",
+ status_code=404,
+ )
+ evidence.append(
+ _evidence("SHOW FULL COLUMNS", success=True, rows=len(columns))
+ )
+
+ # SQL sink audit: relation contains only strictly validated and quoted
+ # identifiers before _optional_execute reaches the read-only SQL sink.
+ create_rows, create_failure = await self._optional_execute(
+ f"SHOW CREATE TABLE {relation}", # nosec B608
+ max_rows=1,
+ )
+ create_sql = _create_statement(create_rows)
+ evidence.append(
+ _evidence(
+ "SHOW CREATE TABLE",
+ success=create_failure is None,
+ rows=len(create_rows),
+ failure=create_failure,
+ )
+ )
+ if create_failure is not None:
+ warnings.append(
+ "External table DDL metadata is unavailable; format evidence "
+ "is limited to the catalog provider."
+ )
+
+ catalog_type = _canonical_catalog_type(catalog_item.get("type"))
+ table_format = _infer_table_format(catalog_type, create_sql)
+ if table_format not in _LAKEHOUSE_FORMATS:
+ raise LakehouseRuntimeFailure(
+ "The requested table is not exposed as a supported lakehouse
format.",
+ reason_code="LAKEHOUSE_TABLE_FORMAT_UNSUPPORTED",
+ status_code=409,
+ )
+
+ stats, stats_failure = await self._table_statistics(relation)
+ evidence.append(
+ _evidence(
+ "SHOW TABLE STATS",
+ success=stats_failure is None,
+ rows=1 if stats else 0,
+ failure=stats_failure,
+ )
+ )
+ if stats_failure is not None:
+ warnings.append("External table statistics are unavailable.")
+
+ snapshots: list[dict[str, Any]] = []
+ snapshots_truncated = False
+ if include_snapshots:
+ (
+ snapshots,
+ snapshots_truncated,
+ snapshot_evidence,
+ snapshot_warning,
+ ) = await self._snapshot_metadata(
+ catalog_name,
+ database_name,
+ table_name,
+ table_format,
+ )
+ evidence.append(snapshot_evidence)
+ if snapshot_warning:
+ warnings.append(snapshot_warning)
+
+ partitions: list[dict[str, Any]] = []
+ partitions_truncated = False
+ if include_partitions:
+ (
+ partitions,
+ partitions_truncated,
+ partition_evidence,
+ partition_warning,
+ ) = await self._partition_metadata(
+ catalog_name,
+ database_name,
+ table_name,
+ table_format,
+ )
+ evidence.append(partition_evidence)
+ if partition_warning:
+ warnings.append(partition_warning)
+
+ plan_facets, plan_evidence, plan_warning = await self._plan_facets(
+ relation
+ )
+ evidence.append(plan_evidence)
+ if plan_warning:
+ warnings.append(plan_warning)
+ version = await self._version()
+ lifecycle = _lifecycle_capabilities(
+ version,
+ table_format,
+ columns,
+ snapshots,
+ )
+ if not plan_facets["predicate_pushdown_observed"]:
+ warnings.append(
+ "The metadata-only probe has no caller predicate and therefore
"
+ "does not prove filter pushdown for a business query."
+ )
+
+ data = {
+ "catalog": catalog_name,
+ "database": database_name,
+ "table": table_name,
+ "format": table_format,
+ "format_evidence_quality": (
+ "reported"
+ if catalog_type in _LAKEHOUSE_FORMATS
+ else "inferred_from_sanitized_ddl"
+ ),
+ "columns": [_column_item(row) for row in columns],
+ "column_count": len(columns),
+ "partition_columns": _partition_columns(create_sql),
+ "statistics": stats,
+ "snapshots": {
+ "items": snapshots,
+ "truncated": snapshots_truncated,
+ "supported_for_format": (
+ table_format in _SNAPSHOT_SYSTEM_TABLE_FORMATS
+ ),
+ },
+ "partitions": {
+ "items": partitions,
+ "truncated": partitions_truncated,
+ },
+ "pushdown": plan_facets,
+ "lifecycle": lifecycle,
+ }
+ return _result(
+ data,
+ source="external catalog metadata",
+ warnings=warnings,
+ evidence=evidence,
+ metadata={
+ "raw_ddl_returned": False,
+ "raw_plan_returned": False,
+ "storage_locations_returned": False,
+ },
+ )
+
+ async def inspect_variant_column(
+ self,
+ *,
+ catalog: str | None,
+ database: str,
+ table: str,
+ column: str,
+ path: str | None = None,
+ sample_rows: int | None = None,
+ ) -> dict[str, Any]:
+ """Inspect Variant configuration and sampled type shape without
values."""
+ catalog_name = (
+ _identifier(catalog, "catalog name")
+ if catalog is not None
+ else "internal"
+ )
+ database_name = _identifier(database, "database name")
+ table_name = _identifier(table, "table name")
+ column_name = _identifier(column, "column name")
+ sample_limit = _bounded_int(
+ sample_rows,
+ default=min(
+ _DEFAULT_VARIANT_SAMPLE_ROWS,
+ self._max_variant_sample_rows,
+ ),
+ maximum=self._max_variant_sample_rows,
+ field="sample rows",
+ )
+ path_segments = _variant_path(path)
+ relation = build_table_reference(
+ table_name,
+ database_name,
+ None if catalog_name == "internal" else catalog_name,
+ )
+ # SQL sink audit: relation contains only strictly validated and quoted
+ # identifiers before _execute sends this read-only metadata command.
+ columns = await self._execute(
+ f"SHOW FULL COLUMNS FROM {relation}", # nosec B608
+ max_rows=_MAX_COLUMNS,
+ )
+ target = next(
+ (
+ row
+ for row in columns
+ if str(_value(row, "field", "column_name") or "") ==
column_name
+ ),
+ None,
+ )
+ if target is None:
+ raise LakehouseRuntimeFailure(
+ "The requested Variant column is unavailable.",
+ reason_code="LAKEHOUSE_VARIANT_COLUMN_NOT_FOUND",
+ status_code=404,
+ )
+ declared_type = str(_value(target, "type", "data_type") or "")
+ if not declared_type.upper().startswith("VARIANT"):
+ raise LakehouseRuntimeFailure(
+ "The requested column is not a Doris Variant column.",
+ reason_code="LAKEHOUSE_COLUMN_NOT_VARIANT",
+ status_code=409,
+ )
+
+ # SQL sink audit: relation contains only strictly validated and quoted
+ # identifiers before _optional_execute reaches the read-only SQL sink.
+ create_rows, create_failure = await self._optional_execute(
+ f"SHOW CREATE TABLE {relation}", # nosec B608
+ max_rows=1,
+ )
+ create_sql = _create_statement(create_rows)
+ properties = _variant_properties(f"{declared_type}\n{create_sql}")
+ configuration, configuration_warnings = _variant_configuration(
+ properties
+ )
+
+ column_ref = quote_identifier(column_name, "column name")
+ expression = column_ref
+ params: list[Any] = []
+ for segment in path_segments:
+ expression += "[%s]"
+ params.append(segment)
+ # SQL sink audit: relation and column are validated/quoted, every path
+ # segment remains driver-bound, and _execute receives a bounded limit.
+ sample_sql = (
+ f"SELECT VARIANT_TYPE({expression}) AS `variant_type` " # nosec
B608
+ f"FROM {relation} WHERE {column_ref} IS NOT NULL "
+ f"LIMIT {sample_limit}"
+ )
+ sample_result, sample_failure = await self._optional_execute(
+ sample_sql,
+ params=tuple(params),
+ max_rows=sample_limit,
+ )
+ warnings = list(configuration_warnings)
+ if create_failure is not None:
+ warnings.append(
+ "Table DDL is unavailable; Variant mode properties may be
incomplete."
+ )
+ if sample_failure is not None:
+ warnings.append(
+ "Bounded VARIANT_TYPE sampling is unavailable; no row values "
+ "or inferred path shapes were returned."
+ )
+ observed_paths, paths_truncated = _variant_type_observations(
+ sample_result,
+ requested_path=path,
+ maximum=self._max_variant_paths,
+ )
+ version = await self._version()
+ advanced = _variant_advanced_capabilities(version, configuration)
+ typed_paths = _typed_paths(declared_type)
+ if len(typed_paths) >= _MAX_TYPED_PATHS:
+ warnings.append(
+ f"Typed Variant path metadata was limited to
{_MAX_TYPED_PATHS} paths."
+ )
+ if paths_truncated:
+ warnings.append(
+ f"Observed Variant paths were limited to
{self._max_variant_paths}."
+ )
+
+ evidence = [
+ _evidence("SHOW FULL COLUMNS", success=True, rows=len(columns)),
+ _evidence(
+ "SHOW CREATE TABLE",
+ success=create_failure is None,
+ rows=len(create_rows),
+ failure=create_failure,
+ ),
+ _evidence(
+ "VARIANT_TYPE bounded sample",
+ success=sample_failure is None,
+ rows=len(sample_result),
+ failure=sample_failure,
+ ),
+ ]
+ data = {
+ "catalog": catalog_name,
+ "database": database_name,
+ "table": table_name,
+ "column": column_name,
+ "declared_type": declared_type,
+ "nullable": str(_value(target, "null") or "").upper() == "YES",
+ "requested_path": path,
+ "typed_paths": typed_paths,
+ "configuration": configuration,
+ "advanced_capabilities": advanced,
+ "shape_sample": {
+ "rows_observed": len(sample_result),
+ "paths": observed_paths,
+ "truncated": paths_truncated,
+ },
+ }
+ return _result(
+ data,
+ source="SHOW FULL COLUMNS + VARIANT_TYPE",
+ warnings=warnings,
+ evidence=evidence,
+ metadata={
+ "sample_limit": sample_limit,
+ "sampled_values_returned": False,
+ "raw_ddl_returned": False,
+ },
+ )
+
+ async def _external_catalog(
+ self,
+ catalog_name: str,
+ ) -> tuple[
+ dict[str, Any],
+ dict[str, Any],
+ list[dict[str, Any]],
+ str | None,
+ ]:
+ rows = await self._execute("SHOW CATALOGS", max_rows=1_000)
+ catalog_item = next(
+ (
+ _catalog_item(row)
+ for row in rows
+ if str(_value(row, "catalogname", "catalog_name") or "")
+ == catalog_name
+ ),
+ None,
+ )
+ if catalog_item is None:
+ raise LakehouseRuntimeFailure(
+ "The requested external catalog is unavailable.",
+ reason_code="LAKEHOUSE_CATALOG_NOT_FOUND",
+ status_code=404,
+ )
+ if catalog_name == "internal":
+ raise LakehouseRuntimeFailure(
+ "The internal Doris catalog is not an external catalog.",
+ reason_code="LAKEHOUSE_CATALOG_NOT_EXTERNAL",
+ status_code=409,
+ )
+ catalog_ref = quote_identifier(catalog_name, "catalog name")
+ # SQL sink audit: catalog_ref is a strictly validated and quoted
+ # identifier before _optional_execute sends this read-only command.
+ property_rows, property_failure = await self._optional_execute(
+ f"SHOW CATALOG {catalog_ref}", # nosec B608
+ max_rows=1_000,
+ )
+ properties = {
+ str(key).strip().casefold(): value
+ for row in property_rows
+ if (key := _value(row, "key", "property", "name")) is not None
+ for value in (_value(row, "value", "property_value"),)
+ }
+ return (
+ catalog_item,
+ properties,
+ [
+ _evidence("SHOW CATALOGS", success=True, rows=len(rows)),
+ _evidence(
+ "SHOW CATALOG",
+ success=property_failure is None,
+ rows=len(property_rows),
+ failure=property_failure,
+ ),
+ ],
+ (
+ None
+ if property_failure is None
+ else "External catalog configuration metadata is unavailable."
+ ),
+ )
+
+ async def _catalog_object_sample(
+ self,
+ catalog_name: str,
+ *,
+ relation_limit: int,
+ ) -> tuple[dict[str, Any], list[dict[str, Any]], list[str]]:
+ catalog_ref = quote_identifier(catalog_name, "catalog name")
+ # SQL sink audit: catalog_ref is a strictly validated and quoted
+ # identifier before _optional_execute sends this read-only command.
+ database_rows, database_failure = await self._optional_execute(
+ f"SHOW DATABASES FROM {catalog_ref}", # nosec B608
+ max_rows=self._max_catalog_databases + 1,
+ )
+ if database_failure is not None:
+ return (
+ {
+ "databases": [],
+ "relations": [],
+ "truncated": False,
+ },
+ [
+ _evidence(
+ "SHOW DATABASES",
+ success=False,
+ rows=0,
+ failure=database_failure,
+ )
+ ],
+ ["Catalog database metadata is unavailable."],
+ )
+ database_names: list[str] = []
+ unaddressable_databases = 0
+ for row in database_rows:
+ value = _first_value(row)
+ if value is None:
+ continue
+ try:
+ database_names.append(
+ _identifier(str(value), "database name")
+ )
+ except LakehouseRuntimeFailure:
+ unaddressable_databases += 1
+ databases_truncated = len(database_names) > self._max_catalog_databases
+ database_names = database_names[: self._max_catalog_databases]
+ relations: list[dict[str, Any]] = []
+ warnings: list[str] = []
+ if unaddressable_databases:
+ warnings.append(
+ f"{unaddressable_databases} catalog database names were not "
+ "addressable under the strict identifier policy."
+ )
+ evidence = [
+ _evidence(
+ "SHOW DATABASES",
+ success=True,
+ rows=len(database_rows),
+ )
+ ]
+ for database_name in database_names:
+ if len(relations) >= relation_limit:
+ break
+ scope = (
+ f"{quote_identifier(catalog_name, 'catalog name')}."
+ f"{quote_identifier(database_name, 'database name')}"
+ )
+ # SQL sink audit: scope contains only strictly validated and quoted
+ # identifiers before _optional_execute reaches the read-only sink.
+ rows, failure = await self._optional_execute(
+ f"SHOW FULL TABLES FROM {scope}", # nosec B608
+ max_rows=(relation_limit - len(relations)) + 1,
+ )
+ evidence.append(
+ _evidence(
+ "SHOW FULL TABLES",
+ success=failure is None,
+ rows=len(rows),
+ failure=failure,
+ )
+ )
+ if failure is not None:
+ warnings.append(
+ f"Relation metadata is unavailable for database
{database_name}."
+ )
+ continue
+ for row in rows:
+ if len(relations) >= relation_limit:
+ break
+ name = _table_name(row)
+ if name is None:
+ continue
+ relations.append(
+ {
+ "database": database_name,
+ "name": name,
+ "type": _table_type(row),
+ }
+ )
+ truncated = (
+ databases_truncated
+ or bool(unaddressable_databases)
+ or len(relations) >= relation_limit
+ )
+ return (
+ {
+ "databases": [{"name": name} for name in database_names],
+ "relations": relations,
+ "truncated": truncated,
+ },
+ evidence,
+ warnings,
+ )
+
+ async def _snapshot_metadata(
+ self,
+ catalog: str,
+ database: str,
+ table: str,
+ table_format: str,
+ ) -> tuple[
+ list[dict[str, Any]],
+ bool,
+ dict[str, Any],
+ str | None,
+ ]:
+ if table_format not in _SNAPSHOT_SYSTEM_TABLE_FORMATS:
+ return (
+ [],
+ False,
+ _evidence(
+ "format system table",
+ success=False,
+ rows=0,
+ reason_code="SNAPSHOT_SYSTEM_TABLE_NOT_SUPPORTED",
+ ),
+ f"Snapshot system-table inspection is not defined for
{table_format}.",
+ )
+ reference = _system_table_reference(
+ catalog,
+ database,
+ table,
+ "snapshots",
+ )
+ # SQL sink audit: reference is assembled from validated/quoted user
+ # identifiers plus a fixed suffix before _optional_execute reaches
Doris.
+ rows, failure = await self._optional_execute(
+ f"SELECT * FROM {reference} LIMIT {self._max_snapshots + 1}", #
nosec B608
+ max_rows=self._max_snapshots + 1,
+ )
+ if failure is not None:
+ return (
+ [],
+ False,
+ _evidence(
+ f"{table_format} snapshots system table",
+ success=False,
+ rows=0,
+ failure=failure,
+ ),
+ "Snapshot metadata is unavailable for the requested table.",
+ )
+ truncated = len(rows) > self._max_snapshots
+ return (
+ [_snapshot_item(row) for row in rows[: self._max_snapshots]],
+ truncated,
+ _evidence(
+ f"{table_format} snapshots system table",
+ success=True,
+ rows=len(rows),
+ ),
+ None,
+ )
+
+ async def _partition_metadata(
+ self,
+ catalog: str,
+ database: str,
+ table: str,
+ table_format: str,
+ ) -> tuple[
+ list[dict[str, Any]],
+ bool,
+ dict[str, Any],
+ str | None,
+ ]:
+ relation = build_table_reference(table, database, catalog)
+ # SQL sink audit: relation contains only validated and quoted
identifiers,
+ # and the integer limit is bounded before _optional_execute reaches
Doris.
+ rows, failure = await self._optional_execute(
+ f"SHOW PARTITIONS FROM {relation} LIMIT {self._max_partitions +
1}", # nosec B608
+ max_rows=self._max_partitions + 1,
+ )
+ source = "SHOW PARTITIONS"
+ if failure is not None and table_format in
_SNAPSHOT_SYSTEM_TABLE_FORMATS:
+ reference = _system_table_reference(
+ catalog,
+ database,
+ table,
+ "partitions",
+ )
+ # SQL sink audit: reference is assembled from validated/quoted user
+ # identifiers plus a fixed suffix and a bounded integer limit.
+ rows, failure = await self._optional_execute(
+ f"SELECT * FROM {reference} LIMIT {self._max_partitions + 1}",
# nosec B608
+ max_rows=self._max_partitions + 1,
+ )
+ source = f"{table_format} partitions system table"
+ if failure is not None:
+ return (
+ [],
+ False,
+ _evidence(
+ source,
+ success=False,
+ rows=0,
+ failure=failure,
+ ),
+ "Partition metadata is unavailable for the requested table.",
+ )
+ truncated = len(rows) > self._max_partitions
+ return (
+ [_partition_item(row) for row in rows[: self._max_partitions]],
+ truncated,
+ _evidence(source, success=True, rows=len(rows)),
+ None,
+ )
+
+ async def _table_statistics(
+ self,
+ relation: str,
+ ) -> tuple[dict[str, Any], LakehouseRuntimeFailure | None]:
+ # SQL sink audit: relation contains only strictly validated and quoted
+ # identifiers before _optional_execute reaches the read-only SQL sink.
+ rows, failure = await self._optional_execute(
+ f"SHOW TABLE STATS {relation}", # nosec B608
+ max_rows=2,
+ )
+ if not rows:
+ return {}, failure
+ row = rows[0]
+ return (
+ {
+ "row_count": _number(
+ _value(row, "row_count", "rowcount", "rows")
+ ),
+ "data_size_bytes": _number(
+ _value(
+ row,
+ "data_size",
+ "data_length",
+ "datasize",
+ )
+ ),
+ "updated_at": _json_value(
+ _value(row, "update_time", "updated_at")
+ ),
+ },
+ failure,
+ )
+
+ async def _plan_facets(
+ self,
+ relation: str,
+ ) -> tuple[dict[str, Any], dict[str, Any], str | None]:
+ # SQL sink audit: relation contains only strictly validated and quoted
+ # identifiers before _optional_execute reaches this read-only EXPLAIN.
+ rows, failure = await self._optional_execute(
+ f"EXPLAIN SELECT * FROM {relation} LIMIT 1", # nosec B608
+ max_rows=512,
+ )
+ if failure is not None:
+ return (
+ {
+ "external_scan_observed": False,
+ "partition_pruning_observed": False,
+ "predicate_pushdown_observed": False,
+ "scan_nodes": [],
+ },
+ _evidence(
+ "EXPLAIN",
+ success=False,
+ rows=0,
+ failure=failure,
+ ),
+ "External scan plan evidence is unavailable.",
+ )
+ plan = "\n".join(
+ str(value)
+ for row in rows
+ for value in row.values()
+ if value is not None
+ )
+ upper = plan.upper()
+ scan_nodes = sorted(
+ {
+ marker
+ for marker in (
+ "HIVE_SCAN_NODE",
+ "ICEBERG_SCAN_NODE",
+ "HUDI_SCAN_NODE",
+ "PAIMON_SCAN_NODE",
+ "JDBC_SCAN_NODE",
+ "ES_SCAN_NODE",
+ "FILE_SCAN_NODE",
+ "VOlapScanNode",
+ )
+ if marker.upper() in upper
+ }
+ )
+ return (
+ {
+ "external_scan_observed": bool(scan_nodes)
+ or "EXTERNAL" in upper,
+ "partition_pruning_observed": any(
+ marker in upper
+ for marker in (
+ "PARTITION PREDICATES",
+ "PARTITIONS=",
+ "PARTITION PRUNING",
+ )
+ ),
+ "predicate_pushdown_observed": any(
+ marker in upper
+ for marker in (
+ "PUSHDOWN",
+ "PREDICATES:",
+ "PUSH DOWN",
+ )
+ ),
+ "scan_nodes": scan_nodes,
+ },
+ _evidence("EXPLAIN", success=True, rows=len(rows)),
+ None,
+ )
+
+ async def _version(self) -> DorisVersion:
+ rows, failure = await self._optional_execute(
+ DORIS_VERSION_COMMENT_QUERY,
+ max_rows=1,
+ )
+ if failure is not None or not rows:
+ return parse_doris_version_comment("")
+ value = _value(
+ rows[0],
+ "version_comment",
+ "@@version_comment",
+ )
+ return parse_doris_version_comment(
+ str(value) if value is not None else ""
+ )
+
+ async def _optional_execute(
+ self,
+ sql: str,
+ *,
+ params: Mapping[str, Any] | tuple[Any, ...] | None = None,
+ max_rows: int,
+ ) -> tuple[list[dict[str, Any]], LakehouseRuntimeFailure | None]:
+ try:
+ return (
+ await self._execute(
+ sql,
+ params=params,
+ max_rows=max_rows,
+ ),
+ None,
+ )
+ except LakehouseRuntimeFailure as exc:
+ return [], exc
+
+ async def _execute(
+ self,
+ sql: str,
+ *,
+ params: Mapping[str, Any] | tuple[Any, ...] | None = None,
+ max_rows: int,
+ ) -> list[dict[str, Any]]:
+ auth_context = get_current_auth_context()
+ session_id = f"{self._session_prefix}:{uuid.uuid4().hex[:8]}"
+ try:
+ async with
self._connection_manager.get_connection_context_for_auth_context(
+ session_id,
+ auth_context,
+ ) as connection:
+ result = await connection.execute(
+ sql,
+ params=params,
+ auth_context=auth_context,
+ mask_result=False,
+ max_rows=max_rows,
+ max_bytes=_MAX_BYTES,
+ )
+ except Exception as exc:
+ raise _classify_failure(exc) from exc
+ return [dict(row) for row in (result.data or ()) if isinstance(row,
Mapping)][
+ :max_rows
+ ]
+
+
+def _result(
+ data: Mapping[str, Any],
+ *,
+ source: str,
+ warnings: Sequence[str],
+ evidence: Sequence[Mapping[str, Any]],
+ metadata: Mapping[str, Any],
+) -> dict[str, Any]:
+ unique_warnings = list(dict.fromkeys(str(item) for item in warnings))
+ return {
+ "status": "partial" if unique_warnings else "success",
+ "data": redact_sensitive_data(dict(data)),
+ "warnings": unique_warnings,
+ "metadata": {
+ "source": source,
+ **dict(metadata),
+ },
+ "evidence": [
+ redact_sensitive_data(dict(item))
+ for item in evidence
+ ],
+ }
+
+
+def _evidence(
+ source: str,
+ *,
+ success: bool,
+ rows: int,
+ failure: LakehouseRuntimeFailure | None = None,
+ reason_code: str | None = None,
+) -> dict[str, Any]:
+ return {
+ "source": source,
+ "success": success,
+ "rows_observed": rows,
+ "reason_code": (
+ reason_code
+ if reason_code is not None
+ else (None if failure is None else failure.reason_code)
+ ),
+ }
+
+
+def _identifier(value: Any, field: str) -> str:
+ try:
+ return validate_identifier(value, field)
+ except (SQLSecurityError, TypeError) as exc:
+ raise _argument_failure(f"Invalid {field}.") from exc
+
+
+def _bounded_int(
+ value: Any,
+ *,
+ default: int,
+ maximum: int,
+ field: str,
+) -> int:
+ if value is None:
+ return default
+ if isinstance(value, bool) or not isinstance(value, int):
+ raise _argument_failure(f"{field.capitalize()} must be an integer.")
+ if not 1 <= value <= maximum:
+ raise _argument_failure(
+ f"{field.capitalize()} must be between 1 and {maximum}."
+ )
+ return int(value)
+
+
+def _configured_limit(value: Any, *, default: int, absolute: int) -> int:
+ if isinstance(value, bool) or not isinstance(value, int):
+ return default
+ return value if 1 <= value <= absolute else default
+
+
+def _variant_path(value: Any) -> tuple[str, ...]:
+ if value is None:
+ return ()
+ if not isinstance(value, str):
+ raise _argument_failure("Variant path must be a string.")
+ normalized = value.strip()
+ if not normalized or len(normalized) > 1_024:
+ raise _argument_failure(
+ "Variant path must contain 1-1024 characters."
+ )
+ if normalized == "$":
+ return ()
+ if normalized.startswith("$."):
+ normalized = normalized[2:]
+ elif normalized.startswith("$"):
+ raise _argument_failure(
+ "Variant path must use simple $.segment notation."
+ )
+ parts = normalized.split(".")
+ if not 1 <= len(parts) <= 16 or any(
+ _SIMPLE_PATH_SEGMENT.fullmatch(part) is None for part in parts
+ ):
+ raise _argument_failure(
+ "Variant path must contain 1-16 simple dot-separated segments."
+ )
+ return tuple(parts)
+
+
+def _system_table_reference(
+ catalog: str,
+ database: str,
+ table: str,
+ suffix: str,
+) -> str:
+ catalog_name = _identifier(catalog, "catalog name")
+ database_name = _identifier(database, "database name")
+ table_name = _identifier(table, "table name")
+ if suffix not in _SYSTEM_TABLE_SUFFIXES:
+ raise _argument_failure("Unsupported lakehouse system-table suffix.")
+ system_table = f"`{table_name}${suffix}`"
+ return (
+ f"{quote_identifier(catalog_name, 'catalog name')}."
+ f"{quote_identifier(database_name, 'database name')}."
+ f"{system_table}"
+ )
+
+
+def _catalog_item(row: Mapping[str, Any]) -> dict[str, Any]:
+ return {
+ "name": _value(row, "catalogname", "catalog_name"),
+ "type": _value(row, "type", "catalog_type"),
+ "is_current": _value(row, "iscurrent", "is_current"),
+ "create_time": _value(row, "createtime", "create_time"),
+ "last_update_time": _value(
+ row,
+ "lastupdatetime",
+ "last_update_time",
+ ),
+ "comment": _value(row, "comment"),
+ }
+
+
+def _catalog_property_summary(
+ properties: Mapping[str, Any],
+) -> dict[str, Any]:
+ safe_keys = sorted(
+ key
+ for key in properties
+ if not _sensitive_property(key)
+ )[:_MAX_PROPERTY_KEYS]
+ redacted_count = sum(
+ 1 for key in properties if _sensitive_property(key)
+ )
+ cache_keys = (
+ "use_meta_cache",
+ "metadata_cache_enabled",
+ "enable_meta_cache",
+ )
+ refresh_keys = (
+ "metadata_refresh_interval_sec",
+ "refresh_interval_sec",
+ )
+ return {
+ "property_count": len(properties),
+ "property_keys": safe_keys,
+ "property_keys_truncated": len(safe_keys)
+ < (len(properties) - redacted_count),
+ "sensitive_property_count": redacted_count,
+ "property_values_returned": False,
+ "metadata_cache_configured": any(
+ key in properties for key in cache_keys
+ ),
+ "refresh_interval_configured": any(
+ key in properties for key in refresh_keys
+ ),
+ "warehouse_configured": any(
+ key in properties
+ for key in (
+ "warehouse",
+ "warehouse_location",
+ "iceberg.catalog.warehouse",
+ )
+ ),
+ }
+
+
+def _sensitive_property(key: str) -> bool:
+ normalized = key.casefold().replace(".", "_").replace("-", "_")
+ return any(part in normalized for part in _SENSITIVE_PROPERTY_PARTS)
+
+
+def _canonical_catalog_type(value: Any) -> str:
+ normalized = (
+ str(value or "unknown")
+ .strip()
+ .casefold()
+ .replace("-", "_")
+ .replace(" ", "_")
+ )
+ aliases = {
+ "iceberg_catalog": "iceberg",
+ "paimon_catalog": "paimon",
+ "hudi_catalog": "hudi",
+ "hive": "hms",
+ "deltalake": "delta",
+ "delta_lake": "delta",
+ "elasticsearch": "es",
+ }
+ return aliases.get(normalized, normalized)
+
+
+def _infer_table_format(catalog_type: str, create_sql: str) -> str:
+ if catalog_type in _LAKEHOUSE_FORMATS:
+ return catalog_type
+ upper = create_sql.upper()
+ for marker, name in (
+ ("ICEBERG", "iceberg"),
+ ("PAIMON", "paimon"),
+ ("HUDI", "hudi"),
+ ("DELTA", "delta"),
+ ):
+ if marker in upper:
+ return name
+ return "unknown"
+
+
+def _column_item(row: Mapping[str, Any]) -> dict[str, Any]:
+ return {
+ "name": _json_value(_value(row, "field", "column_name")),
+ "type": _json_value(_value(row, "type", "data_type")),
+ "nullable": str(_value(row, "null") or "").upper() == "YES",
+ "key": _json_value(_value(row, "key")),
+ "comment": _bounded_output(_value(row, "comment"), 512),
+ }
+
+
+def _create_statement(rows: Sequence[Mapping[str, Any]]) -> str:
+ if not rows:
+ return ""
+ row = rows[0]
+ value = _value(row, "create table", "create_table", "create view")
+ if value is None:
+ values = list(row.values())
+ value = values[-1] if values else ""
+ return str(value or "")
+
+
+def _partition_columns(create_sql: str) -> list[str]:
+ match = re.search(
+ r"\bPARTITIONED?\s+BY\s*\((?P<columns>[^)]*)\)",
+ create_sql,
+ re.IGNORECASE | re.DOTALL,
+ )
+ if match is None:
+ return []
+ columns: list[str] = []
+ for item in match.group("columns").split(","):
+ raw = item.strip().strip("`").split()[0] if item.strip() else ""
+ try:
+ name = validate_identifier(raw, "partition column")
+ except SQLSecurityError:
+ continue
+ columns.append(name)
+ return columns[:128]
+
+
+def _snapshot_item(row: Mapping[str, Any]) -> dict[str, Any]:
+ return {
+ "snapshot_id": _json_value(
+ _value(row, "snapshot_id", "snapshotid", "id")
+ ),
+ "parent_id": _json_value(
+ _value(row, "parent_id", "parent_snapshot_id")
+ ),
+ "schema_id": _json_value(_value(row, "schema_id")),
+ "committed_at": _json_value(
+ _value(
+ row,
+ "commit_time",
+ "committed_at",
+ "timestamp",
+ "timestamp_ms",
+ )
+ ),
+ "operation": _bounded_output(
+ _value(row, "operation", "commit_kind", "kind"),
+ 64,
+ ),
+ "record_count": _number(
+ _value(row, "record_count", "total_record_count")
+ ),
+ "summary_available": _value(row, "summary") is not None,
+ }
+
+
+def _partition_item(row: Mapping[str, Any]) -> dict[str, Any]:
+ return {
+ "name": _bounded_output(
+ _value(row, "partitionname", "partition_name", "partition"),
+ 512,
+ ),
+ "partition_id": _json_value(
+ _value(row, "partitionid", "partition_id", "spec_id")
+ ),
+ "record_count": _number(
+ _value(row, "record_count", "row_count", "table_rows", "rows")
+ ),
+ "file_count": _number(
+ _value(row, "file_count", "data_file_count")
+ ),
+ "updated_at": _json_value(
+ _value(
+ row,
+ "lastconsistencychecktime",
+ "last_update_time",
+ "update_time",
+ )
+ ),
+ }
+
+
+def _lifecycle_capabilities(
+ version: DorisVersion,
+ table_format: str,
+ columns: Sequence[Mapping[str, Any]],
+ snapshots: Sequence[Mapping[str, Any]],
+) -> dict[str, Any]:
+ supports_4_1 = version.is_parsed and version.is_at_least(_DORIS_4_1)
+ column_names = {
+ str(_value(row, "field", "column_name") or "").casefold()
+ for row in columns
+ }
+ row_lineage_columns = sorted(
+ name
+ for name in (
+ "_row_id",
+ "_last_updated_sequence_number",
+ )
+ if name in column_names
+ )
+ eligible = supports_4_1 and table_format == "iceberg"
+ return {
+ "detected_server_version": version.normalized,
+ "iceberg_v3_lifecycle_eligible": eligible,
+ "deletion_vector": {
+ "server_support": eligible,
+ "target_evidence_observed": any(
+ "deletion" in str(item).casefold() for item in snapshots
+ ),
+ },
+ "row_lineage": {
+ "server_support": eligible,
+ "target_evidence_observed": bool(row_lineage_columns),
+ "observable_hidden_columns": row_lineage_columns,
+ },
+ }
+
+
+def _variant_properties(source: str) -> dict[str, str]:
+ properties: dict[str, str] = {}
+ for match in _VARIANT_PROPERTY.finditer(source):
+ key = match.group("key").casefold()
+ if key in _SAFE_VARIANT_PROPERTIES:
+ properties[key] = match.group("value").strip()
+ return properties
+
+
+def _variant_configuration(
+ properties: Mapping[str, str],
+) -> tuple[dict[str, Any], tuple[str, ...]]:
+ doc_mode = _as_bool(properties.get("variant_enable_doc_mode"))
+ typed_sparse = _as_bool(
+ properties.get("variant_enable_typed_paths_to_sparse")
+ )
+ sparse_shards = _number(
+ properties.get("variant_sparse_hash_shard_count")
+ )
+ doc_shards = _number(properties.get("variant_doc_hash_shard_count"))
+ sparse_mode = bool(typed_sparse) or (
+ isinstance(sparse_shards, int | float) and sparse_shards > 1
+ )
+ warnings: list[str] = []
+ if doc_mode and sparse_mode:
+ warnings.append(
+ "DOC mode and sparse Variant storage both appear configured; "
+ "Doris documents these modes as mutually exclusive."
+ )
+ mode = "doc" if doc_mode else "sparse" if sparse_mode else "default"
+ storage_format = properties.get("storage_format")
+ return (
+ {
+ "mode": mode,
+ "storage_format": (
+ storage_format.upper() if storage_format else None
+ ),
+ "storage_v3": bool(
+ storage_format and storage_format.casefold() == "v3"
+ ),
+ "max_subcolumns_count": _number(
+ properties.get("variant_max_subcolumns_count")
+ ),
+ "typed_paths_to_sparse": typed_sparse,
+ "sparse_hash_shard_count": sparse_shards,
+ "doc_hash_shard_count": doc_shards,
+ "doc_materialization_min_rows": _number(
+ properties.get("variant_doc_materialization_min_rows")
+ ),
+ "flatten_nested": _as_bool(
+ properties.get("variant_flatten_nested")
+ ),
+ },
+ tuple(warnings),
+ )
+
+
+def _variant_advanced_capabilities(
+ version: DorisVersion,
+ configuration: Mapping[str, Any],
+) -> dict[str, Any]:
+ supported = version.is_parsed and version.is_at_least(_DORIS_4_1)
+ return {
+ "detected_server_version": version.normalized,
+ "storage_v3_supported": supported,
+ "sparse_sharding_supported": supported,
+ "sparse_cache_supported": supported,
+ "doc_mode_supported": supported,
+ "configured_mode": configuration.get("mode"),
+ }
+
+
+def _typed_paths(declared_type: str) -> list[dict[str, str]]:
+ items: list[dict[str, str]] = []
+ for match in _TYPED_PATH.finditer(declared_type):
+ items.append(
+ {
+ "path": match.group("path"),
+ "type": match.group("type").upper(),
+ }
+ )
+ if len(items) >= _MAX_TYPED_PATHS:
+ break
+ return items
+
+
+def _variant_type_observations(
+ rows: Sequence[Mapping[str, Any]],
+ *,
+ requested_path: str | None,
+ maximum: int,
+) -> tuple[list[dict[str, Any]], bool]:
+ counts: dict[str, Counter[str]] = defaultdict(Counter)
+ observed_rows: Counter[str] = Counter()
+ for row in rows:
+ raw = _value(row, "variant_type")
+ if raw is None:
+ continue
+ parsed = _parse_variant_type(raw)
+ if isinstance(parsed, Mapping):
+ for path, value_type in parsed.items():
+ raw_path = str(path)
+ if requested_path and not raw_path:
+ path_name = requested_path
+ else:
+ path_name = raw_path or "$"
+ counts[path_name][_variant_type_label(value_type)] += 1
+ observed_rows[path_name] += 1
+ else:
+ path_name = requested_path or "$"
+ counts[path_name][_variant_type_label(parsed)] += 1
+ observed_rows[path_name] += 1
+ ordered = sorted(
+ counts,
+ key=lambda path: (-observed_rows[path], path),
+ )
+ truncated = len(ordered) > maximum
+ items = []
+ denominator = len(rows)
+ for path_name in ordered[:maximum]:
+ present = observed_rows[path_name]
+ items.append(
+ {
+ "path": path_name,
+ "types": [
+ {"type": value_type, "rows": count}
+ for value_type, count in counts[path_name].most_common()
+ ],
+ "rows_observed": present,
+ "presence_ratio": (
+ round(present / denominator, 6) if denominator else None
+ ),
+ }
+ )
+ return items, truncated
+
+
+def _variant_type_label(value: Any) -> str:
+ return str(value).strip().upper()[:128] or "UNKNOWN"
+
+
+def _parse_variant_type(value: Any) -> Mapping[str, Any] | str:
+ if isinstance(value, Mapping):
+ return value
+ text = str(value)
+ try:
+ parsed = json.loads(text)
+ except (TypeError, ValueError):
+ return text[:128]
+ if isinstance(parsed, Mapping):
+ return parsed
+ if isinstance(parsed, str):
+ return parsed[:128]
+ return type(parsed).__name__
+
+
+def _table_name(row: Mapping[str, Any]) -> str | None:
+ value = next(
+ (
+ value
+ for key, value in row.items()
+ if str(key).casefold().startswith("tables_in_")
+ ),
+ _first_value(row),
+ )
+ if value is None:
+ return None
+ try:
+ return validate_identifier(str(value), "table name")
+ except SQLSecurityError:
+ return None
+
+
+def _table_type(row: Mapping[str, Any]) -> str:
+ raw = str(_value(row, "table_type", "type") or "table").casefold()
+ return "view" if "view" in raw else "table"
+
+
+def _first_value(row: Mapping[str, Any]) -> Any:
+ return next(iter(row.values()), None)
+
+
+def _value(row: Mapping[str, Any], *names: str) -> Any:
+ normalized = {
+ str(key).strip().casefold().replace(" ", "_"): value
+ for key, value in row.items()
+ }
+ for name in names:
+ key = name.strip().casefold().replace(" ", "_")
+ if key in normalized:
+ return normalized[key]
+ return None
+
+
+def _json_value(value: Any) -> Any:
+ if isinstance(value, datetime | date):
+ return value.isoformat()
+ if isinstance(value, bytes):
+ return value.decode("utf-8", errors="replace")
+ if value is None or isinstance(value, str | int | float | bool):
+ return value
+ return str(value)
+
+
+def _number(value: Any) -> int | float | None:
+ if value in (None, "") or isinstance(value, bool):
+ return None
+ try:
+ number = float(str(value).replace(",", "").strip())
+ except (TypeError, ValueError):
+ return None
+ return int(number) if number.is_integer() else number
+
+
+def _as_bool(value: Any) -> bool:
+ if isinstance(value, bool):
+ return value
+ return str(value or "").strip().casefold() in {
+ "1",
+ "true",
+ "yes",
+ "on",
+ }
+
+
+def _bounded_output(value: Any, maximum: int) -> str | None:
+ if value is None:
+ return None
+ normalized = str(value).replace("\x00", "").replace("\r", " ").replace(
+ "\n",
+ " ",
+ )
+ return normalized[:maximum]
+
+
+def _argument_failure(message: str) -> LakehouseRuntimeFailure:
+ return LakehouseRuntimeFailure(
+ message,
+ reason_code="LAKEHOUSE_ARGUMENT_INVALID",
+ status_code=400,
+ )
+
+
+def _classify_failure(error: Exception) -> LakehouseRuntimeFailure:
+ if isinstance(error, LakehouseRuntimeFailure):
+ return error
+ if isinstance(error, SQLSecurityError):
+ return _argument_failure("Lakehouse arguments are invalid.")
+ error_code = next(
+ (item for item in getattr(error, "args", ()) if isinstance(item, int)),
+ None,
+ )
+ if error_code in {1044, 1045, 1142, 1227}:
+ return LakehouseRuntimeFailure(
+ "Doris denied access to the requested lakehouse metadata.",
+ reason_code="LAKEHOUSE_PERMISSION_DENIED",
+ status_code=403,
+ )
+ if error_code in {
+ 1049,
+ 1054,
+ 1064,
+ 1109,
+ 1146,
+ 1176,
+ 1305,
+ }:
+ return LakehouseRuntimeFailure(
+ "The requested Doris lakehouse metadata is unavailable.",
+ reason_code="LAKEHOUSE_METADATA_UNAVAILABLE",
+ status_code=404,
+ )
+ if isinstance(error, ConnectionError | TimeoutError):
+ return LakehouseRuntimeFailure(
+ "Doris lakehouse metadata is temporarily unavailable.",
+ reason_code="LAKEHOUSE_CONNECTION_FAILED",
+ status_code=503,
+ retryable=True,
+ )
+ return LakehouseRuntimeFailure(
+ "Doris lakehouse metadata execution failed.",
+ reason_code="LAKEHOUSE_EXECUTION_FAILED",
+ status_code=502,
+ )
+
+
+__all__ = [
+ "DorisLakehouseRuntime",
+ "LakehouseRuntimeFailure",
+]
diff --git a/test/integration/test_real_doris_transports.py
b/test/integration/test_real_doris_transports.py
index 8d12f32..eb20e44 100644
--- a/test/integration/test_real_doris_transports.py
+++ b/test/integration/test_real_doris_transports.py
@@ -107,6 +107,11 @@ GOVERNANCE_CHILD_NAMES = (
"list_udfs",
"get_auth_mapping_status",
)
+LAKEHOUSE_CHILD_NAMES = (
+ "inspect_external_catalog",
+ "inspect_lakehouse_table",
+ "inspect_variant_column",
+)
@dataclass(frozen=True)
@@ -147,6 +152,18 @@ class DorisSearchSandbox:
return f"`{self.settings.database}`.`{self.table}`"
+@dataclass
+class DorisVariantSandbox:
+ settings: RealDorisSettings
+ admin_connection: pymysql.Connection
+ table: str
+ marker: str
+
+ @property
+ def qualified_table(self) -> str:
+ return f"`{self.settings.database}`.`{self.table}`"
+
+
def _real_doris_settings() -> RealDorisSettings:
required = {
name: os.getenv(name, "").strip()
@@ -286,6 +303,70 @@ def doris_search_sandbox() -> DorisSearchSandbox:
admin_connection.close()
[email protected]
+def doris_variant_sandbox() -> DorisVariantSandbox:
+ settings = _real_doris_settings()
+ table = f"mcp_variant_it_{secrets.token_hex(6)}"
+ marker = secrets.token_hex(12)
+ qualified_table = f"`{settings.database}`.`{table}`"
+ admin_connection = pymysql.connect(
+ host=settings.host,
+ port=settings.port,
+ user=settings.user,
+ password=settings.password,
+ database=settings.database,
+ autocommit=True,
+ )
+
+ try:
+ with admin_connection.cursor() as cursor:
+ cursor.execute(
+ f"""
+ CREATE TABLE {qualified_table} (
+ id BIGINT,
+ payload VARIANT
+ )
+ DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES ("replication_num" = "1")
+ """
+ )
+ cursor.executemany(
+ f"INSERT INTO {qualified_table} VALUES (%s, %s)",
+ [
+ (
+ 1,
+ json.dumps(
+ {
+ "profile": {"age": 42},
+ "private_marker": marker,
+ }
+ ),
+ ),
+ (
+ 2,
+ json.dumps(
+ {
+ "profile": {"age": 43},
+ "private_marker": marker,
+ }
+ ),
+ ),
+ ],
+ )
+
+ yield DorisVariantSandbox(
+ settings=settings,
+ admin_connection=admin_connection,
+ table=table,
+ marker=marker,
+ )
+ finally:
+ with suppress(Exception), admin_connection.cursor() as cursor:
+ cursor.execute(f"DROP TABLE IF EXISTS {qualified_table}")
+ admin_connection.close()
+
+
def _server_environment(
settings: RealDorisSettings,
*,
@@ -1713,6 +1794,98 @@ async def
test_real_doris_hierarchical_governance_domain_is_read_only_and_live(
assert row_count_after == row_count_before
[email protected]("transport", ["http", "stdio"])
+async def test_real_doris_hierarchical_lakehouse_domain_is_read_only_and_live(
+ transport: str,
+ doris_variant_sandbox: DorisVariantSandbox,
+) -> None:
+ environment = _server_environment(
+ doris_variant_sandbox.settings,
+ user=doris_variant_sandbox.settings.user,
+ password=doris_variant_sandbox.settings.password,
+ )
+ environment["MCP_TOOL_EXPOSURE_MODE"] = "hierarchical"
+ with doris_variant_sandbox.admin_connection.cursor() as cursor:
+ cursor.execute(
+ f"SELECT COUNT(*) FROM {doris_variant_sandbox.qualified_table}"
+ )
+ row_count_before = int(cursor.fetchone()[0])
+
+ async with _transport_client(
+ transport,
+ environment,
+ read_timeout_seconds=60,
+ ) as client:
+ lakehouse_result = await client.call_tool("doris_lakehouse", {})
+ assert lakehouse_result.is_error is False
+ assert isinstance(lakehouse_result.structured_content, dict)
+ manifest = lakehouse_result.structured_content
+ assert manifest["mode"] == "manifest"
+ assert manifest["domain"] == "doris_lakehouse"
+ children = {child["name"]: child for child in manifest["children"]}
+ assert tuple(children) == LAKEHOUSE_CHILD_NAMES
+ assert all(
+ child["availability"]["callable"]
+ for child in children.values()
+ )
+ manifest_version = manifest["manifest_version"]
+
+ variant = await _call_domain_child(
+ client,
+ domain="doris_lakehouse",
+ child_tool="inspect_variant_column",
+ arguments={
+ "database": doris_variant_sandbox.settings.database,
+ "table": doris_variant_sandbox.table,
+ "column": "payload",
+ "path": "$.profile.age",
+ "sample_rows": 10,
+ },
+ manifest_version=manifest_version,
+ )
+ assert variant["data"]["shape_sample"]["rows_observed"] == 2
+ assert variant["data"]["shape_sample"]["paths"] == [
+ {
+ "path": "$.profile.age",
+ "types": [{"type": "BIGINT", "rows": 2}],
+ "rows_observed": 2,
+ "presence_ratio": 1.0,
+ }
+ ]
+ assert (
+ variant["data"]["advanced_capabilities"][
+ "storage_v3_supported"
+ ]
+ is False
+ )
+ assert variant["metadata"]["sampled_values_returned"] is False
+ assert doris_variant_sandbox.marker not in json.dumps(
+ variant,
+ ensure_ascii=False,
+ )
+
+ internal_catalog = await client.call_tool(
+ "doris_lakehouse",
+ {
+ "child_tool": "inspect_external_catalog",
+ "arguments": {"catalog": "internal"},
+ "manifest_version": manifest_version,
+ },
+ )
+ assert internal_catalog.is_error is True
+ assert doris_variant_sandbox.marker not in json.dumps(
+ internal_catalog.model_dump(by_alias=True, mode="json"),
+ ensure_ascii=False,
+ )
+
+ with doris_variant_sandbox.admin_connection.cursor() as cursor:
+ cursor.execute(
+ f"SELECT COUNT(*) FROM {doris_variant_sandbox.qualified_table}"
+ )
+ row_count_after = int(cursor.fetchone()[0])
+ assert row_count_after == row_count_before
+
+
@pytest.mark.skipif(
os.getenv("DORIS_REAL_HTTP_INTEGRATION") != "1",
reason="set DORIS_REAL_HTTP_INTEGRATION=1 with independent FE/BE HTTP
endpoints",
diff --git a/test/protocol/test_multiworker_config.py
b/test/protocol/test_multiworker_config.py
index 929d965..4641378 100644
--- a/test/protocol/test_multiworker_config.py
+++ b/test/protocol/test_multiworker_config.py
@@ -216,6 +216,71 @@ def
test_governance_runtime_controls_load_serialize_and_validate(
)
+def test_lakehouse_runtime_controls_load_serialize_and_validate(
+ monkeypatch,
+ tmp_path,
+) -> None:
+ monkeypatch.setenv("LAKEHOUSE_MAX_CATALOG_OBJECTS", "80")
+ monkeypatch.setenv("LAKEHOUSE_MAX_CATALOG_DATABASES", "30")
+ monkeypatch.setenv("LAKEHOUSE_MAX_SNAPSHOTS", "90")
+ monkeypatch.setenv("LAKEHOUSE_MAX_PARTITIONS", "150")
+ monkeypatch.setenv("LAKEHOUSE_MAX_VARIANT_SAMPLE_ROWS", "40")
+ monkeypatch.setenv("LAKEHOUSE_MAX_VARIANT_PATHS", "300")
+
+ configured = DorisConfig.from_env()
+
+ assert configured.to_dict()["lakehouse"] == {
+ "max_catalog_objects": 80,
+ "max_catalog_databases": 30,
+ "max_snapshots": 90,
+ "max_partitions": 150,
+ "max_variant_sample_rows": 40,
+ "max_variant_paths": 300,
+ }
+ assert configured.validate() == []
+
+ config_path = tmp_path / "doris-mcp.json"
+ config_path.write_text(
+ json.dumps(
+ {
+ "lakehouse": {
+ "max_catalog_objects": 75,
+ "max_catalog_databases": 25,
+ "max_snapshots": 85,
+ "max_partitions": 140,
+ "max_variant_sample_rows": 35,
+ "max_variant_paths": 250,
+ }
+ }
+ ),
+ encoding="utf-8",
+ )
+ from_file = DorisConfig.from_file(str(config_path))
+ assert from_file.lakehouse.max_catalog_objects == 75
+ assert from_file.lakehouse.max_catalog_databases == 25
+ assert from_file.lakehouse.max_snapshots == 85
+ assert from_file.lakehouse.max_partitions == 140
+ assert from_file.lakehouse.max_variant_sample_rows == 35
+ assert from_file.lakehouse.max_variant_paths == 250
+
+ from_file.lakehouse.max_catalog_objects = 0
+ from_file.lakehouse.max_catalog_databases = 101
+ from_file.lakehouse.max_snapshots = 501
+ from_file.lakehouse.max_partitions = 1001
+ from_file.lakehouse.max_variant_sample_rows = 501
+ from_file.lakehouse.max_variant_paths = 2001
+ errors = from_file.validate()
+ assert "Lakehouse catalog object limit must be in the range 1-500" in
errors
+ assert (
+ "Lakehouse catalog database limit must be in the range 1-100"
+ in errors
+ )
+ assert "Lakehouse snapshot limit must be in the range 1-500" in errors
+ assert "Lakehouse partition limit must be in the range 1-1000" in errors
+ assert "Lakehouse Variant sample limit must be in the range 1-500" in
errors
+ assert "Lakehouse Variant path limit must be in the range 1-2000" in errors
+
+
def
test_state_handle_secret_and_ttl_are_configurable_without_serializing_secret(
monkeypatch,
):
@@ -264,6 +329,17 @@ def
test_multiworker_environment_preserves_resolved_parent_config(monkeypatch):
config.capability.snapshot_ttl_seconds = 120
config.capability.probe_timeout_seconds = 7
config.capability.stale_grace_seconds = 480
+ config.governance.max_sample_ratio = 0.15
+ config.governance.max_audit_window_days = 45
+ config.governance.max_lineage_edges = 750
+ config.governance.lineage_store_table = "governance.lineage_events"
+ config.governance.lineage_recent_event_minutes = 180
+ config.lakehouse.max_catalog_objects = 80
+ config.lakehouse.max_catalog_databases = 30
+ config.lakehouse.max_snapshots = 90
+ config.lakehouse.max_partitions = 150
+ config.lakehouse.max_variant_sample_rows = 40
+ config.lakehouse.max_variant_paths = 300
config.mcp_state_handle_secret = "parent-shared-state-handle-secret-value"
config.mcp_state_handle_ttl_seconds = 45
@@ -305,6 +381,20 @@ def
test_multiworker_environment_preserves_resolved_parent_config(monkeypatch):
assert child_config.capability.snapshot_ttl_seconds == 120
assert child_config.capability.probe_timeout_seconds == 7
assert child_config.capability.stale_grace_seconds == 480
+ assert child_config.governance.max_sample_ratio == 0.15
+ assert child_config.governance.max_audit_window_days == 45
+ assert child_config.governance.max_lineage_edges == 750
+ assert (
+ child_config.governance.lineage_store_table
+ == "governance.lineage_events"
+ )
+ assert child_config.governance.lineage_recent_event_minutes == 180
+ assert child_config.lakehouse.max_catalog_objects == 80
+ assert child_config.lakehouse.max_catalog_databases == 30
+ assert child_config.lakehouse.max_snapshots == 90
+ assert child_config.lakehouse.max_partitions == 150
+ assert child_config.lakehouse.max_variant_sample_rows == 40
+ assert child_config.lakehouse.max_variant_paths == 300
assert (
child_config.mcp_state_handle_secret
== "parent-shared-state-handle-secret-value"
diff --git a/test/tools/test_capability_detector.py
b/test/tools/test_capability_detector.py
index dc4ea0b..f83ff71 100644
--- a/test/tools/test_capability_detector.py
+++ b/test/tools/test_capability_detector.py
@@ -214,6 +214,64 @@ async def
test_detector_builds_version_vector_and_extends_domains_lazily() -> No
assert connection.statements.count("SELECT @@version_comment;") == 1
[email protected]
+async def test_lakehouse_probes_derive_target_sensitive_advanced_facets() ->
None:
+ connection = _ProbeConnection()
+ manager = _ProbeConnectionManager(connection)
+ detector = DorisCapabilityDetector(manager) # type: ignore[arg-type]
+ base = await detector.detect_base(
+ None,
+ capability_generation=1,
+ provider_generation="provider.lakehouse",
+ )
+
+ lakehouse = await detector.detect_domain(base, "doris_lakehouse", None)
+
+ assert lakehouse.probed_domains == frozenset({"doris_lakehouse"})
+ for probe_id in (
+ "external_catalog_metadata_readable",
+ "lakehouse_table_metadata_readable",
+ "variant_column_type_readable",
+ ):
+ assert (
+ lakehouse.probe(probe_id).status
+ is CapabilityProbeStatus.SUPPORTED
+ )
+ for probe_id in (
+ "lakehouse_snapshot_features_readable",
+ "iceberg_deletion_vector",
+ "iceberg_row_lineage",
+ ):
+ evidence = lakehouse.probe(probe_id)
+ assert evidence.status is CapabilityProbeStatus.DEGRADED
+ assert (
+ evidence.reason_code
+ == "TARGET_LAKEHOUSE_FORMAT_REQUIRES_CALL_TIME_VALIDATION"
+ )
+ for probe_id in (
+ "variant_advanced_properties_readable",
+ "variant_sparse_sharding",
+ "variant_sparse_cache",
+ "variant_doc_mode",
+ "storage_v3",
+ ):
+ evidence = lakehouse.probe(probe_id)
+ assert evidence.status is CapabilityProbeStatus.DEGRADED
+ assert (
+ evidence.reason_code
+ == "TARGET_VARIANT_PROPERTIES_REQUIRE_CALL_TIME_VALIDATION"
+ )
+ assert "SHOW CATALOGS" in connection.statements
+ assert (
+ "SELECT TABLE_CATALOG, TABLE_SCHEMA, TABLE_NAME "
+ "FROM information_schema.tables LIMIT 1"
+ ) in connection.statements
+ assert (
+ "SELECT COLUMN_NAME, DATA_TYPE "
+ "FROM information_schema.columns LIMIT 1"
+ ) in connection.statements
+
+
@pytest.mark.asyncio
async def test_search_probes_use_visible_target_and_isolated_connections() ->
None:
connection = _ProbeConnection()
diff --git a/test/tools/test_capability_registry.py
b/test/tools/test_capability_registry.py
index cc8d098..31dbf55 100644
--- a/test/tools/test_capability_registry.py
+++ b/test/tools/test_capability_registry.py
@@ -263,6 +263,113 @@ def
test_compaction_prefers_native_tracker_and_uses_legacy_on_405() -> None:
)
+def test_lakehouse_prefers_4_1_variants_and_falls_back_on_4_0() -> None:
+ bound = _BoundHandlers(
+ "doris_lakehouse.inspect_lakehouse_table",
+ "doris_lakehouse.inspect_variant_column",
+ )
+ evaluator = CapabilityEvaluator(
+ matrix=DORIS_FEATURE_MATRIX,
+ bound_handlers=bound, # type: ignore[arg-type]
+ )
+ domain = DORIS_DOMAIN_CATALOG.resolve_domain("doris_lakehouse")
+ table_child = DORIS_DOMAIN_CATALOG.resolve_child(
+ "doris_lakehouse",
+ "inspect_lakehouse_table",
+ )
+ variant_child = DORIS_DOMAIN_CATALOG.resolve_child(
+ "doris_lakehouse",
+ "inspect_variant_column",
+ )
+ probes = {
+ "lakehouse_table_metadata_readable": CapabilityProbeEvidence(
+ probe_id="lakehouse_table_metadata_readable",
+ status=CapabilityProbeStatus.SUPPORTED,
+ reason_code="TEST_BASELINE_METADATA",
+ ),
+ "variant_column_type_readable": CapabilityProbeEvidence(
+ probe_id="variant_column_type_readable",
+ status=CapabilityProbeStatus.SUPPORTED,
+ reason_code="TEST_BASELINE_VARIANT",
+ ),
+ **{
+ probe_id: CapabilityProbeEvidence(
+ probe_id=probe_id,
+ status=CapabilityProbeStatus.DEGRADED,
+ reason_code="TEST_CALL_TIME_TARGET_VALIDATION",
+ )
+ for probe_id in (
+ "lakehouse_snapshot_features_readable",
+ "iceberg_deletion_vector",
+ "iceberg_row_lineage",
+ "variant_advanced_properties_readable",
+ "variant_sparse_sharding",
+ "variant_sparse_cache",
+ "variant_doc_mode",
+ "storage_v3",
+ )
+ },
+ }
+ providers = CapabilityProviderRegistry(
+ {
+ "external_catalog_provider": CapabilityProviderEvidence(
+ provider_id="external_catalog_provider",
+ status=CapabilityProbeStatus.SUPPORTED,
+ reason_code="PROVIDER_CONFIGURED",
+ )
+ }
+ ).snapshot()
+ snapshot_405 = _snapshot(probes=probes)
+ snapshot_410 = replace(
+ snapshot_405,
+ version_vector=DorisClusterVersionVector.from_comments(
+ master_fe="Doris version doris-4.1.0",
+ follower_fes=("Doris version doris-4.1.0",),
+ backends=("Doris version doris-4.1.0",),
+ ),
+ )
+
+ table_405 = evaluator.evaluate(
+ snapshot=snapshot_405,
+ providers=providers,
+ domain=domain,
+ child=table_child,
+ auth_context=None,
+ )
+ table_410 = evaluator.evaluate(
+ snapshot=snapshot_410,
+ providers=providers,
+ domain=domain,
+ child=table_child,
+ auth_context=None,
+ )
+ variant_405 = evaluator.evaluate(
+ snapshot=snapshot_405,
+ providers=providers,
+ domain=domain,
+ child=variant_child,
+ auth_context=None,
+ )
+ variant_410 = evaluator.evaluate(
+ snapshot=snapshot_410,
+ providers=providers,
+ domain=domain,
+ child=variant_child,
+ auth_context=None,
+ )
+
+ assert table_405.active_variant == "lakehouse_table_metadata"
+ assert table_405.status is AvailabilityStatus.AVAILABLE
+ assert table_410.active_variant == "lakehouse_lifecycle_4_1"
+ assert table_410.status is AvailabilityStatus.DEGRADED
+ assert table_410.callable is True
+ assert variant_405.active_variant == "variant_type"
+ assert variant_405.status is AvailabilityStatus.AVAILABLE
+ assert variant_410.active_variant == "variant_advanced_4_1"
+ assert variant_410.status is AvailabilityStatus.DEGRADED
+ assert variant_410.callable is True
+
+
def test_evaluator_normalizes_system_object_probe_evidence_for_manifest() ->
None:
evaluator = CapabilityEvaluator(
matrix=DORIS_FEATURE_MATRIX,
@@ -418,6 +525,32 @@ def
test_builtin_query_evidence_providers_are_ready_when_bound() -> None:
assert providers["query_evidence_provider"].reason_code ==
("PROVIDER_CONFIGURED")
+def test_external_catalog_provider_is_ready_only_when_lakehouse_is_bound() ->
None:
+ bound_registry = CapabilityProviderRegistry.from_runtime(
+ matrix=DORIS_FEATURE_MATRIX,
+ bound_handlers=_BoundHandlers( # type: ignore[arg-type]
+ "doris_lakehouse.inspect_external_catalog",
+ "doris_lakehouse.inspect_lakehouse_table",
+ ),
+ config=SimpleNamespace(adbc=SimpleNamespace(enabled=False)),
+ )
+ unbound_registry = CapabilityProviderRegistry.from_runtime(
+ matrix=DORIS_FEATURE_MATRIX,
+ bound_handlers=_BoundHandlers(), # type: ignore[arg-type]
+ config=SimpleNamespace(adbc=SimpleNamespace(enabled=False)),
+ )
+
+ bound = bound_registry.snapshot().providers["external_catalog_provider"]
+ unbound = unbound_registry.snapshot().providers[
+ "external_catalog_provider"
+ ]
+
+ assert bound.status is CapabilityProbeStatus.SUPPORTED
+ assert bound.reason_code == "PROVIDER_CONFIGURED"
+ assert unbound.status is CapabilityProbeStatus.MISCONFIGURED
+ assert unbound.reason_code == "PROVIDER_NOT_CONFIGURED"
+
+
class _MutableClock:
def __init__(self) -> None:
self.now = datetime(2026, 7, 31, tzinfo=UTC)
diff --git a/test/tools/test_domain_dispatcher.py
b/test/tools/test_domain_dispatcher.py
index aeafae1..c615f96 100644
--- a/test/tools/test_domain_dispatcher.py
+++ b/test/tools/test_domain_dispatcher.py
@@ -53,6 +53,7 @@ from doris_mcp_server.tools.domain_models import (
from doris_mcp_server.tools.tools_manager import DorisToolsManager
from doris_mcp_server.utils.config import DorisConfig
from doris_mcp_server.utils.governance_runtime import GovernanceRuntimeFailure
+from doris_mcp_server.utils.lakehouse_runtime import LakehouseRuntimeFailure
from doris_mcp_server.utils.query_runtime import QueryRuntimeFailure
from doris_mcp_server.utils.security import AuthContext
@@ -163,6 +164,18 @@ def test_governance_domain_binds_all_eight_children() ->
None:
)
+def test_lakehouse_domain_binds_all_three_children() -> None:
+ manager = _manager()
+ bound = BoundHandlerAvailabilityProvider(manager)
+ lakehouse = DORIS_DOMAIN_CATALOG.resolve_domain("doris_lakehouse")
+
+ assert len(lakehouse.children) == 3
+ assert all(
+ bound.is_bound(lakehouse.name, child.name)
+ for child in lakehouse.children
+ )
+
+
@pytest.mark.asyncio
async def test_governance_failures_keep_stable_reason_codes() -> None:
manager = _manager("doris_governance.analyze_columns")
@@ -192,6 +205,36 @@ async def
test_governance_failures_keep_stable_reason_codes() -> None:
assert result.error.details["status_code"] == 404
[email protected]
+async def test_lakehouse_failures_keep_stable_reason_codes() -> None:
+ manager = _manager("doris_lakehouse.inspect_lakehouse_table")
+ manager.lakehouse_runtime.inspect_lakehouse_table = AsyncMock(
+ side_effect=LakehouseRuntimeFailure(
+ "The requested external table is unavailable.",
+ reason_code="LAKEHOUSE_TABLE_NOT_FOUND",
+ status_code=404,
+ )
+ )
+
+ result = await manager.domain_dispatcher.call_domain(
+ "doris_lakehouse",
+ {
+ "child_tool": "inspect_lakehouse_table",
+ "arguments": {
+ "catalog": "ice_prod",
+ "database": "analytics",
+ "table": "missing",
+ },
+ },
+ None,
+ )
+
+ assert result.mode == "error"
+ assert result.error.code is DomainErrorCode.CHILD_ARGUMENTS_INVALID
+ assert result.error.details["reason_code"] == "LAKEHOUSE_TABLE_NOT_FOUND"
+ assert result.error.details["status_code"] == 404
+
+
@pytest.mark.asyncio
async def test_cluster_metric_filters_reach_strict_runtime_unchanged() -> None:
manager = _manager("doris_cluster.get_monitoring_metrics")
diff --git a/test/tools/test_doris_feature_matrix.py
b/test/tools/test_doris_feature_matrix.py
index edf4ae6..49e48d2 100644
--- a/test/tools/test_doris_feature_matrix.py
+++ b/test/tools/test_doris_feature_matrix.py
@@ -138,6 +138,51 @@ def
test_search_prefers_vector_hybrid_and_keeps_text_fallback() -> None:
assert text.callable_when_degraded is True
+def test_lakehouse_prefers_4_1_capabilities_and_keeps_baseline_fallbacks() ->
None:
+ table_feature = DORIS_FEATURE_MATRIX.get_feature(
+ "doris_lakehouse",
+ "inspect_lakehouse_table",
+ )
+ variant_feature = DORIS_FEATURE_MATRIX.get_feature(
+ "doris_lakehouse",
+ "inspect_variant_column",
+ )
+
+ assert tuple(
+ variant.name for variant in table_feature.support_contract.variants
+ ) == (
+ "lakehouse_lifecycle_4_1",
+ "lakehouse_table_metadata",
+ )
+ table_advanced, table_baseline = table_feature.support_contract.variants
+ assert table_advanced.supported_ranges == (">=4.1.0",)
+ assert table_advanced.required_features == (
+ "iceberg_deletion_vector",
+ "iceberg_row_lineage",
+ )
+ assert table_advanced.callable_when_degraded is True
+ assert table_baseline.supported_ranges == (">=3.0.0",)
+
+ assert tuple(
+ variant.name for variant in variant_feature.support_contract.variants
+ ) == (
+ "variant_advanced_4_1",
+ "variant_type",
+ )
+ variant_advanced, variant_baseline = (
+ variant_feature.support_contract.variants
+ )
+ assert variant_advanced.supported_ranges == (">=4.1.0",)
+ assert variant_advanced.required_features == (
+ "variant_sparse_sharding",
+ "variant_sparse_cache",
+ "variant_doc_mode",
+ "storage_v3",
+ )
+ assert variant_advanced.callable_when_degraded is True
+ assert variant_baseline.supported_ranges == (">=3.0.0",)
+
+
def test_every_contract_is_fail_closed_and_has_resolvable_sources() -> None:
known_sources = {source.source_id for source in
DORIS_FEATURE_MATRIX.sources}
diff --git a/test/tools/test_lakehouse_handlers.py
b/test/tools/test_lakehouse_handlers.py
new file mode 100644
index 0000000..53f06f3
--- /dev/null
+++ b/test/tools/test_lakehouse_handlers.py
@@ -0,0 +1,163 @@
+# 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.
+
+"""Exact argument-routing contracts for the formal Lakehouse handlers."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from typing import Any
+from unittest.mock import AsyncMock
+
+import pytest
+
+from doris_mcp_server.tools.lakehouse_handlers import (
+ LakehouseToolHandlersMixin,
+)
+
+
+class _Harness(LakehouseToolHandlersMixin):
+ def __init__(self) -> None:
+ self.lakehouse_runtime = SimpleNamespace(
+ inspect_external_catalog=AsyncMock(
+ return_value={"status": "success"}
+ ),
+ inspect_lakehouse_table=AsyncMock(
+ return_value={"status": "success"}
+ ),
+ inspect_variant_column=AsyncMock(
+ return_value={"status": "success"}
+ ),
+ )
+
+
[email protected]
[email protected](
+ ("handler_name", "runtime_name", "arguments", "expected"),
+ [
+ (
+ "_formal_doris_lakehouse_inspect_external_catalog_tool",
+ "inspect_external_catalog",
+ {
+ "catalog": "ice_prod",
+ "include_objects": True,
+ "object_limit": 12,
+ },
+ {
+ "catalog": "ice_prod",
+ "include_objects": True,
+ "object_limit": 12,
+ },
+ ),
+ (
+ "_formal_doris_lakehouse_inspect_lakehouse_table_tool",
+ "inspect_lakehouse_table",
+ {
+ "catalog": "ice_prod",
+ "database": "analytics",
+ "table": "events",
+ "include_snapshots": True,
+ "include_partitions": True,
+ },
+ {
+ "catalog": "ice_prod",
+ "database": "analytics",
+ "table": "events",
+ "include_snapshots": True,
+ "include_partitions": True,
+ },
+ ),
+ (
+ "_formal_doris_lakehouse_inspect_variant_column_tool",
+ "inspect_variant_column",
+ {
+ "catalog": "internal",
+ "database": "analytics",
+ "table": "profiles",
+ "column": "payload",
+ "path": "$.profile.age",
+ "sample_rows": 10,
+ },
+ {
+ "catalog": "internal",
+ "database": "analytics",
+ "table": "profiles",
+ "column": "payload",
+ "path": "$.profile.age",
+ "sample_rows": 10,
+ },
+ ),
+ ],
+)
+async def test_lakehouse_handlers_forward_exact_arguments(
+ handler_name: str,
+ runtime_name: str,
+ arguments: dict[str, Any],
+ expected: dict[str, Any],
+) -> None:
+ harness = _Harness()
+
+ result = await getattr(harness, handler_name)(arguments)
+
+ assert result == {"status": "success"}
+ getattr(harness.lakehouse_runtime, runtime_name).assert_awaited_once_with(
+ **expected
+ )
+
+
[email protected]
+async def test_lakehouse_handlers_apply_documented_defaults() -> None:
+ harness = _Harness()
+
+ await harness._formal_doris_lakehouse_inspect_external_catalog_tool(
+ {"catalog": "ice_prod"}
+ )
+ await harness._formal_doris_lakehouse_inspect_lakehouse_table_tool(
+ {
+ "catalog": "ice_prod",
+ "database": "analytics",
+ "table": "events",
+ }
+ )
+ await harness._formal_doris_lakehouse_inspect_variant_column_tool(
+ {
+ "database": "analytics",
+ "table": "profiles",
+ "column": "payload",
+ }
+ )
+
+
harness.lakehouse_runtime.inspect_external_catalog.assert_awaited_once_with(
+ catalog="ice_prod",
+ include_objects=False,
+ object_limit=None,
+ )
+ harness.lakehouse_runtime.inspect_lakehouse_table.assert_awaited_once_with(
+ catalog="ice_prod",
+ database="analytics",
+ table="events",
+ include_snapshots=False,
+ include_partitions=False,
+ )
+ harness.lakehouse_runtime.inspect_variant_column.assert_awaited_once_with(
+ catalog=None,
+ database="analytics",
+ table="profiles",
+ column="payload",
+ path=None,
+ sample_rows=None,
+ )
diff --git a/test/utils/test_lakehouse_runtime.py
b/test/utils/test_lakehouse_runtime.py
new file mode 100644
index 0000000..1656c31
--- /dev/null
+++ b/test/utils/test_lakehouse_runtime.py
@@ -0,0 +1,519 @@
+# 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.
+
+"""Production evidence contracts for the formal Lakehouse runtime."""
+
+from __future__ import annotations
+
+from collections.abc import AsyncIterator, Callable, Mapping
+from contextlib import asynccontextmanager
+from types import SimpleNamespace
+from typing import Any
+
+import pytest
+
+from doris_mcp_server.utils.lakehouse_runtime import (
+ DorisLakehouseRuntime,
+ LakehouseRuntimeFailure,
+)
+
+_Params = Mapping[str, Any] | tuple[Any, ...] | None
+_Responder = Callable[[str, _Params], list[dict[str, Any]]]
+
+
+class _ConnectionManager:
+ def __init__(
+ self,
+ responder: _Responder,
+ *,
+ lakehouse: Any | None = None,
+ ) -> None:
+ self._responder = responder
+ self.calls: list[str] = []
+ self.params: list[_Params] = []
+ self.max_rows: list[int] = []
+ self.context_count = 0
+ self.config = SimpleNamespace(
+ lakehouse=lakehouse
+ or SimpleNamespace(
+ max_catalog_objects=50,
+ max_catalog_databases=20,
+ max_snapshots=50,
+ max_partitions=100,
+ max_variant_sample_rows=20,
+ max_variant_paths=200,
+ )
+ )
+
+ @asynccontextmanager
+ async def get_connection_context_for_auth_context(
+ self,
+ _session_id: str,
+ _auth_context: Any,
+ ) -> AsyncIterator[Any]:
+ self.context_count += 1
+ manager = self
+
+ class _Connection:
+ async def execute(
+ self,
+ sql: str,
+ params: _Params = None,
+ **kwargs: Any,
+ ) -> SimpleNamespace:
+ manager.calls.append(sql)
+ manager.params.append(params)
+ manager.max_rows.append(int(kwargs["max_rows"]))
+ return SimpleNamespace(data=manager._responder(sql, params))
+
+ yield _Connection()
+
+
+def _runtime(
+ responder: _Responder,
+ *,
+ lakehouse: Any | None = None,
+) -> tuple[DorisLakehouseRuntime, _ConnectionManager]:
+ manager = _ConnectionManager(responder, lakehouse=lakehouse)
+ runtime = DorisLakehouseRuntime(manager) # type: ignore[arg-type]
+ return runtime, manager
+
+
+def _catalog_rows() -> list[dict[str, Any]]:
+ return [
+ {
+ "CatalogName": "ice_prod",
+ "Type": "iceberg",
+ "IsCurrent": "No",
+ "CreateTime": "2026-07-01 00:00:00",
+ "LastUpdateTime": "2026-07-30 10:00:00",
+ "Comment": "Warehouse endpoint https://comment.private.invalid",
+ },
+ {
+ "CatalogName": "internal",
+ "Type": "internal",
+ "IsCurrent": "Yes",
+ },
+ ]
+
+
[email protected]
+async def test_external_catalog_returns_only_sanitized_bounded_metadata() ->
None:
+ def responder(sql: str, _params: _Params) -> list[dict[str, Any]]:
+ if sql == "SHOW CATALOGS":
+ return _catalog_rows()
+ if sql == "SHOW CATALOG `ice_prod`":
+ return [
+ {"Key": "type", "Value": "iceberg"},
+ {"Key": "password", "Value": "do-not-return"},
+ {
+ "Key": "s3.endpoint",
+ "Value": "https://private.example.invalid",
+ },
+ {"Key": "use_meta_cache", "Value": "true"},
+ {"Key": "metadata_refresh_interval_sec", "Value": "60"},
+ {"Key": "warehouse", "Value": "s3://private/warehouse"},
+ ]
+ if sql == "SHOW DATABASES FROM `ice_prod`":
+ return [{"Database": "analytics"}, {"Database": "archive"}]
+ if sql == "SHOW FULL TABLES FROM `ice_prod`.`analytics`":
+ return [
+ {
+ "Tables_in_analytics": "events",
+ "Table_type": "BASE TABLE",
+ },
+ {
+ "Tables_in_analytics": "events_view",
+ "Table_type": "VIEW",
+ },
+ ]
+ raise AssertionError(f"Unexpected SQL: {sql}")
+
+ runtime, manager = _runtime(responder)
+ result = await runtime.inspect_external_catalog(
+ catalog="ice_prod",
+ include_objects=True,
+ object_limit=1,
+ )
+
+ assert result["status"] == "success"
+ assert result["data"]["reported_type"] == "iceberg"
+ assert result["data"]["configuration"] == {
+ "property_count": 6,
+ "property_keys": [
+ "metadata_refresh_interval_sec",
+ "type",
+ "use_meta_cache",
+ "warehouse",
+ ],
+ "property_keys_truncated": False,
+ "sensitive_property_count": 2,
+ "property_values_returned": False,
+ "metadata_cache_configured": True,
+ "refresh_interval_configured": True,
+ "warehouse_configured": True,
+ }
+ assert result["data"]["object_sample"]["relations"] == [
+ {"database": "analytics", "name": "events", "type": "table"}
+ ]
+ assert result["data"]["object_sample"]["truncated"] is True
+ serialized = str(result)
+ assert "do-not-return" not in serialized
+ assert "private.example.invalid" not in serialized
+ assert "comment.private.invalid" not in serialized
+ assert "s3://private/warehouse" not in serialized
+ assert manager.max_rows[-1] == 2
+
+
[email protected]
+async def test_external_catalog_rejects_internal_and_injected_names() -> None:
+ runtime, manager = _runtime(
+ lambda sql, _params: _catalog_rows() if sql == "SHOW CATALOGS" else []
+ )
+
+ with pytest.raises(LakehouseRuntimeFailure) as internal_failure:
+ await runtime.inspect_external_catalog(catalog="internal")
+ assert internal_failure.value.reason_code ==
"LAKEHOUSE_CATALOG_NOT_EXTERNAL"
+
+ call_count = len(manager.calls)
+ with pytest.raises(LakehouseRuntimeFailure) as injection_failure:
+ await runtime.inspect_external_catalog(catalog="ice; DROP DATABASE
prod")
+ assert injection_failure.value.reason_code == "LAKEHOUSE_ARGUMENT_INVALID"
+ assert len(manager.calls) == call_count
+
+
[email protected]
+async def test_external_catalog_degrades_when_properties_are_not_visible() ->
None:
+ def responder(sql: str, _params: _Params) -> list[dict[str, Any]]:
+ if sql == "SHOW CATALOGS":
+ return _catalog_rows()
+ if sql == "SHOW CATALOG `ice_prod`":
+ raise RuntimeError(1142, "catalog properties denied")
+ raise AssertionError(f"Unexpected SQL: {sql}")
+
+ runtime, _ = _runtime(responder)
+ result = await runtime.inspect_external_catalog(catalog="ice_prod")
+
+ assert result["status"] == "partial"
+ assert result["data"]["configuration"]["property_count"] == 0
+ assert result["evidence"][1]["reason_code"] ==
"LAKEHOUSE_PERMISSION_DENIED"
+ assert "catalog properties denied" not in str(result)
+
+
[email protected]
+async def test_iceberg_table_returns_recorded_facets_without_raw_artifacts()
-> None:
+ def responder(sql: str, _params: _Params) -> list[dict[str, Any]]:
+ if sql == "SHOW CATALOGS":
+ return _catalog_rows()
+ if sql == "SHOW CATALOG `ice_prod`":
+ return [{"Key": "type", "Value": "iceberg"}]
+ if sql == "SHOW FULL COLUMNS FROM `ice_prod`.`analytics`.`events`":
+ return [
+ {
+ "Field": "event_id",
+ "Type": "BIGINT",
+ "Null": "NO",
+ "Key": "",
+ "Comment": "Stable identifier",
+ },
+ {
+ "Field": "_row_id",
+ "Type": "BIGINT",
+ "Null": "YES",
+ "Key": "",
+ "Comment": "",
+ },
+ ]
+ if sql == "SHOW CREATE TABLE `ice_prod`.`analytics`.`events`":
+ return [
+ {
+ "Create Table": (
+ "CREATE TABLE events (event_id BIGINT) "
+ "PARTITIONED BY (`event_date`) "
+ "PROPERTIES ('warehouse'='s3://private/location')"
+ )
+ }
+ ]
+ if sql == "SHOW TABLE STATS `ice_prod`.`analytics`.`events`":
+ return [
+ {
+ "row_count": "1000",
+ "data_size": "4096",
+ "update_time": "2026-07-30 12:00:00",
+ }
+ ]
+ if sql.startswith(
+ "SELECT * FROM `ice_prod`.`analytics`.`events$snapshots`"
+ ):
+ return [
+ {
+ "snapshot_id": 42,
+ "parent_id": 41,
+ "schema_id": 7,
+ "committed_at": "2026-07-30 12:00:00",
+ "operation": "append",
+ "record_count": 1000,
+ "summary": '{"private-location":"s3://secret"}',
+ }
+ ]
+ if sql.startswith(
+ "SHOW PARTITIONS FROM `ice_prod`.`analytics`.`events`"
+ ):
+ return [
+ {
+ "PartitionName": "event_date=2026-07-30",
+ "PartitionId": 9,
+ "Rows": 1000,
+ "UpdateTime": "2026-07-30 12:00:00",
+ }
+ ]
+ if sql.startswith(
+ "EXPLAIN SELECT * FROM `ice_prod`.`analytics`.`events`"
+ ):
+ return [
+ {
+ "Explain String": (
+ "ICEBERG_SCAN_NODE\nPARTITIONS=1/12\nPREDICATES:
event_id"
+ )
+ }
+ ]
+ if sql == "SELECT @@version_comment;":
+ return [
+ {
+ "@@version_comment": (
+ "Doris version doris-4.1.0-43f06a5e26 "
+ "(Cloud Mode)"
+ )
+ }
+ ]
+ raise AssertionError(f"Unexpected SQL: {sql}")
+
+ runtime, _ = _runtime(responder)
+ result = await runtime.inspect_lakehouse_table(
+ catalog="ice_prod",
+ database="analytics",
+ table="events",
+ include_snapshots=True,
+ include_partitions=True,
+ )
+
+ assert result["status"] == "success"
+ assert result["data"]["format"] == "iceberg"
+ assert result["data"]["partition_columns"] == ["event_date"]
+ assert result["data"]["statistics"]["row_count"] == 1000
+ assert result["data"]["snapshots"]["items"][0] == {
+ "snapshot_id": 42,
+ "parent_id": 41,
+ "schema_id": 7,
+ "committed_at": "2026-07-30 12:00:00",
+ "operation": "append",
+ "record_count": 1000,
+ "summary_available": True,
+ }
+ assert result["data"]["partitions"]["items"][0]["record_count"] == 1000
+ assert result["data"]["pushdown"] == {
+ "external_scan_observed": True,
+ "partition_pruning_observed": True,
+ "predicate_pushdown_observed": True,
+ "scan_nodes": ["ICEBERG_SCAN_NODE"],
+ }
+ assert result["data"]["lifecycle"]["iceberg_v3_lifecycle_eligible"] is True
+ assert result["data"]["lifecycle"]["row_lineage"][
+ "observable_hidden_columns"
+ ] == ["_row_id"]
+ serialized = str(result)
+ assert "CREATE TABLE events (" not in serialized
+ assert "s3://private/location" not in serialized
+ assert "ICEBERG_SCAN_NODE\nPARTITIONS" not in serialized
+ assert "s3://secret" not in serialized
+
+
[email protected]
+async def test_lakehouse_table_rejects_non_lakehouse_catalog() -> None:
+ def responder(sql: str, _params: _Params) -> list[dict[str, Any]]:
+ if sql == "SHOW CATALOGS":
+ return [{"CatalogName": "hms_prod", "Type": "hms"}]
+ if sql == "SHOW CATALOG `hms_prod`":
+ return [{"Key": "type", "Value": "hms"}]
+ if sql == "SHOW FULL COLUMNS FROM `hms_prod`.`analytics`.`events`":
+ return [{"Field": "id", "Type": "BIGINT", "Null": "NO"}]
+ if sql == "SHOW CREATE TABLE `hms_prod`.`analytics`.`events`":
+ return [{"Create Table": "CREATE TABLE events (id BIGINT)"}]
+ raise AssertionError(f"Unexpected SQL: {sql}")
+
+ runtime, _ = _runtime(responder)
+
+ with pytest.raises(LakehouseRuntimeFailure) as failure:
+ await runtime.inspect_lakehouse_table(
+ catalog="hms_prod",
+ database="analytics",
+ table="events",
+ )
+ assert (
+ failure.value.reason_code
+ == "LAKEHOUSE_TABLE_FORMAT_UNSUPPORTED"
+ )
+
+
[email protected]
+async def test_variant_inspection_binds_path_and_returns_only_type_shape() ->
None:
+ def responder(sql: str, params: _Params) -> list[dict[str, Any]]:
+ if sql == "SHOW FULL COLUMNS FROM `analytics`.`profiles`":
+ return [
+ {
+ "Field": "payload",
+ "Type": "VARIANT<'$.profile.age': INT>",
+ "Null": "YES",
+ }
+ ]
+ if sql == "SHOW CREATE TABLE `analytics`.`profiles`":
+ return [
+ {
+ "Create Table": (
+ "CREATE TABLE profiles (payload VARIANT) "
+ "PROPERTIES ("
+ "'storage_format'='V3',"
+ "'variant_enable_doc_mode'='true',"
+ "'variant_sparse_hash_shard_count'='4',"
+ "'password'='do-not-return')"
+ )
+ }
+ ]
+ if sql.startswith(
+ "SELECT VARIANT_TYPE(`payload`[%s][%s]) AS `variant_type`"
+ ):
+ assert params == ("profile", "age")
+ assert sql.endswith("LIMIT 2")
+ return [
+ {"variant_type": '{"":"int"}'},
+ {"variant_type": '{"":"bigint"}'},
+ ]
+ if sql == "SELECT @@version_comment;":
+ return [{"@@version_comment": "Apache Doris 4.1.0"}]
+ raise AssertionError(f"Unexpected SQL: {sql}")
+
+ runtime, manager = _runtime(responder)
+ result = await runtime.inspect_variant_column(
+ catalog=None,
+ database="analytics",
+ table="profiles",
+ column="payload",
+ path="$.profile.age",
+ sample_rows=2,
+ )
+
+ assert result["status"] == "partial"
+ assert result["data"]["typed_paths"] == [
+ {"path": "$.profile.age", "type": "INT"}
+ ]
+ assert result["data"]["configuration"]["mode"] == "doc"
+ assert result["data"]["configuration"]["storage_v3"] is True
+ assert result["data"]["advanced_capabilities"]["doc_mode_supported"] is
True
+ assert result["data"]["shape_sample"]["paths"] == [
+ {
+ "path": "$.profile.age",
+ "types": [
+ {"type": "INT", "rows": 1},
+ {"type": "BIGINT", "rows": 1},
+ ],
+ "rows_observed": 2,
+ "presence_ratio": 1.0,
+ }
+ ]
+ assert manager.params[-2] == ("profile", "age")
+ serialized = str(result)
+ assert "do-not-return" not in serialized
+ assert "CREATE TABLE profiles (" not in serialized
+ assert result["metadata"]["sampled_values_returned"] is False
+
+
[email protected]
+async def test_variant_sampling_failure_degrades_without_exposing_values() ->
None:
+ def responder(sql: str, _params: _Params) -> list[dict[str, Any]]:
+ if sql == "SHOW FULL COLUMNS FROM `analytics`.`profiles`":
+ return [{"Field": "payload", "Type": "VARIANT", "Null": "YES"}]
+ if sql == "SHOW CREATE TABLE `analytics`.`profiles`":
+ return [{"Create Table": "CREATE TABLE profiles (payload
VARIANT)"}]
+ if sql.startswith("SELECT VARIANT_TYPE"):
+ raise RuntimeError(1142, "SELECT denied for secret principal")
+ if sql == "SELECT @@version_comment;":
+ return [{"@@version_comment": "Apache Doris 4.0.6"}]
+ raise AssertionError(f"Unexpected SQL: {sql}")
+
+ runtime, _ = _runtime(responder)
+ result = await runtime.inspect_variant_column(
+ catalog=None,
+ database="analytics",
+ table="profiles",
+ column="payload",
+ )
+
+ assert result["status"] == "partial"
+ assert result["data"]["shape_sample"]["rows_observed"] == 0
+ assert result["data"]["shape_sample"]["paths"] == []
+ assert result["evidence"][-1]["reason_code"] ==
"LAKEHOUSE_PERMISSION_DENIED"
+ assert "secret principal" not in str(result)
+
+
[email protected]
+async def
test_variant_rejects_non_variant_path_injection_and_oversized_sample() -> None:
+ def responder(sql: str, _params: _Params) -> list[dict[str, Any]]:
+ if sql == "SHOW FULL COLUMNS FROM `analytics`.`profiles`":
+ return [{"Field": "payload", "Type": "VARCHAR", "Null": "YES"}]
+ raise AssertionError(f"Unexpected SQL: {sql}")
+
+ lakehouse = SimpleNamespace(
+ max_catalog_objects=50,
+ max_catalog_databases=20,
+ max_snapshots=50,
+ max_partitions=100,
+ max_variant_sample_rows=5,
+ max_variant_paths=200,
+ )
+ runtime, manager = _runtime(responder, lakehouse=lakehouse)
+
+ with pytest.raises(LakehouseRuntimeFailure) as type_failure:
+ await runtime.inspect_variant_column(
+ catalog=None,
+ database="analytics",
+ table="profiles",
+ column="payload",
+ )
+ assert type_failure.value.reason_code == "LAKEHOUSE_COLUMN_NOT_VARIANT"
+
+ call_count = len(manager.calls)
+ with pytest.raises(LakehouseRuntimeFailure) as path_failure:
+ await runtime.inspect_variant_column(
+ catalog=None,
+ database="analytics",
+ table="profiles",
+ column="payload",
+ path="$['profile']; DROP TABLE users",
+ )
+ assert path_failure.value.reason_code == "LAKEHOUSE_ARGUMENT_INVALID"
+ assert len(manager.calls) == call_count
+
+ with pytest.raises(LakehouseRuntimeFailure) as limit_failure:
+ await runtime.inspect_variant_column(
+ catalog=None,
+ database="analytics",
+ table="profiles",
+ column="payload",
+ sample_rows=6,
+ )
+ assert limit_failure.value.reason_code == "LAKEHOUSE_ARGUMENT_INVALID"
+ assert len(manager.calls) == call_count
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]