| # 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. |
| """ |
| Performance Analytics Tools Module |
| Provides slow query analysis and resource growth monitoring capabilities |
| """ |
| |
| import statistics |
| import time |
| from collections import Counter, defaultdict |
| from datetime import datetime, timedelta |
| from typing import Any, TypedDict |
| |
| from .db import DorisConnection, DorisConnectionManager |
| from .logger import get_logger |
| from .sql_security_utils import ( |
| SQLSecurityError, |
| build_table_reference, |
| get_auth_context, |
| quote_identifier, |
| validate_identifier, |
| validate_integer, |
| ) |
| |
| logger = get_logger(__name__) |
| |
| |
| class DailyGrowthData(TypedDict): |
| """Per-day partition growth aggregation.""" |
| |
| rows: int |
| size_mb: float |
| |
| |
| class PerformanceAnalyticsTools: |
| """Performance analytics tools for query and resource monitoring""" |
| |
| def __init__(self, connection_manager: DorisConnectionManager): |
| self.connection_manager = connection_manager |
| logger.info("PerformanceAnalyticsTools initialized") |
| |
| async def analyze_slow_queries_topn( |
| self, |
| days: int = 7, |
| top_n: int = 20, |
| min_execution_time_ms: int = 1000, |
| include_patterns: bool = True |
| ) -> dict[str, Any]: |
| """ |
| Analyze top N slowest queries and performance patterns |
| |
| Args: |
| days: Number of days to analyze |
| top_n: Number of top slow queries to return |
| min_execution_time_ms: Minimum execution time threshold |
| include_patterns: Whether to include query pattern analysis |
| |
| Returns: |
| Slow query analysis results |
| """ |
| connection = None |
| try: |
| days = validate_integer(days, "days", minimum=1, maximum=3650) |
| top_n = validate_integer(top_n, "top N", minimum=1, maximum=1000) |
| min_execution_time_ms = validate_integer( |
| min_execution_time_ms, |
| "minimum execution time", |
| minimum=0, |
| maximum=86_400_000, |
| ) |
| start_time = time.time() |
| connection = await self.connection_manager.get_connection("query") |
| |
| # Get slow query data |
| slow_queries = await self._get_slow_query_data( |
| connection, days, min_execution_time_ms |
| ) |
| |
| if not slow_queries: |
| return { |
| "message": "No slow queries found for the specified criteria", |
| "analysis_period": {"days": days, "threshold_ms": min_execution_time_ms}, |
| "analysis_timestamp": datetime.now().isoformat() |
| } |
| |
| # Analyze top N queries |
| top_queries = await self._analyze_top_slow_queries(slow_queries, top_n) |
| |
| # Performance insights |
| performance_insights = await self._generate_performance_insights(slow_queries) |
| |
| # Query patterns analysis |
| pattern_analysis = {} |
| if include_patterns: |
| pattern_analysis = await self._analyze_query_patterns(slow_queries) |
| |
| execution_time = time.time() - start_time |
| |
| return { |
| "analysis_period": { |
| "days": days, |
| "threshold_ms": min_execution_time_ms, |
| "start_date": (datetime.now() - timedelta(days=days)).isoformat(), |
| "end_date": datetime.now().isoformat() |
| }, |
| "analysis_timestamp": datetime.now().isoformat(), |
| "execution_time_seconds": round(execution_time, 3), |
| "summary": { |
| "total_slow_queries": len(slow_queries), |
| "unique_queries": len({q.get("sql_hash", q.get("sql", ""))[:100] for q in slow_queries}), |
| "top_n_analyzed": min(top_n, len(slow_queries)) |
| }, |
| "top_slow_queries": top_queries, |
| "performance_insights": performance_insights, |
| "query_patterns": pattern_analysis, |
| "recommendations": self._generate_performance_recommendations(performance_insights, pattern_analysis) |
| } |
| |
| except Exception as e: |
| logger.error(f"Slow query analysis failed: {str(e)}") |
| return { |
| "error": str(e), |
| "analysis_timestamp": datetime.now().isoformat() |
| } |
| finally: |
| release_connection = getattr( |
| self.connection_manager, |
| "release_connection", |
| None, |
| ) |
| if connection is not None and callable(release_connection): |
| await release_connection("query", connection) |
| |
| async def analyze_resource_growth_curves( |
| self, |
| days: int = 30, |
| resource_types: list[str] | None = None, |
| include_predictions: bool = False, |
| detailed_response: bool = False |
| ) -> dict[str, Any]: |
| """ |
| Analyze resource growth patterns and trends based on real historical data |
| |
| Args: |
| days: Number of days to analyze |
| resource_types: Types of resources to analyze |
| include_predictions: Whether to include growth predictions |
| detailed_response: Whether to return detailed data including daily breakdowns |
| |
| Returns: |
| Resource growth analysis results |
| """ |
| connection = None |
| try: |
| days = validate_integer(days, "days", minimum=1, maximum=3650) |
| start_time = time.time() |
| connection = await self.connection_manager.get_connection("query") |
| |
| if resource_types is None: |
| resource_types = ["storage", "query_volume", "user_activity"] |
| |
| # Analyze each resource type |
| resource_analysis = {} |
| |
| if "storage" in resource_types: |
| resource_analysis["storage"] = await self._analyze_storage_growth_with_real_data( |
| connection, days, detailed_response |
| ) |
| |
| if "query_volume" in resource_types: |
| resource_analysis["query_volume"] = await self._analyze_query_volume_growth( |
| connection, days, detailed_response |
| ) |
| |
| if "user_activity" in resource_types: |
| resource_analysis["user_activity"] = await self._analyze_user_activity_growth( |
| connection, days, detailed_response |
| ) |
| |
| # Generate growth insights |
| growth_insights = await self._generate_enhanced_growth_insights(resource_analysis, days) |
| |
| # Growth predictions (based on real data) |
| predictions = {} |
| if include_predictions: |
| predictions = await self._generate_statistical_growth_predictions(resource_analysis, days) |
| |
| execution_time = time.time() - start_time |
| |
| result = { |
| "analysis_period": { |
| "days": days, |
| "start_date": (datetime.now() - timedelta(days=days)).isoformat(), |
| "end_date": datetime.now().isoformat() |
| }, |
| "analysis_timestamp": datetime.now().isoformat(), |
| "execution_time_seconds": round(execution_time, 3), |
| "resource_types_analyzed": resource_types, |
| "resource_analysis": resource_analysis, |
| "growth_insights": growth_insights, |
| "growth_predictions": predictions, |
| "recommendations": self._generate_enhanced_growth_recommendations(growth_insights, predictions), |
| "data_quality": { |
| "historical_data_available": True, |
| "analysis_methods": ["partition_based", "timestamp_based", "audit_log_based"], |
| "confidence_level": "high" |
| } |
| } |
| |
| # Add execution info for debugging |
| result["_execution_info"] = { |
| "tool_name": "analyze_resource_growth_curves", |
| "execution_time": round(execution_time, 3), |
| "timestamp": datetime.now().isoformat(), |
| "detailed_response": detailed_response, |
| "version": "2.0_real_data_based" |
| } |
| |
| return result |
| |
| except Exception as e: |
| logger.error(f"Resource growth analysis failed: {str(e)}") |
| return { |
| "error": str(e), |
| "analysis_timestamp": datetime.now().isoformat() |
| } |
| finally: |
| release_connection = getattr( |
| self.connection_manager, |
| "release_connection", |
| None, |
| ) |
| if connection is not None and callable(release_connection): |
| await release_connection("query", connection) |
| |
| # ==================== Private Helper Methods ==================== |
| |
| async def _analyze_query_volume_growth( |
| self, |
| connection: DorisConnection, |
| days: int, |
| detailed_response: bool = False, |
| ) -> dict[str, Any]: |
| """Analyze query volume growth patterns""" |
| try: |
| start_date = datetime.now() - timedelta(days=days) |
| |
| # Get daily query counts from audit logs |
| query_volume_sql = """ |
| SELECT |
| DATE(`time`) as query_date, |
| COUNT(*) as total_queries, |
| COUNT(DISTINCT `user`) as unique_users, |
| AVG(`query_time`) as avg_execution_time_ms, |
| SUM(`scan_bytes`) as total_scan_bytes, |
| SUM(`scan_rows`) as total_scan_rows |
| FROM internal.__internal_schema.audit_log |
| WHERE `time` >= %s |
| AND `stmt` IS NOT NULL |
| AND `stmt` != '' |
| GROUP BY DATE(`time`) |
| ORDER BY query_date |
| """ |
| |
| auth_context = get_auth_context() |
| result = await connection.execute( |
| query_volume_sql, |
| params=(start_date,), |
| auth_context=auth_context, |
| ) |
| daily_data = result.data if result.data else [] |
| |
| if not daily_data: |
| return { |
| "growth_trend": "no_data", |
| "daily_query_count": { |
| "current": 0, |
| "average": 0, |
| "growth_rate_percent": 0 |
| }, |
| "query_complexity_trend": "stable", |
| "user_adoption_trend": "stable" |
| } |
| |
| # Calculate growth metrics |
| query_counts = [row.get("total_queries", 0) for row in daily_data] |
| |
| avg_queries = sum(query_counts) / len(query_counts) if query_counts else 0 |
| current_queries = query_counts[-1] if query_counts else 0 |
| |
| # Calculate growth rate |
| if len(query_counts) >= 2: |
| early_avg = sum(query_counts[:len(query_counts)//2]) / (len(query_counts)//2) |
| late_avg = sum(query_counts[len(query_counts)//2:]) / (len(query_counts) - len(query_counts)//2) |
| growth_rate = ((late_avg - early_avg) / early_avg * 100) if early_avg > 0 else 0 |
| else: |
| growth_rate = 0 |
| |
| return { |
| "growth_trend": "increasing" if growth_rate > 5 else "decreasing" if growth_rate < -5 else "stable", |
| "daily_query_count": { |
| "current": current_queries, |
| "average": round(avg_queries, 2), |
| "growth_rate_percent": round(growth_rate, 2) |
| }, |
| "query_complexity_trend": "stable", # Could be enhanced with more analysis |
| "user_adoption_trend": "stable", # Could be enhanced with more analysis |
| "analysis_period_days": days, |
| "data_points": len(daily_data) |
| } |
| |
| except Exception as e: |
| logger.warning(f"Failed to analyze query volume growth: {str(e)}") |
| return { |
| "growth_trend": "unknown", |
| "daily_query_count": { |
| "current": 0, |
| "average": 0, |
| "growth_rate_percent": 0 |
| }, |
| "error": str(e) |
| } |
| |
| async def _analyze_user_activity_growth( |
| self, |
| connection: DorisConnection, |
| days: int, |
| detailed_response: bool = False, |
| ) -> dict[str, Any]: |
| """Analyze user activity growth patterns""" |
| try: |
| start_date = datetime.now() - timedelta(days=days) |
| |
| # Get daily user activity from audit logs |
| user_activity_sql = """ |
| SELECT |
| DATE(`time`) as activity_date, |
| COUNT(DISTINCT `user`) as daily_active_users, |
| COUNT(*) as total_queries, |
| COUNT(DISTINCT `client_ip`) as unique_ips |
| FROM internal.__internal_schema.audit_log |
| WHERE `time` >= %s |
| AND `stmt` IS NOT NULL |
| AND `stmt` != '' |
| GROUP BY DATE(`time`) |
| ORDER BY activity_date |
| """ |
| |
| auth_context = get_auth_context() |
| result = await connection.execute( |
| user_activity_sql, |
| params=(start_date,), |
| auth_context=auth_context, |
| ) |
| daily_data = result.data if result.data else [] |
| |
| if not daily_data: |
| return { |
| "growth_trend": "no_data", |
| "daily_active_users": { |
| "current": 0, |
| "average": 0, |
| "growth_rate_percent": 0 |
| }, |
| "user_engagement_trend": "stable" |
| } |
| |
| # Calculate user activity metrics |
| user_counts = [row.get("daily_active_users", 0) for row in daily_data] |
| avg_users = sum(user_counts) / len(user_counts) if user_counts else 0 |
| current_users = user_counts[-1] if user_counts else 0 |
| |
| # Calculate growth rate |
| if len(user_counts) >= 2: |
| early_avg = sum(user_counts[:len(user_counts)//2]) / (len(user_counts)//2) |
| late_avg = sum(user_counts[len(user_counts)//2:]) / (len(user_counts) - len(user_counts)//2) |
| growth_rate = ((late_avg - early_avg) / early_avg * 100) if early_avg > 0 else 0 |
| else: |
| growth_rate = 0 |
| |
| return { |
| "growth_trend": "increasing" if growth_rate > 10 else "decreasing" if growth_rate < -10 else "stable", |
| "daily_active_users": { |
| "current": current_users, |
| "average": round(avg_users, 2), |
| "growth_rate_percent": round(growth_rate, 2) |
| }, |
| "user_engagement_trend": "stable", # Could be enhanced with more analysis |
| "analysis_period_days": days, |
| "data_points": len(daily_data) |
| } |
| |
| except Exception as e: |
| logger.warning(f"Failed to analyze user activity growth: {str(e)}") |
| return { |
| "growth_trend": "unknown", |
| "daily_active_users": { |
| "current": 0, |
| "average": 0, |
| "growth_rate_percent": 0 |
| }, |
| "error": str(e) |
| } |
| |
| async def _get_slow_query_data( |
| self, |
| connection: DorisConnection, |
| days: int, |
| min_execution_time_ms: int, |
| ) -> list[dict[str, Any]]: |
| """Get slow query data from audit logs""" |
| try: |
| start_date = datetime.now() - timedelta(days=days) |
| |
| slow_query_sql = """ |
| SELECT |
| `user` as user_name, |
| `client_ip` as host, |
| `time` as query_time, |
| `stmt` as sql_statement, |
| `query_time` as execution_time_ms, |
| `scan_bytes` as scan_bytes, |
| `scan_rows` as scan_rows, |
| `return_rows` as return_rows |
| FROM internal.__internal_schema.audit_log |
| WHERE `time` >= %s |
| AND `query_time` >= %s |
| AND `stmt` IS NOT NULL |
| AND `stmt` != '' |
| AND `stmt` NOT LIKE '%__internal_schema%' |
| AND `stmt` NOT LIKE '%information_schema%' |
| AND `stmt` NOT LIKE '%mysql%' |
| AND `state` != 'ERR' |
| ORDER BY `query_time` DESC |
| LIMIT 5000 |
| """ |
| |
| auth_context = get_auth_context() |
| result = await connection.execute( |
| slow_query_sql, |
| params=(start_date, min_execution_time_ms), |
| auth_context=auth_context, |
| ) |
| return result.data if result.data else [] |
| |
| except Exception as e: |
| logger.warning(f"Failed to get slow query data: {str(e)}") |
| return [] |
| |
| async def _analyze_top_slow_queries(self, slow_queries: list[dict], top_n: int) -> list[dict]: |
| """Analyze top N slowest queries""" |
| # Sort by execution time and take top N |
| sorted_queries = sorted( |
| slow_queries, |
| key=lambda x: x.get("execution_time_ms", 0), |
| reverse=True |
| )[:top_n] |
| |
| analyzed_queries = [] |
| for i, query in enumerate(sorted_queries): |
| sql = query.get("sql_statement", "") |
| execution_time = query.get("execution_time_ms", 0) |
| |
| analyzed_query = { |
| "rank": i + 1, |
| "execution_time_ms": execution_time, |
| "execution_time_seconds": round(execution_time / 1000, 2), |
| "user_name": query.get("user_name", "unknown"), |
| "query_time": str(query.get("query_time", "")), |
| "sql_statement": sql[:500] + "..." if len(sql) > 500 else sql, |
| "sql_length": len(sql), |
| "query_type": self._classify_query_type(sql), |
| "scan_metrics": { |
| "scan_bytes": query.get("scan_bytes", 0), |
| "scan_rows": query.get("scan_rows", 0), |
| "return_rows": query.get("return_rows", 0) |
| }, |
| "performance_issues": self._identify_performance_issues(query) |
| } |
| |
| analyzed_queries.append(analyzed_query) |
| |
| return analyzed_queries |
| |
| def _classify_query_type(self, sql: str) -> str: |
| """Classify SQL query type""" |
| if not sql: |
| return "unknown" |
| |
| sql_upper = sql.upper().strip() |
| |
| if sql_upper.startswith('SELECT'): |
| return "SELECT" |
| elif sql_upper.startswith('INSERT'): |
| return "INSERT" |
| elif sql_upper.startswith('UPDATE'): |
| return "UPDATE" |
| elif sql_upper.startswith('DELETE'): |
| return "DELETE" |
| else: |
| return "OTHER" |
| |
| def _identify_performance_issues(self, query: dict) -> list[str]: |
| """Identify potential performance issues in query""" |
| issues = [] |
| |
| sql = query.get("sql_statement", "").upper() |
| execution_time = query.get("execution_time_ms", 0) |
| scan_bytes = query.get("scan_bytes", 0) |
| scan_rows = query.get("scan_rows", 0) |
| return_rows = query.get("return_rows", 0) |
| |
| # High execution time |
| if execution_time > 60000: # > 1 minute |
| issues.append("very_long_execution") |
| elif execution_time > 10000: # > 10 seconds |
| issues.append("long_execution") |
| |
| # Large data scan |
| if scan_bytes > 1024**3: # > 1GB |
| issues.append("large_data_scan") |
| |
| # High row scan vs return ratio |
| if scan_rows > 0 and return_rows > 0: |
| scan_ratio = scan_rows / return_rows |
| if scan_ratio > 1000: |
| issues.append("inefficient_filtering") |
| |
| # SQL pattern issues |
| if "SELECT *" in sql: |
| issues.append("select_all_columns") |
| |
| if "ORDER BY" in sql and "LIMIT" not in sql: |
| issues.append("unlimited_sort") |
| |
| return issues |
| |
| async def _generate_performance_insights(self, slow_queries: list[dict]) -> dict[str, Any]: |
| """Generate performance insights from slow queries""" |
| if not slow_queries: |
| return {} |
| |
| execution_times = [q.get("execution_time_ms", 0) for q in slow_queries] |
| scan_bytes = [q.get("scan_bytes", 0) for q in slow_queries if q.get("scan_bytes", 0) > 0] |
| |
| # User analysis |
| user_query_counts = Counter(q.get("user_name", "unknown") for q in slow_queries) |
| |
| # Query type distribution |
| query_types = Counter(self._classify_query_type(q.get("sql_statement", "")) for q in slow_queries) |
| |
| # Time pattern analysis |
| query_hours = [] |
| for query in slow_queries: |
| try: |
| query_time = query.get("query_time") |
| if query_time: |
| if isinstance(query_time, str): |
| dt = datetime.fromisoformat(query_time.replace('Z', '+00:00')) |
| else: |
| dt = query_time |
| query_hours.append(dt.hour) |
| except (AttributeError, TypeError, ValueError) as exc: |
| logger.debug( |
| "Skipping invalid slow-query timestamp %r: %s", |
| query.get("query_time"), |
| exc, |
| ) |
| continue |
| |
| hour_distribution = Counter(query_hours) |
| |
| return { |
| "execution_time_stats": { |
| "avg_ms": round(statistics.mean(execution_times), 2) if execution_times else 0, |
| "median_ms": round(statistics.median(execution_times), 2) if execution_times else 0, |
| "max_ms": max(execution_times) if execution_times else 0, |
| "min_ms": min(execution_times) if execution_times else 0 |
| }, |
| "data_scan_stats": { |
| "avg_bytes": round(statistics.mean(scan_bytes), 2) if scan_bytes else 0, |
| "max_bytes": max(scan_bytes) if scan_bytes else 0, |
| "total_bytes_scanned": sum(scan_bytes) if scan_bytes else 0 |
| }, |
| "user_analysis": { |
| "top_slow_query_users": dict(user_query_counts.most_common(10)), |
| "unique_users": len(user_query_counts) |
| }, |
| "query_type_distribution": dict(query_types), |
| "temporal_patterns": { |
| "hourly_distribution": dict(hour_distribution), |
| "peak_hour": ( |
| max( |
| hour_distribution, |
| key=lambda hour: hour_distribution[hour], |
| ) |
| if hour_distribution |
| else None |
| ) |
| } |
| } |
| |
| async def _analyze_query_patterns(self, slow_queries: list[dict]) -> dict[str, Any]: |
| """Analyze query patterns in slow queries""" |
| patterns: dict[str, Any] = { |
| "common_issues": Counter(), |
| "table_access_patterns": Counter(), |
| "query_complexity": [] |
| } |
| |
| for query in slow_queries: |
| sql = query.get("sql_statement", "") |
| |
| # Identify common issues |
| issues = self._identify_performance_issues(query) |
| patterns["common_issues"].update(issues) |
| |
| # Extract table names |
| tables = self._extract_table_names(sql) |
| patterns["table_access_patterns"].update(tables) |
| |
| # Query complexity metrics |
| complexity = self._calculate_query_complexity(sql) |
| patterns["query_complexity"].append(complexity) |
| |
| return { |
| "common_performance_issues": dict(patterns["common_issues"].most_common(10)), |
| "frequently_accessed_tables": dict(patterns["table_access_patterns"].most_common(15)), |
| "complexity_analysis": { |
| "avg_complexity": round(statistics.mean(patterns["query_complexity"]), 2) if patterns["query_complexity"] else 0, |
| "max_complexity": max(patterns["query_complexity"]) if patterns["query_complexity"] else 0, |
| "high_complexity_queries": len([c for c in patterns["query_complexity"] if c > 10]) |
| } |
| } |
| |
| def _extract_table_names(self, sql: str) -> list[str]: |
| """Extract table names from SQL (simplified)""" |
| import re |
| |
| if not sql: |
| return [] |
| |
| # Simple pattern matching for table names |
| patterns = [ |
| r'\bFROM\s+([a-zA-Z_][a-zA-Z0-9_.]*)', |
| r'\bJOIN\s+([a-zA-Z_][a-zA-Z0-9_.]*)', |
| r'\bINTO\s+([a-zA-Z_][a-zA-Z0-9_.]*)', |
| r'\bUPDATE\s+([a-zA-Z_][a-zA-Z0-9_.]*)' |
| ] |
| |
| tables = [] |
| for pattern in patterns: |
| matches = re.findall(pattern, sql, re.IGNORECASE) |
| tables.extend(matches) |
| |
| return [table.lower() for table in tables if table] |
| |
| def _calculate_query_complexity(self, sql: str) -> int: |
| """Calculate query complexity score""" |
| if not sql: |
| return 0 |
| |
| sql_upper = sql.upper() |
| complexity = 0 |
| |
| # Basic complexity factors |
| complexity += sql_upper.count('JOIN') * 2 |
| complexity += sql_upper.count('SUBQUERY') * 3 |
| complexity += sql_upper.count('UNION') * 2 |
| complexity += sql_upper.count('GROUP BY') * 1 |
| complexity += sql_upper.count('ORDER BY') * 1 |
| complexity += sql_upper.count('HAVING') * 2 |
| complexity += sql_upper.count('CASE') * 1 |
| |
| # Length factor |
| complexity += len(sql) // 100 |
| |
| return complexity |
| |
| async def _analyze_storage_growth_with_real_data( |
| self, |
| connection: DorisConnection, |
| days: int, |
| detailed_response: bool = False, |
| ) -> dict[str, Any]: |
| """Analyze storage growth patterns based on real historical data with intelligent table selection""" |
| try: |
| logger.info("🔍 Starting optimized storage growth analysis...") |
| |
| # Step 1: Fast data size collection using SHOW DATA |
| logger.info("📊 Fast scanning all tables data sizes...") |
| all_tables_sizes = await self._get_all_tables_sizes_fast(connection) |
| if not all_tables_sizes: |
| return {"error": "No tables found for storage analysis"} |
| |
| # Step 2: Calculate data distribution and select top tables |
| logger.info("🎯 Selecting high-impact tables for detailed analysis...") |
| selected_tables = await self._select_high_impact_tables(all_tables_sizes, target_coverage=0.8) |
| |
| logger.info(f"📈 Analyzing {len(selected_tables)} high-impact tables (covering {selected_tables['coverage_percentage']:.1f}% of total data)") |
| |
| # Step 3: Detailed analysis only for selected tables |
| table_growth_data = [] |
| total_current_size = selected_tables["total_selected_size_mb"] |
| total_historical_data_points = 0 |
| |
| for table_info in selected_tables["tables"]: |
| table_name = table_info["table_name"] |
| schema_name = table_info["schema_name"] |
| try: |
| full_table_name = build_table_reference( |
| table_name, |
| schema_name or None, |
| ) |
| except SQLSecurityError as exc: |
| logger.warning( |
| "Skipping table with unsafe metadata identifier: %s", |
| exc, |
| ) |
| continue |
| |
| logger.info(f"🔍 Analyzing table: {full_table_name} ({table_info['size_mb']:.1f}MB)") |
| |
| # Analyze historical growth for single table |
| table_growth = await self._analyze_single_table_storage_growth( |
| connection, full_table_name, table_name, schema_name, days |
| ) |
| |
| if table_growth and table_growth.get("current_size_mb", 0) > 0: |
| table_growth_data.append(table_growth) |
| total_historical_data_points += len(table_growth.get("historical_data", [])) |
| |
| # Calculate overall storage growth trends |
| overall_growth = await self._calculate_overall_storage_growth(table_growth_data, days) |
| |
| result = { |
| "analysis_method": "optimized_high_impact_analysis", |
| "total_tables_scanned": len(all_tables_sizes), |
| "high_impact_tables_analyzed": len(table_growth_data), |
| "data_coverage_percentage": round(selected_tables["coverage_percentage"], 1), |
| "total_cluster_storage_mb": round(selected_tables["total_cluster_size_mb"], 2), |
| "analyzed_storage_mb": round(total_current_size, 2), |
| "historical_data_points": total_historical_data_points, |
| "overall_growth_metrics": overall_growth, |
| "confidence_level": self._calculate_storage_confidence_level(table_growth_data), |
| "optimization_info": { |
| "strategy": "80/20 rule - analyze top tables covering 80% of data", |
| "performance_gain": f"Analyzed {len(table_growth_data)} tables instead of {len(all_tables_sizes)}", |
| "time_saved_percentage": round((1 - len(table_growth_data) / len(all_tables_sizes)) * 100, 1) if all_tables_sizes else 0 |
| } |
| } |
| |
| # Include detailed table-level data |
| if detailed_response: |
| result["table_level_analysis"] = table_growth_data |
| else: |
| # Only include top 10 largest tables |
| result["top_growing_tables"] = sorted( |
| table_growth_data, |
| key=lambda x: x.get("growth_rate_mb_per_day", 0), |
| reverse=True |
| )[:10] |
| |
| logger.info(f"✅ Storage growth analysis completed: {len(table_growth_data)} tables analyzed") |
| return result |
| |
| except Exception as e: |
| logger.error(f"Failed to analyze storage growth with real data: {str(e)}") |
| return {"error": str(e)} |
| |
| async def _get_all_tables_sizes_fast( |
| self, |
| connection: DorisConnection, |
| ) -> list[dict[str, Any]]: |
| """Fast collection of all tables sizes using information_schema optimization""" |
| try: |
| # Stage 1: Get database-level overview using information_schema |
| logger.info("🔍 Stage 1: Getting database-level data overview from information_schema...") |
| |
| db_sizes_sql = """ |
| SELECT |
| TABLE_SCHEMA as db_name, |
| ROUND(SUM(COALESCE(DATA_LENGTH, 0) + COALESCE(INDEX_LENGTH, 0)) / 1024 / 1024, 2) as size_mb, |
| COUNT(*) as table_count |
| FROM information_schema.tables |
| WHERE TABLE_SCHEMA NOT IN ('information_schema', '__internal_schema', 'mysql') |
| AND TABLE_TYPE = 'BASE TABLE' |
| GROUP BY TABLE_SCHEMA |
| HAVING size_mb > 0 |
| ORDER BY size_mb DESC |
| """ |
| |
| auth_context = get_auth_context() |
| db_result = await connection.execute(db_sizes_sql, auth_context=auth_context) |
| |
| if not db_result.data: |
| logger.warning("No database size information available") |
| return [] |
| |
| # Parse database-level data sizes |
| db_sizes = [] |
| for row in db_result.data: |
| db_name = row.get("db_name", "") |
| size_mb = row.get("size_mb", 0) |
| table_count = row.get("table_count", 0) |
| |
| # Ensure size_mb is a valid number |
| try: |
| size_mb = float(size_mb) if size_mb is not None else 0.0 |
| except (ValueError, TypeError): |
| size_mb = 0.0 |
| |
| # Ensure table_count is a valid number |
| try: |
| table_count = int(table_count) if table_count is not None else 0 |
| except (ValueError, TypeError): |
| table_count = 0 |
| |
| if db_name and size_mb > 0: |
| db_sizes.append({ |
| "db_name": db_name, |
| "size_mb": size_mb, |
| "table_count": table_count, |
| "size_display": f"{size_mb:.2f}MB" |
| }) |
| |
| if not db_sizes: |
| logger.warning("No databases with data found") |
| return [] |
| |
| # Select top databases covering 85% of data |
| selected_dbs = self._select_top_databases(db_sizes, target_coverage=0.85) |
| logger.info(f"🎯 Stage 1 completed: Selected {len(selected_dbs['databases'])} databases covering {selected_dbs['coverage_percentage']:.1f}% of data") |
| |
| # Stage 2: Get table-level details for selected databases |
| logger.info("📊 Stage 2: Getting table-level details for selected databases...") |
| all_tables_sizes = [] |
| |
| for db_info in selected_dbs['databases']: |
| db_name = db_info['db_name'] |
| try: |
| # Get table details for this database using information_schema |
| table_details = await self._get_database_table_details_from_schema(connection, db_name) |
| all_tables_sizes.extend(table_details) |
| |
| except Exception as e: |
| logger.warning(f"Failed to get table details for database {db_name}: {str(e)}") |
| continue |
| |
| # Sort by size descending, handle None values |
| all_tables_sizes.sort(key=lambda x: x.get("size_mb", 0) or 0, reverse=True) |
| logger.info(f"✅ Two-stage scan completed: {len(all_tables_sizes)} tables from {len(selected_dbs['databases'])} databases") |
| |
| return all_tables_sizes |
| |
| except Exception as e: |
| logger.error(f"❌ Failed to get tables sizes fast: {str(e)}") |
| return [] |
| |
| def _select_top_databases(self, db_sizes: list[dict], target_coverage: float = 0.85) -> dict: |
| """Select top databases that cover target percentage of total data""" |
| if not db_sizes: |
| return {"databases": [], "total_size_mb": 0, "selected_size_mb": 0, "coverage_percentage": 0} |
| |
| # Sort by size descending, handle None values |
| db_sizes.sort(key=lambda x: x.get("size_mb", 0) or 0, reverse=True) |
| |
| total_size = sum(db["size_mb"] for db in db_sizes) |
| target_size = total_size * target_coverage |
| |
| selected_databases = [] |
| selected_size = 0 |
| |
| for db in db_sizes: |
| selected_databases.append(db) |
| selected_size += db["size_mb"] |
| |
| # Stop when we reach target coverage or have enough databases |
| if selected_size >= target_size or len(selected_databases) >= 10: |
| break |
| |
| coverage_percentage = (selected_size / total_size * 100) if total_size > 0 else 0 |
| |
| return { |
| "databases": selected_databases, |
| "total_size_mb": total_size, |
| "selected_size_mb": selected_size, |
| "coverage_percentage": coverage_percentage |
| } |
| |
| async def _get_database_table_details_from_schema( |
| self, |
| connection: DorisConnection, |
| db_name: str, |
| ) -> list[dict[str, Any]]: |
| """Get table details for a specific database using information_schema""" |
| try: |
| # SECURITY FIX: Validate db_name and use parameterized query |
| auth_context = get_auth_context() |
| |
| try: |
| validate_identifier(db_name, "database name") |
| except SQLSecurityError as e: |
| logger.warning(f"Invalid database name rejected: {e}") |
| return [] |
| |
| table_details_sql = """ |
| SELECT |
| TABLE_SCHEMA as schema_name, |
| TABLE_NAME as table_name, |
| COALESCE(ROUND((COALESCE(DATA_LENGTH, 0) + COALESCE(INDEX_LENGTH, 0)) / 1024 / 1024, 2), 0) as size_mb, |
| COALESCE(TABLE_ROWS, 0) as row_count, |
| CREATE_TIME as create_time, |
| UPDATE_TIME as update_time |
| FROM information_schema.tables |
| WHERE TABLE_SCHEMA = %s |
| AND TABLE_TYPE = 'BASE TABLE' |
| AND (COALESCE(DATA_LENGTH, 0) + COALESCE(INDEX_LENGTH, 0)) > 0 |
| ORDER BY size_mb DESC |
| """ |
| |
| result = await connection.execute(table_details_sql, params=(db_name,), auth_context=auth_context) |
| |
| if not result.data: |
| logger.warning(f"No table details found for database {db_name}") |
| return [] |
| |
| table_details = [] |
| for row in result.data: |
| schema_name = row.get("schema_name", "") |
| table_name = row.get("table_name", "") |
| size_mb = row.get("size_mb", 0) |
| row_count = row.get("row_count", 0) |
| |
| # Ensure size_mb is a valid number |
| try: |
| size_mb = float(size_mb) if size_mb is not None else 0.0 |
| except (ValueError, TypeError): |
| size_mb = 0.0 |
| |
| # Ensure row_count is a valid number |
| try: |
| row_count = int(row_count) if row_count is not None else 0 |
| except (ValueError, TypeError): |
| row_count = 0 |
| |
| if table_name and size_mb > 0: |
| table_details.append({ |
| "schema_name": schema_name, |
| "table_name": table_name, |
| "full_table_name": f"{schema_name}.{table_name}", |
| "size_mb": size_mb, |
| "row_count": row_count, |
| "size_display": f"{size_mb:.2f}MB", |
| "create_time": str(row.get("create_time", "")), |
| "update_time": str(row.get("update_time", "")) |
| }) |
| |
| logger.info(f"📋 Found {len(table_details)} tables in database {db_name}") |
| return table_details |
| |
| except Exception as e: |
| logger.error(f"Failed to get table details for database {db_name}: {str(e)}") |
| return [] |
| |
| async def _get_database_table_details( |
| self, |
| connection: DorisConnection, |
| db_name: str, |
| ) -> list[dict[str, Any]]: |
| """Get table details for a specific database using session-consistent queries""" |
| try: |
| # SECURITY FIX: Validate db_name before using in SQL |
| try: |
| validate_identifier(db_name, "database name") |
| except SQLSecurityError as e: |
| logger.warning(f"Invalid database name rejected: {e}") |
| return [] |
| |
| # Method 1: Try to use session-consistent approach with raw connection |
| # This requires accessing the underlying connection to maintain session state |
| |
| # First, try to get the raw connection if possible |
| raw_conn = getattr(connection, '_connection', None) or getattr(connection, 'connection', None) |
| |
| if raw_conn: |
| # Use raw connection to maintain session state |
| cursor = await raw_conn.cursor() |
| try: |
| # SECURITY FIX: Use quoted identifier for USE statement |
| quoted_db = quote_identifier(db_name, "database name") |
| await cursor.execute(f"USE {quoted_db}") |
| await cursor.execute("SHOW DATA") |
| |
| result = await cursor.fetchall() |
| columns = [desc[0] for desc in cursor.description] |
| |
| # Convert to dict format |
| table_data = [] |
| for row in result: |
| row_dict = dict(zip(columns, row, strict=False)) |
| table_name = row_dict.get("TableName", "") |
| size_str = row_dict.get("Size", "") |
| |
| # Skip summary rows |
| if (table_name and size_str and |
| table_name not in ["Total", "Quota", "Left", "Transaction Quota"]): |
| |
| size_mb = self._parse_size_to_mb(size_str) |
| if size_mb is not None and size_mb > 0: |
| table_data.append({ |
| "schema_name": db_name, |
| "table_name": table_name, |
| "size_mb": size_mb, |
| "size_display": size_str |
| }) |
| |
| await cursor.close() |
| return table_data |
| |
| except Exception as e: |
| await cursor.close() |
| raise e |
| |
| # Method 2: Fallback to individual table queries |
| logger.info(f"Using fallback method for database {db_name}") |
| return await self._get_database_table_details_fallback(connection, db_name) |
| |
| except Exception as e: |
| logger.warning(f"Failed to get table details for {db_name}: {str(e)}") |
| return [] |
| |
| async def _get_database_table_details_fallback( |
| self, |
| connection: DorisConnection, |
| db_name: str, |
| ) -> list[dict[str, Any]]: |
| """Fallback method to get table details using individual queries""" |
| try: |
| # SECURITY FIX: Validate db_name and get auth_context |
| auth_context = get_auth_context() |
| |
| try: |
| validate_identifier(db_name, "database name") |
| except SQLSecurityError as e: |
| logger.warning(f"Invalid database name rejected: {e}") |
| return [] |
| |
| # Get all tables in the database using quoted identifier |
| quoted_db = quote_identifier(db_name, "database name") |
| tables_sql = f"SHOW TABLES FROM {quoted_db}" |
| tables_result = await connection.execute(tables_sql, auth_context=auth_context) |
| |
| if not tables_result.data: |
| return [] |
| |
| table_details = [] |
| for table_row in tables_result.data: |
| table_name = table_row.get(f"Tables_in_{db_name}", "") or table_row.get("table_name", "") |
| if table_name: |
| try: |
| # SECURITY FIX: Validate table_name and use safe reference |
| validate_identifier(table_name, "table name") |
| safe_table_ref = build_table_reference(table_name, db_name) |
| data_sql = f"SHOW DATA FROM {safe_table_ref}" |
| data_result = await connection.execute(data_sql, auth_context=auth_context) |
| |
| if data_result.data: |
| for row in data_result.data: |
| if row.get("TableName") == table_name: |
| size_str = row.get("Size", "") |
| if size_str: |
| size_mb = self._parse_size_to_mb(size_str) |
| if size_mb is not None and size_mb > 0: |
| table_details.append({ |
| "schema_name": db_name, |
| "table_name": table_name, |
| "size_mb": size_mb, |
| "size_display": size_str |
| }) |
| break |
| except Exception as table_e: |
| logger.warning(f"Failed to get size for table {db_name}.{table_name}: {str(table_e)}") |
| continue |
| |
| return table_details |
| |
| except Exception as e: |
| logger.warning(f"Fallback method failed for database {db_name}: {str(e)}") |
| return [] |
| |
| def _parse_size_to_mb(self, size_str: str) -> float: |
| """Parse size string to MB""" |
| try: |
| if not size_str: |
| return 0.0 |
| |
| size_str = size_str.strip().upper() |
| if not size_str or size_str == "--" or size_str == "0.000": |
| return 0.0 |
| |
| # Extract number and unit |
| import re |
| match = re.match(r'^([\d.]+)\s*([KMGT]?B?)$', size_str) |
| if not match: |
| return 0.0 |
| |
| value = float(match.group(1)) |
| unit = match.group(2) |
| |
| # Convert to MB |
| if unit in ['B', '']: |
| return value / (1024 * 1024) |
| elif unit in ['KB', 'K']: |
| return value / 1024 |
| elif unit in ['MB', 'M']: |
| return value |
| elif unit in ['GB', 'G']: |
| return value * 1024 |
| elif unit in ['TB', 'T']: |
| return value * 1024 * 1024 |
| else: |
| return 0.0 |
| |
| except Exception as e: |
| logger.warning(f"Failed to parse size string '{size_str}': {str(e)}") |
| return 0.0 |
| |
| async def _select_high_impact_tables(self, all_tables_sizes: list[dict], target_coverage: float = 0.8) -> dict: |
| """Select high-impact tables that cover target percentage of total data""" |
| if not all_tables_sizes: |
| return {"tables": [], "total_cluster_size_mb": 0, "total_selected_size_mb": 0, "coverage_percentage": 0} |
| |
| # Filter out tables with None or invalid size_mb |
| valid_tables = [table for table in all_tables_sizes if table.get("size_mb") is not None and table.get("size_mb", 0) > 0] |
| |
| if not valid_tables: |
| return {"tables": [], "total_cluster_size_mb": 0, "total_selected_size_mb": 0, "coverage_percentage": 0} |
| |
| total_size = sum(table["size_mb"] for table in valid_tables) |
| target_size = total_size * target_coverage |
| |
| selected_tables = [] |
| selected_size = 0 |
| |
| for table in valid_tables: |
| selected_tables.append(table) |
| selected_size += table["size_mb"] |
| |
| # Stop when we reach target coverage or have enough tables for analysis |
| if selected_size >= target_size or len(selected_tables) >= 20: |
| break |
| |
| coverage_percentage = (selected_size / total_size * 100) if total_size > 0 else 0 |
| |
| return { |
| "tables": selected_tables, |
| "total_cluster_size_mb": total_size, |
| "total_selected_size_mb": selected_size, |
| "coverage_percentage": coverage_percentage |
| } |
| |
| async def _get_all_tables_info( |
| self, |
| connection: DorisConnection, |
| ) -> list[dict[str, Any]]: |
| """Get basic information for all tables (fallback method)""" |
| try: |
| auth_context = get_auth_context() |
| tables_sql = """ |
| SELECT |
| table_schema, |
| table_name, |
| table_rows, |
| data_length, |
| index_length, |
| (data_length + index_length) as total_size, |
| create_time, |
| update_time, |
| engine |
| FROM information_schema.tables |
| WHERE table_type = 'BASE TABLE' |
| AND (data_length > 0 OR table_rows > 0) |
| ORDER BY (data_length + index_length) DESC |
| """ |
| |
| result = await connection.execute(tables_sql, auth_context=auth_context) |
| return result.data if result.data else [] |
| |
| except Exception as e: |
| logger.warning(f"Failed to get tables info: {str(e)}") |
| return [] |
| |
| async def _analyze_single_table_storage_growth( |
| self, |
| connection: DorisConnection, |
| full_table_name: str, |
| table_name: str, |
| schema_name: str, |
| days: int, |
| ) -> dict[str, Any] | None: |
| """Analyze storage growth for a single table""" |
| try: |
| # Get current table size |
| current_size = await self._get_current_table_size(connection, full_table_name) |
| if not current_size: |
| return None |
| |
| # Try multiple methods to get historical data |
| historical_data = [] |
| data_source = "unknown" |
| |
| # Method 1: Partition-based historical data |
| partition_data = await self._get_partition_based_growth_data( |
| connection, table_name, schema_name, days |
| ) |
| if partition_data: |
| historical_data = partition_data |
| data_source = "partition_based" |
| |
| # Method 2: Timestamp field-based historical data |
| if not historical_data: |
| timestamp_data = await self._get_timestamp_based_growth_data( |
| connection, full_table_name, table_name, schema_name, days |
| ) |
| if timestamp_data: |
| historical_data = timestamp_data |
| data_source = "timestamp_based" |
| |
| # Method 3: Audit log-based growth estimation |
| if not historical_data: |
| audit_data = await self._get_audit_based_growth_estimation( |
| connection, table_name, days |
| ) |
| if audit_data: |
| historical_data = audit_data |
| data_source = "audit_log_based" |
| |
| # Calculate growth rate |
| growth_metrics = self._calculate_table_growth_metrics(historical_data, current_size) |
| |
| return { |
| "table_name": full_table_name, |
| "current_size_mb": current_size["size_mb"], |
| "current_rows": current_size["rows"], |
| "data_source": data_source, |
| "historical_data": historical_data, |
| "growth_metrics": growth_metrics, |
| "analysis_period_days": days |
| } |
| |
| except Exception as e: |
| logger.warning(f"Failed to analyze growth for table {full_table_name}: {str(e)}") |
| return None |
| |
| async def _get_current_table_size( |
| self, |
| connection: DorisConnection, |
| full_table_name: str, |
| ) -> dict[str, Any] | None: |
| """Get current table size""" |
| try: |
| # SECURITY FIX: Get auth_context and use parameterized query |
| auth_context = get_auth_context() |
| |
| # Extract table name for parameterized query |
| table_name_only = full_table_name.split('.')[-1] if '.' in full_table_name else full_table_name |
| |
| # Validate identifiers |
| try: |
| validate_identifier(table_name_only, "table name") |
| except SQLSecurityError as e: |
| logger.warning(f"Invalid table name rejected: {e}") |
| return None |
| |
| # Use parameterized query for safety |
| size_sql = """ |
| SELECT |
| COALESCE(ROUND((COALESCE(data_length, 0) + COALESCE(index_length, 0)) / 1024 / 1024, 2), 0) as size_mb, |
| COALESCE(table_rows, 0) as `rows` |
| FROM information_schema.tables |
| WHERE CONCAT(table_schema, '.', table_name) = %s |
| OR table_name = %s |
| """ |
| |
| result = await connection.execute(size_sql, params=(full_table_name, table_name_only), auth_context=auth_context) |
| if result.data and result.data[0]: |
| return dict(result.data[0]) |
| |
| # If information_schema has no data, try COUNT query |
| # full_table_name should already be validated by caller using build_table_reference |
| # SQL sink audit: full_table_name is produced by |
| # build_table_reference before DorisConnection.execute. |
| count_sql = ( |
| f"SELECT COUNT(*) as rows FROM {full_table_name}" # nosec B608 |
| ) |
| count_result = await connection.execute(count_sql, auth_context=auth_context) |
| if count_result.data: |
| return { |
| "size_mb": 0, # Cannot get exact size |
| "rows": count_result.data[0]["rows"] |
| } |
| |
| return None |
| |
| except SQLSecurityError as e: |
| logger.warning(f"Security validation failed for {full_table_name}: {str(e)}") |
| return None |
| except Exception as e: |
| logger.warning(f"Failed to get current size for {full_table_name}: {str(e)}") |
| return None |
| |
| async def _get_partition_based_growth_data( |
| self, |
| connection: DorisConnection, |
| table_name: str, |
| schema_name: str, |
| days: int, |
| ) -> list[dict[str, Any]]: |
| """Get historical growth data based on partitions""" |
| try: |
| # SECURITY FIX: Validate identifiers and use parameterized query |
| auth_context = get_auth_context() |
| |
| try: |
| validate_identifier(table_name, "table name") |
| if schema_name: |
| validate_identifier(schema_name, "schema name") |
| except SQLSecurityError as e: |
| logger.warning(f"Invalid identifier rejected: {e}") |
| return [] |
| |
| # Use parameterized query for safety |
| partition_sql = """ |
| SELECT |
| partition_name, |
| partition_description, |
| table_rows, |
| data_length, |
| create_time |
| FROM information_schema.partitions |
| WHERE table_schema = %s |
| AND table_name = %s |
| AND partition_name IS NOT NULL |
| AND create_time IS NOT NULL |
| AND create_time >= DATE_SUB(NOW(), INTERVAL %s DAY) |
| ORDER BY create_time DESC |
| """ |
| |
| result = await connection.execute( |
| partition_sql, |
| params=(schema_name or "", table_name, days), |
| auth_context=auth_context |
| ) |
| if not result.data: |
| return [] |
| |
| # Process partition data, aggregate by date |
| daily_data: defaultdict[str, DailyGrowthData] = defaultdict( |
| lambda: {"rows": 0, "size_mb": 0.0} |
| ) |
| |
| for partition in result.data: |
| create_date = partition["create_time"] |
| if isinstance(create_date, str): |
| create_date = datetime.fromisoformat(create_date.replace('Z', '+00:00')) |
| |
| date_key = create_date.date().isoformat() |
| table_rows = int(partition.get("table_rows", 0) or 0) |
| data_length = float(partition.get("data_length", 0) or 0) |
| daily_data[date_key]["rows"] += table_rows |
| daily_data[date_key]["size_mb"] += (data_length / 1024 / 1024) |
| |
| # Convert to list format |
| historical_data = [] |
| for date_str, data in sorted(daily_data.items()): |
| historical_data.append({ |
| "date": date_str, |
| "rows": data["rows"], |
| "size_mb": round(data["size_mb"], 2), |
| "data_source": "partition_create_time" |
| }) |
| |
| return historical_data |
| |
| except Exception as e: |
| logger.warning(f"Failed to get partition-based growth data: {str(e)}") |
| return [] |
| |
| async def _get_timestamp_based_growth_data( |
| self, |
| connection: DorisConnection, |
| full_table_name: str, |
| table_name: str, |
| schema_name: str, |
| days: int, |
| ) -> list[dict[str, Any]]: |
| """Get historical growth data based on timestamp fields""" |
| try: |
| # SECURITY FIX: Get auth_context |
| auth_context = get_auth_context() |
| |
| # Find possible timestamp fields |
| timestamp_columns = await self._find_timestamp_columns(connection, table_name, schema_name) |
| if not timestamp_columns: |
| return [] |
| |
| # Use best timestamp field for analysis |
| time_column = timestamp_columns[0] |
| |
| # SECURITY FIX: Validate time_column before using in SQL |
| try: |
| validate_identifier(time_column, "column name") |
| except SQLSecurityError as e: |
| logger.warning(f"Invalid column name rejected: {e}") |
| return [] |
| |
| quoted_time_column = quote_identifier(time_column, "column name") |
| |
| # Aggregate data by date (full_table_name should be validated by caller) |
| # SQL sink audit: metadata column -> quote_identifier; table -> |
| # build_table_reference; days -> bound param before |
| # DorisConnection.execute. |
| growth_sql = f""" |
| SELECT |
| DATE({quoted_time_column}) as date, |
| COUNT(*) as daily_records, |
| COUNT(*) / SUM(COUNT(*)) OVER() * 100 as percentage |
| FROM {full_table_name} |
| WHERE {quoted_time_column} >= DATE_SUB(NOW(), INTERVAL %s DAY) |
| AND {quoted_time_column} IS NOT NULL |
| GROUP BY DATE({quoted_time_column}) |
| ORDER BY date DESC |
| """ # nosec B608 |
| |
| result = await connection.execute(growth_sql, params=(days,), auth_context=auth_context) |
| if not result.data: |
| return [] |
| |
| # Calculate cumulative growth |
| cumulative_rows = 0 |
| historical_data = [] |
| |
| for row in reversed(result.data): # Start from earliest date |
| cumulative_rows += row["daily_records"] |
| historical_data.append({ |
| "date": row["date"].isoformat() if hasattr(row["date"], 'isoformat') else str(row["date"]), |
| "daily_records": row["daily_records"], |
| "cumulative_rows": cumulative_rows, |
| "data_source": f"timestamp_field_{time_column}" |
| }) |
| |
| return list(reversed(historical_data)) # Return with latest date first |
| |
| except Exception as e: |
| logger.warning(f"Failed to get timestamp-based growth data: {str(e)}") |
| return [] |
| |
| async def _find_timestamp_columns( |
| self, |
| connection: DorisConnection, |
| table_name: str, |
| schema_name: str, |
| ) -> list[str]: |
| """Find timestamp fields in table""" |
| try: |
| # SECURITY FIX: Validate identifiers and use parameterized query |
| auth_context = get_auth_context() |
| |
| try: |
| validate_identifier(table_name, "table name") |
| if schema_name: |
| validate_identifier(schema_name, "schema name") |
| except SQLSecurityError as e: |
| logger.warning(f"Invalid identifier rejected: {e}") |
| return [] |
| |
| timestamp_sql = """ |
| SELECT column_name, data_type |
| FROM information_schema.columns |
| WHERE table_schema = %s |
| AND table_name = %s |
| AND ( |
| data_type IN ('datetime', 'timestamp', 'date') |
| OR column_name REGEXP '(create|insert|update|modify).*time' |
| OR column_name REGEXP '.*date' |
| OR column_name REGEXP '(created|updated|modified)_(at|on)' |
| ) |
| ORDER BY |
| CASE |
| WHEN column_name REGEXP '(create|insert).*time' THEN 1 |
| WHEN column_name REGEXP 'update.*time' THEN 2 |
| WHEN data_type IN ('datetime', 'timestamp') THEN 3 |
| WHEN data_type = 'date' THEN 4 |
| ELSE 5 |
| END |
| """ |
| |
| result = await connection.execute( |
| timestamp_sql, |
| params=(schema_name or "", table_name), |
| auth_context=auth_context |
| ) |
| return [row["column_name"] for row in result.data] if result.data else [] |
| |
| except SQLSecurityError as e: |
| logger.warning(f"Security validation failed: {str(e)}") |
| return [] |
| except Exception as e: |
| logger.warning(f"Failed to find timestamp columns: {str(e)}") |
| return [] |
| |
| async def _get_audit_based_growth_estimation( |
| self, |
| connection: DorisConnection, |
| table_name: str, |
| days: int, |
| ) -> list[dict[str, Any]]: |
| """Estimate growth data based on audit logs""" |
| try: |
| # SECURITY FIX: Validate table_name and use parameterized query |
| auth_context = get_auth_context() |
| |
| try: |
| validate_identifier(table_name.split(".")[-1], "table name") |
| except SQLSecurityError as e: |
| logger.warning(f"Invalid table name rejected: {e}") |
| return [] |
| |
| # Extract just the table name for LIKE pattern |
| table_name_only = table_name.split(".")[-1] |
| like_pattern_full = f"%{table_name}%" |
| like_pattern_short = f"%{table_name_only}%" |
| |
| # Analyze operation history for this table |
| audit_sql = """ |
| SELECT |
| DATE(`time`) as operation_date, |
| COUNT(*) as operation_count, |
| SUM(CASE WHEN stmt LIKE 'INSERT%' THEN 1 ELSE 0 END) as insert_count, |
| SUM(CASE WHEN stmt LIKE 'UPDATE%' THEN 1 ELSE 0 END) as update_count, |
| SUM(CASE WHEN stmt LIKE 'DELETE%' THEN 1 ELSE 0 END) as delete_count |
| FROM internal.__internal_schema.audit_log |
| WHERE `time` >= DATE_SUB(NOW(), INTERVAL %s DAY) |
| AND stmt IS NOT NULL |
| AND ( |
| stmt LIKE %s |
| OR stmt LIKE %s |
| ) |
| GROUP BY DATE(`time`) |
| ORDER BY operation_date DESC |
| """ |
| |
| result = await connection.execute( |
| audit_sql, |
| params=(days, like_pattern_full, like_pattern_short), |
| auth_context=auth_context |
| ) |
| if not result.data: |
| return [] |
| |
| # Estimate growth based on operation patterns |
| historical_data = [] |
| for row in result.data: |
| # Simple growth estimation: INSERT operations indicate data growth |
| estimated_growth = row["insert_count"] * 1000 # Assume each INSERT operation inserts 1000 rows on average |
| |
| historical_data.append({ |
| "date": row["operation_date"].isoformat() if hasattr(row["operation_date"], 'isoformat') else str(row["operation_date"]), |
| "operation_count": row["operation_count"], |
| "insert_operations": row["insert_count"], |
| "estimated_records_added": estimated_growth, |
| "data_source": "audit_log_estimation" |
| }) |
| |
| return historical_data |
| |
| except Exception as e: |
| logger.warning(f"Failed to get audit-based growth estimation: {str(e)}") |
| return [] |
| |
| def _calculate_table_growth_metrics(self, historical_data: list[dict], current_size: dict) -> dict[str, Any]: |
| """Calculate table growth metrics""" |
| if not historical_data or len(historical_data) < 2: |
| return { |
| "growth_rate_mb_per_day": 0, |
| "growth_rate_rows_per_day": 0, |
| "growth_trend": "insufficient_data", |
| "confidence": "low" |
| } |
| |
| try: |
| # Extract numerical data |
| dates = [] |
| sizes = [] |
| rows = [] |
| |
| for data_point in historical_data: |
| try: |
| date_obj = datetime.fromisoformat(data_point["date"]) |
| dates.append(date_obj) |
| |
| # Handle fields from different data sources |
| if "size_mb" in data_point: |
| sizes.append(data_point["size_mb"]) |
| if "cumulative_rows" in data_point: |
| rows.append(data_point["cumulative_rows"]) |
| elif "rows" in data_point: |
| rows.append(data_point["rows"]) |
| elif "estimated_records_added" in data_point: |
| rows.append(data_point["estimated_records_added"]) |
| |
| except (ValueError, KeyError): |
| continue |
| |
| if len(dates) < 2: |
| return {"growth_rate_mb_per_day": 0, "growth_rate_rows_per_day": 0, "growth_trend": "insufficient_data"} |
| |
| # Calculate time span (days) |
| time_span_days = (max(dates) - min(dates)).days |
| if time_span_days == 0: |
| time_span_days = 1 |
| |
| # Calculate growth rate |
| growth_metrics = {} |
| |
| # Size growth rate |
| if len(sizes) >= 2: |
| size_growth = (max(sizes) - min(sizes)) / time_span_days |
| growth_metrics["growth_rate_mb_per_day"] = round(size_growth, 4) |
| else: |
| growth_metrics["growth_rate_mb_per_day"] = 0 |
| |
| # Row count growth rate |
| if len(rows) >= 2: |
| rows_growth = (max(rows) - min(rows)) / time_span_days |
| growth_metrics["growth_rate_rows_per_day"] = round(rows_growth, 2) |
| else: |
| growth_metrics["growth_rate_rows_per_day"] = 0 |
| |
| # Growth trend analysis |
| if len(historical_data) >= 3: |
| # Use linear regression to analyze trends |
| growth_metrics["growth_trend"] = self._analyze_growth_trend(dates, sizes if sizes else rows) |
| growth_metrics["confidence"] = "high" if len(historical_data) >= 7 else "medium" |
| else: |
| growth_metrics["growth_trend"] = "stable" |
| growth_metrics["confidence"] = "low" |
| |
| return growth_metrics |
| |
| except Exception as e: |
| logger.warning(f"Failed to calculate growth metrics: {str(e)}") |
| return {"growth_rate_mb_per_day": 0, "growth_rate_rows_per_day": 0, "growth_trend": "error"} |
| |
| def _analyze_growth_trend(self, dates: list[datetime], values: list[float]) -> str: |
| """Analyze growth trend""" |
| if len(dates) != len(values) or len(values) < 3: |
| return "unknown" |
| |
| try: |
| # Convert dates to numerical values (days) |
| base_date = min(dates) |
| x_values = [(date - base_date).days for date in dates] |
| |
| # Simple linear regression |
| n = len(x_values) |
| sum_x = sum(x_values) |
| sum_y = sum(values) |
| sum_xy = sum(x * y for x, y in zip(x_values, values, strict=False)) |
| sum_x2 = sum(x * x for x in x_values) |
| |
| # Calculate slope |
| denominator = n * sum_x2 - sum_x * sum_x |
| if denominator == 0: |
| return "stable" |
| |
| slope = (n * sum_xy - sum_x * sum_y) / denominator |
| |
| # Determine trend |
| if slope > 0.1: |
| return "increasing" |
| elif slope < -0.1: |
| return "decreasing" |
| else: |
| return "stable" |
| |
| except Exception: |
| return "unknown" |
| |
| async def _calculate_overall_storage_growth(self, table_growth_data: list[dict], days: int) -> dict[str, Any]: |
| """Calculate overall storage growth""" |
| if not table_growth_data: |
| return {"error": "No table growth data available"} |
| |
| try: |
| # Aggregate growth data from all tables |
| total_growth_mb_per_day = sum( |
| table.get("growth_metrics", {}).get("growth_rate_mb_per_day", 0) |
| for table in table_growth_data |
| ) |
| |
| total_growth_rows_per_day = sum( |
| table.get("growth_metrics", {}).get("growth_rate_rows_per_day", 0) |
| for table in table_growth_data |
| ) |
| |
| # Calculate growth trend distribution |
| trend_counts = Counter( |
| table.get("growth_metrics", {}).get("growth_trend", "unknown") |
| for table in table_growth_data |
| ) |
| |
| # Calculate confidence level |
| high_confidence_tables = sum( |
| 1 for table in table_growth_data |
| if table.get("growth_metrics", {}).get("confidence") == "high" |
| ) |
| |
| overall_confidence = "high" if high_confidence_tables > len(table_growth_data) * 0.5 else "medium" |
| |
| return { |
| "daily_growth_mb": round(total_growth_mb_per_day, 2), |
| "daily_growth_rows": round(total_growth_rows_per_day, 2), |
| "monthly_growth_mb": round(total_growth_mb_per_day * 30, 2), |
| "monthly_growth_rows": round(total_growth_rows_per_day * 30, 2), |
| "trend_distribution": dict(trend_counts), |
| "overall_trend": trend_counts.most_common(1)[0][0] if trend_counts else "unknown", |
| "confidence_level": overall_confidence, |
| "analysis_method": "aggregated_real_data" |
| } |
| |
| except Exception as e: |
| logger.error(f"Failed to calculate overall storage growth: {str(e)}") |
| return {"error": str(e)} |
| |
| def _calculate_storage_confidence_level(self, table_growth_data: list[dict]) -> str: |
| """Calculate confidence level for storage analysis""" |
| if not table_growth_data: |
| return "none" |
| |
| # Count tables with historical data |
| tables_with_data = sum( |
| 1 for table in table_growth_data |
| if table.get("historical_data") and len(table.get("historical_data", [])) > 0 |
| ) |
| |
| # Count tables with high confidence |
| high_confidence_tables = sum( |
| 1 for table in table_growth_data |
| if table.get("growth_metrics", {}).get("confidence") == "high" |
| ) |
| |
| total_tables = len(table_growth_data) |
| |
| if high_confidence_tables > total_tables * 0.7: |
| return "high" |
| elif tables_with_data > total_tables * 0.5: |
| return "medium" |
| else: |
| return "low" |
| |
| async def _generate_enhanced_growth_insights(self, resource_analysis: dict, days: int) -> dict[str, Any]: |
| """Generate enhanced growth insights""" |
| insights = {} |
| |
| # Storage insights |
| if "storage" in resource_analysis: |
| storage = resource_analysis["storage"] |
| if "overall_growth_metrics" in storage: |
| metrics = storage["overall_growth_metrics"] |
| insights["storage"] = { |
| "current_status": f"Total storage: {storage.get('current_total_storage_mb', 0):.2f} MB", |
| "growth_rate": f"Daily growth: {metrics.get('daily_growth_mb', 0):.2f} MB", |
| "monthly_projection": f"Monthly growth estimate: {metrics.get('monthly_growth_mb', 0):.2f} MB", |
| "trend": metrics.get("overall_trend", "unknown"), |
| "confidence": metrics.get("confidence_level", "unknown"), |
| "analysis_quality": f"Based on real historical data from {storage.get('total_tables_analyzed', 0)} tables" |
| } |
| |
| # Query volume insights (keep original logic as it's already based on real data) |
| if "query_volume" in resource_analysis: |
| query_vol = resource_analysis["query_volume"] |
| insights["query_volume"] = { |
| "avg_daily_queries": query_vol.get("avg_daily_queries", 0), |
| "trend": query_vol.get("trend", "stable"), |
| "analysis_period": f"{days} days of historical data" |
| } |
| |
| # User activity insights (keep original logic) |
| if "user_activity" in resource_analysis: |
| user_activity = resource_analysis["user_activity"] |
| insights["user_activity"] = { |
| "avg_daily_users": user_activity.get("avg_daily_active_users", 0), |
| "max_daily_users": user_activity.get("max_daily_users", 0), |
| "analysis_period": f"{days} days of historical data" |
| } |
| |
| return insights |
| |
| async def _generate_statistical_growth_predictions(self, resource_analysis: dict, days: int) -> dict[str, Any]: |
| """Generate growth predictions based on statistical methods""" |
| predictions = {} |
| |
| try: |
| # Storage predictions |
| if "storage" in resource_analysis: |
| storage = resource_analysis["storage"] |
| if "overall_growth_metrics" in storage: |
| metrics = storage["overall_growth_metrics"] |
| daily_growth = metrics.get("daily_growth_mb", 0) |
| confidence = metrics.get("confidence_level", "low") |
| |
| # Make predictions based on real growth rates |
| predictions["storage"] = { |
| "next_30_days_mb": round(daily_growth * 30, 2), |
| "next_90_days_mb": round(daily_growth * 90, 2), |
| "next_365_days_mb": round(daily_growth * 365, 2), |
| "prediction_method": "linear_extrapolation_from_real_data", |
| "confidence": confidence, |
| "warning": "Predictions based on historical trends, actual growth may vary due to business changes" |
| } |
| |
| # Query volume predictions |
| if "query_volume" in resource_analysis: |
| query_vol = resource_analysis["query_volume"] |
| avg_queries = query_vol.get("avg_daily_queries", 0) |
| trend = query_vol.get("trend", "stable") |
| |
| # Adjust predictions based on trends |
| growth_factor = 1.0 |
| if trend == "increasing": |
| growth_factor = 1.1 # 10% growth |
| elif trend == "decreasing": |
| growth_factor = 0.9 # 10% decline |
| |
| predictions["query_volume"] = { |
| "next_30_days_avg": round(avg_queries * growth_factor, 2), |
| "prediction_method": "trend_based_extrapolation", |
| "confidence": "medium" |
| } |
| |
| return predictions |
| |
| except Exception as e: |
| logger.error(f"Failed to generate statistical predictions: {str(e)}") |
| return {"error": str(e)} |
| |
| def _generate_enhanced_growth_recommendations(self, growth_insights: dict, predictions: dict) -> list[dict]: |
| """Generate enhanced growth recommendations""" |
| recommendations = [] |
| |
| try: |
| # Storage-related recommendations |
| if "storage" in growth_insights: |
| storage_insight = growth_insights["storage"] |
| confidence = storage_insight.get("confidence", "low") |
| trend = storage_insight.get("trend", "unknown") |
| |
| if confidence == "high" and trend == "increasing": |
| recommendations.append({ |
| "category": "storage_capacity", |
| "priority": "high", |
| "title": "Storage Capacity Planning", |
| "description": "Based on real historical data analysis, storage shows significant growth trend", |
| "action": "Recommend advance storage expansion planning, consider data archiving strategies" |
| }) |
| |
| if "storage" in predictions: |
| storage_pred = predictions["storage"] |
| yearly_growth = storage_pred.get("next_365_days_mb", 0) |
| if yearly_growth > 100000: # 100GB |
| recommendations.append({ |
| "category": "storage_optimization", |
| "priority": "medium", |
| "title": "Data Compression Optimization", |
| "description": f"Expected annual growth {yearly_growth/1024:.1f} GB", |
| "action": "Consider enabling data compression and optimizing storage formats" |
| }) |
| |
| # Data quality recommendations |
| recommendations.append({ |
| "category": "data_monitoring", |
| "priority": "medium", |
| "title": "Continuous Monitoring", |
| "description": "Establish growth monitoring system based on real historical data", |
| "action": "Regularly analyze partition growth and timestamp field distribution, detect abnormal growth promptly" |
| }) |
| |
| return recommendations |
| |
| except Exception as e: |
| logger.error(f"Failed to generate enhanced recommendations: {str(e)}") |
| return [] |
| |
| def _generate_performance_recommendations(self, performance_insights: dict, pattern_analysis: dict) -> list[dict]: |
| """Generate performance improvement recommendations""" |
| recommendations = [] |
| |
| # Execution time recommendations |
| exec_stats = performance_insights.get("execution_time_stats", {}) |
| avg_time = exec_stats.get("avg_ms", 0) |
| |
| if avg_time > 30000: # > 30 seconds |
| recommendations.append({ |
| "type": "query_optimization", |
| "priority": "high", |
| "title": "High average query execution time", |
| "description": f"Average slow query time is {avg_time/1000:.1f} seconds", |
| "action": "Review and optimize slowest queries, consider indexing strategies" |
| }) |
| |
| # Pattern-based recommendations |
| if pattern_analysis: |
| common_issues = pattern_analysis.get("common_performance_issues", {}) |
| |
| if common_issues.get("select_all_columns", 0) > 5: |
| recommendations.append({ |
| "type": "query_best_practices", |
| "priority": "medium", |
| "title": "Frequent SELECT * usage detected", |
| "description": "Many queries use SELECT * which can impact performance", |
| "action": "Replace SELECT * with specific column names in queries" |
| }) |
| |
| if common_issues.get("large_data_scan", 0) > 3: |
| recommendations.append({ |
| "type": "data_access_optimization", |
| "priority": "high", |
| "title": "Large data scans detected", |
| "description": "Multiple queries are scanning large amounts of data", |
| "action": "Review partitioning strategies and add appropriate indexes" |
| }) |
| |
| return recommendations |