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 97ebfac  fix(cluster): require recorded history evidence (#212)
97ebfac is described below

commit 97ebfacca0b23058e7bbb35420823a767a768f12
Author: Yijia Su <[email protected]>
AuthorDate: Thu Aug 13 19:36:59 2026 +0800

    fix(cluster): require recorded history evidence (#212)
---
 CHANGELOG.md                                  |   3 +
 doris_mcp_server/tools/capability_detector.py | 109 +++++++++++++++++++++++---
 doris_mcp_server/tools/domain_catalog.py      |   3 +-
 doris_mcp_server/utils/cluster_runtime.py     |  15 ++++
 test/tools/test_capability_detector.py        |  54 ++++++++++++-
 test/utils/test_cluster_runtime.py            |  30 +++++++
 6 files changed, 198 insertions(+), 16 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index e58126b..58ccee2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -65,6 +65,9 @@ under **Unreleased** until a new version is selected and 
published.
 
 ### Fixed
 
+- Required at least two recorded time buckets before exposing or executing
+  resource-growth analysis, so readable-but-empty audit and partition metadata
+  no longer masquerade as historical evidence.
 - Aligned `doris_cluster.list_active_tasks` capability detection with its
   read-only execution fallbacks, so restricted Doris accounts can use the
   `information_schema.active_queries` or process-list source when
diff --git a/doris_mcp_server/tools/capability_detector.py 
b/doris_mcp_server/tools/capability_detector.py
index e9342f4..6d115c4 100644
--- a/doris_mcp_server/tools/capability_detector.py
+++ b/doris_mcp_server/tools/capability_detector.py
@@ -244,17 +244,6 @@ _DOMAIN_PROBES: Mapping[str, tuple[tuple[str, tuple[str, 
...]], ...]] = {
             "SHOW COMPUTE GROUPS",
             ("compute_group_metadata_readable",),
         ),
-        (
-            ("SELECT `time` FROM internal.__internal_schema.audit_log LIMIT 
1"),
-            ("metrics_history_readable",),
-        ),
-        (
-            (
-                "SELECT CREATE_TIME, DATA_LENGTH, INDEX_LENGTH "
-                "FROM information_schema.partitions LIMIT 1"
-            ),
-            ("resource_storage_history_readable",),
-        ),
     ),
     "doris_pipeline": (
         (
@@ -516,6 +505,9 @@ class DorisCapabilityDetector:
                         probes
                     )
                 elif domain_name == "doris_cluster":
+                    probes.update(
+                        await self._probe_cluster_history_sources(auth_context)
+                    )
                     probes.update(await 
self._safe_probe_cluster_services(auth_context))
                     probes.update(_combine_cluster_evidence_probes(probes))
                 elif domain_name == "doris_pipeline":
@@ -753,6 +745,76 @@ class DorisCapabilityDetector:
         )
         return probes
 
+    async def _probe_cluster_history_sources(
+        self,
+        auth_context: Any | None,
+    ) -> dict[str, CapabilityProbeEvidence]:
+        """Require recorded history, not merely readable metadata objects."""
+        contracts = (
+            (
+                "metrics_history_readable",
+                "SELECT COUNT(DISTINCT DATE(`time`)) AS evidence_bucket_count "
+                "FROM internal.__internal_schema.audit_log "
+                "WHERE `time` >= DATE_SUB(NOW(), INTERVAL 3650 DAY)",
+                "AUDIT_HISTORY_RECORDED",
+                "AUDIT_HISTORY_INSUFFICIENT",
+            ),
+            (
+                "resource_storage_history_readable",
+                "SELECT COUNT(DISTINCT DATE(CREATE_TIME)) "
+                "AS evidence_bucket_count "
+                "FROM information_schema.partitions "
+                "WHERE CREATE_TIME >= DATE_SUB(NOW(), INTERVAL 3650 DAY)",
+                "PARTITION_CREATION_HISTORY_RECORDED",
+                "PARTITION_CREATION_HISTORY_INSUFFICIENT",
+            ),
+        )
+        evidence: dict[str, CapabilityProbeEvidence] = {}
+        route = self.route_identity(auth_context)
+        for index, (
+            probe_id,
+            statement,
+            supported_reason,
+            insufficient_reason,
+        ) in enumerate(contracts):
+            session_id = (
+                f"capability-cluster:history:{index}:"
+                f"{route.fingerprint[:12]}"
+            )
+            async with (
+                
self._connection_manager.get_connection_context_for_auth_context(
+                    session_id,
+                    auth_context,
+                ) as connection
+            ):
+                rows, probe = await self._probe_rows(
+                    connection,
+                    statement,
+                    probe_id,
+                )
+            if probe.status is CapabilityProbeStatus.SUPPORTED:
+                bucket_count = _nonnegative_int(
+                    _row_value(rows[0], "evidence_bucket_count")
+                    if rows
+                    else None
+                )
+                probe = CapabilityProbeEvidence(
+                    probe_id=probe_id,
+                    status=(
+                        CapabilityProbeStatus.SUPPORTED
+                        if bucket_count >= 2
+                        else CapabilityProbeStatus.UNKNOWN
+                    ),
+                    reason_code=(
+                        supported_reason
+                        if bucket_count >= 2
+                        else insufficient_reason
+                    ),
+                    evidence_sources=("recorded_history_probe",),
+                )
+            evidence[probe_id] = probe
+        return evidence
+
     async def _probe_search_match_syntax(
         self,
         auth_context: Any | None,
@@ -1886,6 +1948,16 @@ def _combine_cluster_evidence_probes(
             evidence_sources=("runtime_probe",),
         )
     )
+    if audit is not None and storage is not None and not any(
+        source.status is CapabilityProbeStatus.SUPPORTED
+        for source in (audit, storage)
+    ):
+        full = CapabilityProbeEvidence(
+            probe_id=full.probe_id,
+            status=full.status,
+            reason_code="RESOURCE_HISTORY_UNAVAILABLE",
+            evidence_sources=full.evidence_sources,
+        )
 
     def partial_source(
         probe_id: str,
@@ -2349,6 +2421,21 @@ def _row_value(
     return None
 
 
+def _nonnegative_int(value: Any | None) -> int:
+    if isinstance(value, bool):
+        return 0
+    if isinstance(value, int):
+        parsed = value
+    elif isinstance(value, str):
+        try:
+            parsed = int(value.strip())
+        except ValueError:
+            return 0
+    else:
+        return 0
+    return max(0, parsed)
+
+
 def _component_version(value: Any) -> DorisVersion:
     raw = "" if value is None else str(value).strip()
     if not raw:
diff --git a/doris_mcp_server/tools/domain_catalog.py 
b/doris_mcp_server/tools/domain_catalog.py
index 56267b4..df024b5 100644
--- a/doris_mcp_server/tools/domain_catalog.py
+++ b/doris_mcp_server/tools/domain_catalog.py
@@ -1513,7 +1513,8 @@ DOMAIN_DEFINITIONS = (
                 "analyze_resource_growth",
                 "Analyze resource growth",
                 "Analyze recorded resource-growth evidence without inventing "
-                "missing history. For an unqualified cluster-history request, "
+                "missing history. A series requires at least two recorded time 
"
+                "buckets. For an unqualified cluster-history request, "
                 "omit resource so every currently usable recorded series is "
                 "attempted and partial evidence is preserved.",
                 _input_schema(
diff --git a/doris_mcp_server/utils/cluster_runtime.py 
b/doris_mcp_server/utils/cluster_runtime.py
index ce88fa2..28b54c5 100644
--- a/doris_mcp_server/utils/cluster_runtime.py
+++ b/doris_mcp_server/utils/cluster_runtime.py
@@ -749,7 +749,22 @@ class DorisClusterRuntime:
                     "value": _number(_row_lookup(row, "value")),
                 }
                 for row in rows
+                if _row_lookup(row, "bucket") is not None
             ]
+            if len(points) < 2:
+                warnings.append(
+                    f"{resource_name} history has fewer than two recorded "
+                    "time buckets."
+                )
+                evidence.append(
+                    {
+                        "resource": resource_name,
+                        "success": False,
+                        "reason_code": "RESOURCE_HISTORY_INSUFFICIENT",
+                        "points": len(points),
+                    }
+                )
+                continue
             series[resource_name] = points
             evidence.append(
                 {
diff --git a/test/tools/test_capability_detector.py 
b/test/tools/test_capability_detector.py
index 187a1ec..8066cd6 100644
--- a/test/tools/test_capability_detector.py
+++ b/test/tools/test_capability_detector.py
@@ -44,6 +44,16 @@ _SEARCH_TARGET_DISCOVERY_SQL = (
     "AND TABLE_SCHEMA NOT IN ('information_schema', 'mysql') "
     "ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION LIMIT 8"
 )
+_AUDIT_HISTORY_PROBE_SQL = (
+    "SELECT COUNT(DISTINCT DATE(`time`)) AS evidence_bucket_count "
+    "FROM internal.__internal_schema.audit_log "
+    "WHERE `time` >= DATE_SUB(NOW(), INTERVAL 3650 DAY)"
+)
+_STORAGE_HISTORY_PROBE_SQL = (
+    "SELECT COUNT(DISTINCT DATE(CREATE_TIME)) AS evidence_bucket_count "
+    "FROM information_schema.partitions "
+    "WHERE CREATE_TIME >= DATE_SUB(NOW(), INTERVAL 3650 DAY)"
+)
 
 
 class _ProbeConnection:
@@ -113,6 +123,8 @@ class _ProbeConnection:
                     "DATA_LENGTH": 8,
                 }
             ],
+            _AUDIT_HISTORY_PROBE_SQL: [{"evidence_bucket_count": 3}],
+            _STORAGE_HISTORY_PROBE_SQL: [{"evidence_bucket_count": 2}],
         }
         return SimpleNamespace(data=self.row_overrides.get(sql, rows.get(sql, 
[])))
 
@@ -222,10 +234,7 @@ async def 
test_detector_builds_version_vector_and_extends_domains_lazily() -> No
 @pytest.mark.asyncio
 async def test_cluster_history_keeps_storage_fallback_without_audit_access() 
-> None:
     connection = _ProbeConnection()
-    audit_probe = (
-        "SELECT `time` FROM internal.__internal_schema.audit_log LIMIT 1"
-    )
-    connection.failures[audit_probe] = RuntimeError(
+    connection.failures[_AUDIT_HISTORY_PROBE_SQL] = RuntimeError(
         "Access denied; user lacks SELECT privilege"
     )
     manager = _ProbeConnectionManager(connection)
@@ -251,6 +260,43 @@ async def 
test_cluster_history_keeps_storage_fallback_without_audit_access() ->
     assert storage.reason_code == "PARTITION_CREATION_HISTORY_ONLY"
 
 
[email protected]
+async def test_cluster_history_requires_two_recorded_time_buckets() -> None:
+    connection = _ProbeConnection()
+    connection.row_overrides[_AUDIT_HISTORY_PROBE_SQL] = [
+        {"evidence_bucket_count": 1}
+    ]
+    connection.row_overrides[_STORAGE_HISTORY_PROBE_SQL] = [
+        {"evidence_bucket_count": 0}
+    ]
+    manager = _ProbeConnectionManager(connection)
+    detector = DorisCapabilityDetector(manager)  # type: ignore[arg-type]
+    base = await detector.detect_base(
+        None,
+        capability_generation=1,
+        provider_generation="provider.cluster",
+    )
+
+    cluster = await detector.detect_domain(base, "doris_cluster", None)
+
+    audit = cluster.probe("metrics_history_readable")
+    storage = cluster.probe("resource_storage_history_readable")
+    assert audit is not None
+    assert audit.status is CapabilityProbeStatus.UNKNOWN
+    assert audit.reason_code == "AUDIT_HISTORY_INSUFFICIENT"
+    assert storage is not None
+    assert storage.status is CapabilityProbeStatus.UNKNOWN
+    assert storage.reason_code == "PARTITION_CREATION_HISTORY_INSUFFICIENT"
+    assert (
+        cluster.probe("resource_history_all_sources_readable").status
+        is CapabilityProbeStatus.UNKNOWN
+    )
+    assert (
+        cluster.probe("resource_history_all_sources_readable").reason_code
+        == "RESOURCE_HISTORY_UNAVAILABLE"
+    )
+
+
 @pytest.mark.asyncio
 async def test_cluster_active_tasks_accepts_read_only_query_view_fallback() -> 
None:
     connection = _ProbeConnection()
diff --git a/test/utils/test_cluster_runtime.py 
b/test/utils/test_cluster_runtime.py
index b6b8e81..9e7c613 100644
--- a/test/utils/test_cluster_runtime.py
+++ b/test/utils/test_cluster_runtime.py
@@ -346,3 +346,33 @@ async def 
test_resource_growth_rejects_unknown_resource_before_sql() -> None:
 
     assert error.value.reason_code == "CLUSTER_ARGUMENT_INVALID"
     assert manager.calls == []
+
+
[email protected]
[email protected](
+    "rows",
+    [
+        [],
+        [{"bucket": "2026-07-31", "value": 15}],
+    ],
+)
+async def test_resource_growth_rejects_insufficient_recorded_history(
+    rows: list[dict[str, Any]],
+) -> None:
+    query_sql = (
+        "SELECT DATE(`time`) AS bucket, COUNT(*) AS value "
+        "FROM internal.__internal_schema.audit_log "
+        "WHERE `time` >= DATE_SUB(NOW(), INTERVAL %s DAY) "
+        "GROUP BY bucket ORDER BY bucket"
+    )
+    runtime, manager, _ = _runtime(rows={query_sql: rows})
+
+    with pytest.raises(ClusterRuntimeFailure) as error:
+        await runtime.analyze_resource_growth(
+            resource="query_volume",
+            window_days=30,
+            granularity="day",
+        )
+
+    assert error.value.reason_code == "RESOURCE_HISTORY_UNAVAILABLE"
+    assert manager.calls == [query_sql]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to