diff --git a/README.md b/README.md index 4318d1c..f4fe872 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ RushTI transforms sequential TurboIntegrator execution into intelligent, paralle - **Self-Optimization** — EWMA-based learning reorders tasks from historical performance - **Checkpoint & Resume** — Automatic progress saving with failure recovery - **Exclusive Mode** — Prevents concurrent runs on shared TM1 servers -- **SQLite Statistics** — Persistent execution history with dashboards and analysis +- **Statistics Storage (SQLite or DynamoDB)** — Persistent execution history with dashboards and analysis - **TM1 Integration** — Read tasks from and write results to a TM1 cube - **100% Backwards Compatible** — Legacy TXT task files work without changes diff --git a/config/settings.ini.template b/config/settings.ini.template index da86b4d..284e0cd 100644 --- a/config/settings.ini.template +++ b/config/settings.ini.template @@ -196,23 +196,43 @@ # auto_resume = false # ------------------------------------------------------------------------------ -# [stats] - SQLite stats database for execution history +# [stats] - Execution history storage (SQLite or DynamoDB) # ------------------------------------------------------------------------------ [stats] # Enable the stats database for storing execution history -# The stats database stores execution statistics for: +# Stats storage stores execution statistics for: # - Optimization features (EWMA runtime estimation) # - TM1 cube logging data source # - Historical analysis via 'rushti db' commands # Default: false # enabled = false +# Storage backend: sqlite or dynamodb +# Default: sqlite +# backend = sqlite + # Path to the SQLite database file # Relative paths are resolved from the application directory # Default: data/rushti_stats.db # db_path = data/rushti_stats.db +# AWS region for DynamoDB backend (required when backend = dynamodb) +# Example: eu-west-1 +# dynamodb_region = eu-west-1 + +# DynamoDB runs table name (backend = dynamodb) +# Default: rushti_runs +# dynamodb_runs_table = rushti_runs + +# DynamoDB task results table name (backend = dynamodb) +# Default: rushti_task_results +# dynamodb_task_results_table = rushti_task_results + +# Optional custom DynamoDB endpoint URL (for LocalStack/testing) +# Example: http://localhost:4566 +# dynamodb_endpoint_url = + # Number of days to retain execution history # Valid range: 1-365 # Default: 90 diff --git a/docs/advanced/settings-reference.md b/docs/advanced/settings-reference.md index bd35806..46dc01f 100644 --- a/docs/advanced/settings-reference.md +++ b/docs/advanced/settings-reference.md @@ -184,12 +184,22 @@ Controls checkpoint saving and resume capability. When enabled, RushTI periodica ### [stats] -Controls the SQLite statistics database that stores execution history. The stats database powers several features: EWMA optimization, the `rushti stats` commands, dashboard visualization, and historical analysis. +Controls stats storage for execution history. RushTI supports two backends: +- `sqlite` (default): local file storage +- `dynamodb`: AWS DynamoDB tables + +Stats storage powers several features: EWMA optimization, the `rushti stats` commands, dashboard visualization, and historical analysis. | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `enabled` | bool | `false` | Enable the SQLite stats database. When enabled, every run records task-level execution data (timing, status, errors). | -| `retention_days` | int | `90` | Days to keep execution history. Records older than this value are deleted at startup. Valid range: 1--365. Use `0` to keep data indefinitely. | +| `enabled` | bool | `false` | Enable stats storage. When enabled, every run records task-level execution data (timing, status, errors). | +| `backend` | str | `sqlite` | Storage backend: `sqlite` or `dynamodb`. | +| `db_path` | str | `data/rushti_stats.db` | SQLite file path (used when `backend = sqlite`). | +| `dynamodb_region` | str | `` | AWS region for DynamoDB (used when `backend = dynamodb`). | +| `dynamodb_runs_table` | str | `rushti_runs` | DynamoDB table name for run-level records. | +| `dynamodb_task_results_table` | str | `rushti_task_results` | DynamoDB table name for task-level records. | +| `dynamodb_endpoint_url` | str | `` | Optional custom endpoint URL (for local testing tools such as LocalStack). | +| `retention_days` | int | `90` | Days to keep execution history. Records older than this value are deleted at startup. Valid range: 1-365. Use `0` to keep data indefinitely. | **Required by:** `[optimization]`, `rushti stats` commands, `rushti stats visualize` @@ -353,7 +363,7 @@ Copy this template to `config/settings.ini` and uncomment the settings you want # auto_resume = false # ------------------------------------------------------------------------------ -# [stats] - SQLite stats database for execution history +# [stats] - Execution history storage (SQLite or DynamoDB) # ------------------------------------------------------------------------------ [stats] @@ -365,11 +375,31 @@ Copy this template to `config/settings.ini` and uncomment the settings you want # Default: false # enabled = false +# Storage backend: sqlite or dynamodb +# Default: sqlite +# backend = sqlite + # Path to the SQLite database file # Relative paths are resolved from the application directory # Default: data/rushti_stats.db # db_path = data/rushti_stats.db +# AWS region for DynamoDB backend (required when backend = dynamodb) +# Example: eu-west-1 +# dynamodb_region = eu-west-1 + +# DynamoDB runs table name (backend = dynamodb) +# Default: rushti_runs +# dynamodb_runs_table = rushti_runs + +# DynamoDB task results table name (backend = dynamodb) +# Default: rushti_task_results +# dynamodb_task_results_table = rushti_task_results + +# Optional custom DynamoDB endpoint URL (for LocalStack/testing) +# Example: http://localhost:4566 +# dynamodb_endpoint_url = + # Number of days to retain execution history # Valid range: 1-365 # Default: 90 diff --git a/pyproject.toml b/pyproject.toml index 3f1984d..72026f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,9 @@ dependencies = [ ] [project.optional-dependencies] +dynamodb = [ + "boto3>=1.34.0", +] dev = [ "pytest>=7.0.0", "pytest-asyncio", diff --git a/src/rushti/cli.py b/src/rushti/cli.py index 5036ca7..6b0d568 100644 --- a/src/rushti/cli.py +++ b/src/rushti/cli.py @@ -960,12 +960,17 @@ def main() -> int: results = list() # Initialize stats database if enabled - from rushti.stats import get_db_path + from rushti.stats import get_db_path, get_stats_backend db_kwargs = dict( enabled=settings.stats.enabled, retention_days=settings.stats.retention_days, + backend=get_stats_backend(settings), db_path=get_db_path(settings), + dynamodb_region=settings.stats.dynamodb_region or None, + dynamodb_runs_table=settings.stats.dynamodb_runs_table, + dynamodb_task_results_table=settings.stats.dynamodb_task_results_table, + dynamodb_endpoint_url=settings.stats.dynamodb_endpoint_url or None, ) ctx = ExecutionContext( stats_db=create_stats_database(**db_kwargs), diff --git a/src/rushti/commands.py b/src/rushti/commands.py index e497d50..e28a615 100644 --- a/src/rushti/commands.py +++ b/src/rushti/commands.py @@ -1149,11 +1149,18 @@ def _stats_export(args) -> None: # Import here to avoid circular imports from rushti.tm1_integration import export_results_to_csv + from rushti.stats import get_db_path # Create stats database connection stats_db = create_stats_database( enabled=True, retention_days=settings.stats.retention_days, + backend=settings.stats.backend, + db_path=get_db_path(settings), + dynamodb_region=settings.stats.dynamodb_region or None, + dynamodb_runs_table=settings.stats.dynamodb_runs_table, + dynamodb_task_results_table=settings.stats.dynamodb_task_results_table, + dynamodb_endpoint_url=settings.stats.dynamodb_endpoint_url or None, ) try: @@ -1195,12 +1202,21 @@ def _stats_analyze(args) -> None: try: # Load settings from rushti.settings import load_settings - from rushti.stats import StatsDatabase, get_db_path + from rushti.stats import create_stats_database, get_db_path settings = load_settings(args.settings_file) # Initialize stats database - stats_db = StatsDatabase(db_path=get_db_path(settings), enabled=settings.stats.enabled) + stats_db = create_stats_database( + enabled=settings.stats.enabled, + retention_days=settings.stats.retention_days, + backend=settings.stats.backend, + db_path=get_db_path(settings), + dynamodb_region=settings.stats.dynamodb_region or None, + dynamodb_runs_table=settings.stats.dynamodb_runs_table, + dynamodb_task_results_table=settings.stats.dynamodb_task_results_table, + dynamodb_endpoint_url=settings.stats.dynamodb_endpoint_url or None, + ) # Run analysis report = analyze_runs( @@ -1276,7 +1292,7 @@ def _stats_optimize(args) -> None: write_optimized_taskfile, ) from rushti.settings import load_settings - from rushti.stats import StatsDatabase, get_db_path + from rushti.stats import create_stats_database, get_db_path from rushti.utils import resolve_app_path settings = load_settings(args.settings_file) @@ -1287,7 +1303,16 @@ def _stats_optimize(args) -> None: sys.exit(1) # Initialize stats database - stats_db = StatsDatabase(db_path=get_db_path(settings), enabled=True) + stats_db = create_stats_database( + enabled=True, + retention_days=settings.stats.retention_days, + backend=settings.stats.backend, + db_path=get_db_path(settings), + dynamodb_region=settings.stats.dynamodb_region or None, + dynamodb_runs_table=settings.stats.dynamodb_runs_table, + dynamodb_task_results_table=settings.stats.dynamodb_task_results_table, + dynamodb_endpoint_url=settings.stats.dynamodb_endpoint_url or None, + ) try: # Resolve taskfile: explicit --tasks flag, or auto-resolve from archive @@ -1592,16 +1617,31 @@ def _stats_visualize(args) -> None: from rushti.dashboard import generate_dashboard from rushti.db_admin import get_visualization_data from rushti.settings import load_settings - from rushti.stats import get_db_path + from rushti.stats import create_stats_database, get_db_path, get_stats_backend from rushti.utils import resolve_app_path settings = load_settings(getattr(args, "settings_file", None)) + backend = get_stats_backend(settings) db_path = get_db_path(settings) + stats_db = None try: - print(f"Generating visualizations for workflow: {args.workflow}") + stats_db = create_stats_database( + enabled=True, + backend=backend, + db_path=db_path, + dynamodb_region=settings.stats.dynamodb_region or None, + dynamodb_runs_table=settings.stats.dynamodb_runs_table, + dynamodb_task_results_table=settings.stats.dynamodb_task_results_table, + dynamodb_endpoint_url=settings.stats.dynamodb_endpoint_url or None, + ) + data = get_visualization_data( + args.workflow, + stats_db, + include_all_workflows=True, + ) - data = get_visualization_data(args.workflow, db_path) + print(f"Generating visualizations for workflow: {args.workflow}") if not data.get("exists"): print(f"Error: {data.get('message')}") sys.exit(1) @@ -1624,37 +1664,64 @@ def _stats_visualize(args) -> None: # --- Attempt DAG generation --- dag_generated = False - taskfile_path = None - # Find the first accessible taskfile_path from runs (most recent first) + # Prefer DB-based DAG (no taskfile on disk needed); fall back to taskfile if unavailable. runs = data["runs"] - for run in runs: - candidate = run.get("taskfile_path") - if candidate and not candidate.startswith("TM1:") and os.path.isfile(candidate): - taskfile_path = candidate - break + workflow_lower = args.workflow.lower() + workflow_runs = [r for r in runs if (r.get("workflow") or "").lower() == workflow_lower] + latest_run = workflow_runs[0] if workflow_runs else None - if taskfile_path: + dag_generated_from_db = False + if latest_run: try: - from rushti.taskfile_ops import visualize_dag - - # Ensure output directory exists - Path(dag_path).parent.mkdir(parents=True, exist_ok=True) - - visualize_dag( - source=taskfile_path, - output_path=dag_path, - dashboard_url=dashboard_filename, - ) - dag_generated = True - print(f"DAG visualization generated: {dag_path}") + from rushti.taskfile_ops import visualize_dag_from_db_results + + latest_run_id = latest_run["run_id"] + latest_task_results = [ + tr for tr in data["task_results"] if tr["run_id"] == latest_run_id + ] + + if latest_task_results: + Path(dag_path).parent.mkdir(parents=True, exist_ok=True) + visualize_dag_from_db_results( + task_results=latest_task_results, + output_path=dag_path, + dashboard_url=dashboard_filename, + ) + dag_generated = True + dag_generated_from_db = True + print(f"DAG visualization generated: {dag_path}") except Exception as e: - logger.warning(f"Could not generate DAG visualization: {e}") - print(f"Warning: DAG visualization skipped ({e})") - else: - print( - "Warning: No accessible taskfile found in run history, skipping DAG visualization" - ) + logger.warning(f"Could not generate DAG from DB: {e}") + + if not dag_generated_from_db: + # Fall back to taskfile on disk (most recent accessible one for this workflow) + taskfile_path = None + for run in workflow_runs: + candidate = run.get("taskfile_path") + if candidate and not candidate.startswith("TM1:") and os.path.isfile(candidate): + taskfile_path = candidate + break + + if taskfile_path: + try: + from rushti.taskfile_ops import visualize_dag + + Path(dag_path).parent.mkdir(parents=True, exist_ok=True) + visualize_dag( + source=taskfile_path, + output_path=dag_path, + dashboard_url=dashboard_filename, + ) + dag_generated = True + print(f"DAG visualization generated from taskfile: {dag_path}") + except Exception as e: + logger.warning(f"Could not generate DAG visualization: {e}") + print(f"Warning: DAG visualization skipped ({e})") + else: + print( + "Warning: No DB task results or accessible taskfile found, skipping DAG visualization" + ) # --- Generate dashboard --- output_file = generate_dashboard( @@ -1682,6 +1749,9 @@ def _stats_visualize(args) -> None: traceback.print_exc() sys.exit(1) + finally: + if stats_db is not None: + stats_db.close() def _stats_list(args) -> None: @@ -1693,14 +1763,26 @@ def _stats_list(args) -> None: """ from rushti.db_admin import list_runs, list_tasks from rushti.settings import load_settings - from rushti.stats import get_db_path + from rushti.stats import create_stats_database, get_db_path, get_stats_backend settings = load_settings(getattr(args, "settings_file", None)) + backend = get_stats_backend(settings) db_path = get_db_path(settings) + stats_db = None try: + stats_db = create_stats_database( + enabled=True, + backend=backend, + db_path=db_path, + dynamodb_region=settings.stats.dynamodb_region or None, + dynamodb_runs_table=settings.stats.dynamodb_runs_table, + dynamodb_task_results_table=settings.stats.dynamodb_task_results_table, + dynamodb_endpoint_url=settings.stats.dynamodb_endpoint_url or None, + ) + if args.list_type == "runs": - runs = list_runs(args.workflow, db_path, limit=args.limit) + runs = list_runs(args.workflow, stats_db, limit=args.limit) if not runs: print(f"No runs found for workflow: {args.workflow}") sys.exit(0) @@ -1719,7 +1801,7 @@ def _stats_list(args) -> None: ) elif args.list_type == "tasks": - tasks = list_tasks(args.workflow, db_path) + tasks = list_tasks(args.workflow, stats_db) if not tasks: print(f"No tasks found for workflow: {args.workflow}") sys.exit(0) @@ -1747,6 +1829,9 @@ def _stats_list(args) -> None: except Exception as e: print(f"Error: {e}") sys.exit(1) + finally: + if stats_db is not None: + stats_db.close() def run_db_command(argv: list) -> None: @@ -1778,7 +1863,7 @@ def run_db_command(argv: list) -> None: show_task_history, ) from rushti.settings import load_settings - from rushti.stats import get_db_path + from rushti.stats import get_db_path, get_stats_backend # Check for help flag or no subcommand if len(argv) < 3 or (len(argv) == 3 and argv[2] in ("--help", "-h")): @@ -1848,6 +1933,9 @@ def run_db_command(argv: list) -> None: # Get database path from settings settings = load_settings(args.settings_file) + if get_stats_backend(settings) != "sqlite": + print("Error: 'db' command currently supports only [stats] backend = sqlite") + sys.exit(1) db_path = get_db_path(settings) try: diff --git a/src/rushti/contention_analyzer.py b/src/rushti/contention_analyzer.py index 06f6a99..4ae0d6a 100644 --- a/src/rushti/contention_analyzer.py +++ b/src/rushti/contention_analyzer.py @@ -19,10 +19,15 @@ import math from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union from rushti.stats import StatsDatabase +if TYPE_CHECKING: + from rushti.stats import DynamoDBStatsDatabase + +AnyStatsDatabase = Union[StatsDatabase, "DynamoDBStatsDatabase"] + logger = logging.getLogger(__name__) @@ -81,7 +86,7 @@ def light_task_count(self) -> int: def _compute_ewma_durations( - stats_db: StatsDatabase, + stats_db: AnyStatsDatabase, workflow: str, lookback_runs: int = 10, alpha: float = 0.3, @@ -119,7 +124,7 @@ def _compute_ewma_durations( def _get_task_parameters( - stats_db: StatsDatabase, + stats_db: AnyStatsDatabase, workflow: str, ) -> List[Dict[str, Any]]: """Get task_id, task_signature, process, and parameters for the most recent run. @@ -128,29 +133,33 @@ def _get_task_parameters( :param workflow: Workflow name :return: List of dicts with task_id, task_signature, process, parameters (parsed) """ - cursor = stats_db._conn.cursor() - cursor.execute( - """ - SELECT task_id, task_signature, process, parameters - FROM task_results - WHERE run_id = ( - SELECT run_id FROM runs - WHERE workflow = ? AND status = 'Success' - ORDER BY start_time DESC LIMIT 1 - ) - ORDER BY CAST(task_id AS INTEGER) - """, - (workflow,), - ) + runs = stats_db.get_runs_for_workflow(workflow) + successful_run = next((r for r in runs if r.get("status") == "Success"), None) + if not successful_run: + return [] + + run_id = successful_run.get("run_id") + if not run_id: + return [] + + run_results = stats_db.get_run_results(run_id) + + def _task_sort_key(result: Dict[str, Any]) -> Any: + task_id = str(result.get("task_id", "")) + if task_id.isdigit(): + return int(task_id) + return task_id + + run_results.sort(key=_task_sort_key) results = [] - for row in cursor.fetchall(): - params = json.loads(row["parameters"]) if row["parameters"] else {} + for row in run_results: + params = json.loads(row["parameters"]) if row.get("parameters") else {} results.append( { - "task_id": row["task_id"], - "task_signature": row["task_signature"], - "process": row["process"], + "task_id": row.get("task_id"), + "task_signature": row.get("task_signature"), + "process": row.get("process"), "parameters": params, } ) @@ -429,7 +438,7 @@ def _round_to_5(value: float) -> int: def _detect_concurrency_ceiling( - stats_db: StatsDatabase, + stats_db: AnyStatsDatabase, workflow: str, min_correlation: float = 0.7, max_efficiency_ratio: float = 0.75, @@ -655,7 +664,7 @@ def _detect_concurrency_ceiling( def analyze_contention( - stats_db: StatsDatabase, + stats_db: AnyStatsDatabase, workflow: str, task_params: Optional[List[Dict[str, Any]]] = None, sensitivity: float = 10.0, @@ -870,7 +879,7 @@ def analyze_contention( def get_archived_taskfile_path( - stats_db: StatsDatabase, + stats_db: AnyStatsDatabase, workflow: str, ) -> Optional[str]: """Get the archived taskfile path from the most recent successful run. @@ -883,18 +892,14 @@ def get_archived_taskfile_path( :param workflow: Workflow name :return: Path to archived JSON taskfile, or None if no successful runs exist """ - cursor = stats_db._conn.cursor() - cursor.execute( - """ - SELECT taskfile_path FROM runs - WHERE workflow = ? AND status = 'Success' - ORDER BY start_time DESC LIMIT 1 - """, - (workflow,), - ) - row = cursor.fetchone() - if row and row["taskfile_path"]: - return row["taskfile_path"] + runs = stats_db.get_runs_for_workflow(workflow) + successful_run = next((r for r in runs if r.get("status") == "Success"), None) + if successful_run: + run_id = successful_run.get("run_id") + if run_id: + run_info = stats_db.get_run_info(run_id) + if run_info and run_info.get("taskfile_path"): + return run_info["taskfile_path"] return None diff --git a/src/rushti/dashboard.py b/src/rushti/dashboard.py index 521f5b2..e46a11b 100644 --- a/src/rushti/dashboard.py +++ b/src/rushti/dashboard.py @@ -195,6 +195,7 @@ def _prepare_dashboard_data( runs: List[Dict[str, Any]], task_results: List[Dict[str, Any]], default_runs: int, + selected_workflow: Optional[str] = None, ) -> Dict[str, Any]: """Prepare all data for the dashboard template. @@ -211,6 +212,10 @@ def _prepare_dashboard_data( tasks_by_run[run_id] = [] tasks_by_run[run_id].append(tr) + # Normalize workflow names to lowercase for case-insensitive aggregation + if selected_workflow is not None: + selected_workflow = selected_workflow.lower() + # Build enriched run data enriched_runs = [] for run in runs: @@ -221,6 +226,7 @@ def _prepare_dashboard_data( enriched_runs.append( { **run, + "workflow": (run.get("workflow") or "").lower(), "stats": run_stats, "concurrency": concurrency, "task_count_actual": len(run_tasks), @@ -330,16 +336,38 @@ def _prepare_dashboard_data( } ) - # Taskfile metadata from the most recent run - latest = runs[0] if runs else {} + # Workflow metadata from the most recent run per workflow. + workflow_meta: Dict[str, Dict[str, Any]] = {} + for run in enriched_runs: + wf = run.get("workflow") or "" + if wf not in workflow_meta: + workflow_meta[wf] = { + "taskfile_name": run.get("taskfile_name", ""), + "taskfile_description": run.get("taskfile_description", ""), + "taskfile_author": run.get("taskfile_author", ""), + "run_count": 0, + } + workflow_meta[wf]["run_count"] += 1 + + workflows = list(workflow_meta.keys()) + effective_workflow = selected_workflow if selected_workflow in workflow_meta else "" + if not effective_workflow and workflows: + effective_workflow = workflows[0] + + workflow_run_count = workflow_meta.get(effective_workflow, {}).get("run_count", 0) + effective_default_runs = min(default_runs, workflow_run_count) return { - "workflow": latest.get("workflow", ""), - "taskfile_name": latest.get("taskfile_name", ""), - "taskfile_description": latest.get("taskfile_description", ""), - "taskfile_author": latest.get("taskfile_author", ""), + "workflow": effective_workflow, + "workflows": workflows, + "workflow_meta": workflow_meta, + "taskfile_name": workflow_meta.get(effective_workflow, {}).get("taskfile_name", ""), + "taskfile_description": workflow_meta.get(effective_workflow, {}).get( + "taskfile_description", "" + ), + "taskfile_author": workflow_meta.get(effective_workflow, {}).get("taskfile_author", ""), "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - "default_runs": min(default_runs, len(runs)), + "default_runs": effective_default_runs, "total_runs": len(runs), "runs": enriched_runs, "task_results": slim_task_results, @@ -367,7 +395,12 @@ def generate_dashboard( :param dag_url: Optional relative URL to the DAG visualization HTML :return: Path to the generated HTML file """ - data = _prepare_dashboard_data(runs, task_results, default_runs) + data = _prepare_dashboard_data( + runs, + task_results, + default_runs, + selected_workflow=workflow, + ) data_json = json.dumps(data, default=str) # Build conditional DAG link HTML @@ -623,6 +656,10 @@ def generate_dashboard(