sqlbenchdag 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- infrastructure/debug_aws.py +40 -0
- scripts/dev/backfill_queries.py +44 -0
- scripts/dev/timestamp_capsule.py +49 -0
- scripts/dev/upgrade_capsule.py +63 -0
- scripts/dev/verify_capsule.py +79 -0
- scripts/dev/verify_portability.py +103 -0
- scripts/tools/_plotlib.py +43 -0
- scripts/tools/analyze_scaling.py +39 -0
- scripts/tools/demo_breach_detection.py +61 -0
- scripts/tools/extract_results.py +57 -0
- scripts/tools/find_kink.py +104 -0
- scripts/tools/gen_experiment_catalog.py +103 -0
- scripts/tools/load_parquet_to_local.py +98 -0
- scripts/tools/plot_execution_modes.py +82 -0
- scripts/tools/plot_scaling.py +91 -0
- scripts/tools/plot_selectivity.py +139 -0
- scripts/tools/plot_threads.py +95 -0
- scripts/tools/regenerate_dashboard.py +58 -0
- scripts/tools/verify_doc_claims.py +88 -0
- sql_benchmarks/__init__.py +3 -0
- sql_benchmarks/api/__init__.py +0 -0
- sql_benchmarks/api/app.py +30 -0
- sql_benchmarks/api/data/__init__.py +0 -0
- sql_benchmarks/api/data/catalog_reader.py +73 -0
- sql_benchmarks/api/data/reader.py +125 -0
- sql_benchmarks/api/logic/__init__.py +0 -0
- sql_benchmarks/api/logic/comparator.py +44 -0
- sql_benchmarks/api/logic/ranker.py +127 -0
- sql_benchmarks/api/models/__init__.py +0 -0
- sql_benchmarks/api/models/catalog.py +22 -0
- sql_benchmarks/api/models/experiments.py +20 -0
- sql_benchmarks/api/models/recommend.py +11 -0
- sql_benchmarks/api/models/results.py +66 -0
- sql_benchmarks/api/routers/__init__.py +0 -0
- sql_benchmarks/api/routers/catalog.py +19 -0
- sql_benchmarks/api/routers/experiments.py +79 -0
- sql_benchmarks/api/routers/recommend.py +22 -0
- sql_benchmarks/api/routers/results.py +51 -0
- sql_benchmarks/assets/__init__.py +4 -0
- sql_benchmarks/assets/benchmark_factory.py +222 -0
- sql_benchmarks/assets/data_factory.py +54 -0
- sql_benchmarks/assets/data_quality.py +91 -0
- sql_benchmarks/assets/ingestion_factory.py +88 -0
- sql_benchmarks/assets/maintenance.py +63 -0
- sql_benchmarks/assets/reporting.py +385 -0
- sql_benchmarks/assets/semantic_gate.py +76 -0
- sql_benchmarks/cli.py +69 -0
- sql_benchmarks/config_loader.py +104 -0
- sql_benchmarks/constants.py +60 -0
- sql_benchmarks/coordinator.py +282 -0
- sql_benchmarks/definitions.py +115 -0
- sql_benchmarks/experiments/templates/experiment_template.yaml +99 -0
- sql_benchmarks/harness.py +84 -0
- sql_benchmarks/jobs.py +8 -0
- sql_benchmarks/partitions.py +17 -0
- sql_benchmarks/plugins/data_sources/declarative_gen.py +211 -0
- sql_benchmarks/plugins/data_sources/local_file_loader.py +62 -0
- sql_benchmarks/plugins/data_sources/tpc_h.py +61 -0
- sql_benchmarks/resources/__init__.py +0 -0
- sql_benchmarks/resources/actian.py +234 -0
- sql_benchmarks/resources/actian_client.py +231 -0
- sql_benchmarks/resources/base_engine.py +58 -0
- sql_benchmarks/resources/base_schema_client.py +104 -0
- sql_benchmarks/resources/duckdb.py +58 -0
- sql_benchmarks/resources/duckdb_client.py +157 -0
- sql_benchmarks/resources/postgres.py +188 -0
- sql_benchmarks/resources/postgres_client.py +184 -0
- sql_benchmarks/resources/quack.py +56 -0
- sql_benchmarks/resources/quack_client.py +169 -0
- sql_benchmarks/resources/typedb_client.py +370 -0
- sql_benchmarks/resources/typedb_engine.py +303 -0
- sql_benchmarks/scripts/__init__.py +0 -0
- sql_benchmarks/scripts/sql/acid_test/duckdb/test.sql +1 -0
- sql_benchmarks/scripts/sql/acid_test/postgres/test.sql +1 -0
- sql_benchmarks/scripts/sql/analytical_wall/actian/analytical_wall.sql +15 -0
- sql_benchmarks/scripts/sql/analytical_wall/analytical_wall.sql.template +19 -0
- sql_benchmarks/scripts/sql/analytical_wall/duckdb/analytical_wall.sql +15 -0
- sql_benchmarks/scripts/sql/analytical_wall/postgres/analytical_wall.sql +15 -0
- sql_benchmarks/scripts/sql/group_by/duckdb/groupby_antipattern.sql +11 -0
- sql_benchmarks/scripts/sql/group_by/duckdb/groupby_recommended_cte.sql +19 -0
- sql_benchmarks/scripts/sql/group_by/postgres/groupby_antipattern.sql +13 -0
- sql_benchmarks/scripts/sql/group_by/postgres/groupby_recommended_pk.sql +11 -0
- sql_benchmarks/scripts/sql/hypergraph/duckdb/q_1hop.sql +5 -0
- sql_benchmarks/scripts/sql/hypergraph/duckdb/q_2hop.sql +6 -0
- sql_benchmarks/scripts/sql/hypergraph/duckdb/q_full.sql +9 -0
- sql_benchmarks/scripts/sql/hypergraph/typedb/q_1hop.sql +4 -0
- sql_benchmarks/scripts/sql/hypergraph/typedb/q_2hop.sql +5 -0
- sql_benchmarks/scripts/sql/hypergraph/typedb/q_full.sql +6 -0
- sql_benchmarks/scripts/sql/joins/duckdb/join_antipattern.sql +5 -0
- sql_benchmarks/scripts/sql/joins/duckdb/join_recommended.sql +4 -0
- sql_benchmarks/scripts/sql/joins/postgres/join_antipattern.sql +5 -0
- sql_benchmarks/scripts/sql/joins/postgres/join_recommended.sql +4 -0
- sql_benchmarks/scripts/sql/null_logic/duckdb/2VL_identity.sql +14 -0
- sql_benchmarks/scripts/sql/null_logic/duckdb/3VL_identity.sql +5 -0
- sql_benchmarks/scripts/sql/null_logic/postgres/2VL_identity.sql +14 -0
- sql_benchmarks/scripts/sql/null_logic/postgres/3VL_identity.sql +5 -0
- sql_benchmarks/scripts/sql/null_sentinel/duckdb/2VL_identity.sql +14 -0
- sql_benchmarks/scripts/sql/null_sentinel/duckdb/3VL_identity.sql +5 -0
- sql_benchmarks/scripts/sql/null_sentinel/duckdb/sentinel_optimization.sql +16 -0
- sql_benchmarks/scripts/sql/null_sentinel/postgres/2VL_identity.sql +14 -0
- sql_benchmarks/scripts/sql/null_sentinel/postgres/3VL_identity.sql +5 -0
- sql_benchmarks/scripts/sql/null_sentinel/postgres/sentinel_optimization.sql +21 -0
- sql_benchmarks/scripts/sql/recursion/duckdb/recursive_cte.sql +26 -0
- sql_benchmarks/scripts/sql/recursion/postgres/recursive_cte.sql +30 -0
- sql_benchmarks/scripts/sql/recursive_graph/duckdb/q_bounded_3hop.sql +17 -0
- sql_benchmarks/scripts/sql/recursive_graph/duckdb/q_fixed_2hop.sql +6 -0
- sql_benchmarks/scripts/sql/recursive_graph/duckdb/q_transitive_closure.sql +17 -0
- sql_benchmarks/scripts/sql/recursive_graph/typedb/q_bounded_3hop.sql +6 -0
- sql_benchmarks/scripts/sql/recursive_graph/typedb/q_fixed_2hop.sql +5 -0
- sql_benchmarks/scripts/sql/recursive_graph/typedb/q_transitive_closure.sql +7 -0
- sql_benchmarks/scripts/sql/security/acid_resilience/duckdb/test.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/duckdb/q_0_1_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/duckdb/q_10_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/duckdb/q_1_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/duckdb/q_20_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/duckdb/q_5_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/duckdb/q_filler.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/postgres/q_0_1_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/postgres/q_10_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/postgres/q_1_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/postgres/q_20_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/postgres/q_5_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/postgres/q_filler.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/typedb/q_0_1_percent.sql +4 -0
- sql_benchmarks/scripts/sql/selectivity/typedb/q_10_percent.sql +4 -0
- sql_benchmarks/scripts/sql/selectivity/typedb/q_1_percent.sql +4 -0
- sql_benchmarks/scripts/sql/selectivity/typedb/q_20_percent.sql +4 -0
- sql_benchmarks/scripts/sql/selectivity/typedb/q_5_percent.sql +4 -0
- sql_benchmarks/scripts/sql/selectivity/typedb/q_filler.sql +4 -0
- sql_benchmarks/scripts/sql/sort_spill/duckdb/sort_full.sql +25 -0
- sql_benchmarks/scripts/sql/sort_spill/duckdb/sort_wide_rows.sql +25 -0
- sql_benchmarks/scripts/sql/sort_spill/postgres/sort_full.sql +25 -0
- sql_benchmarks/scripts/sql/sort_spill/postgres/sort_wide_rows.sql +26 -0
- sql_benchmarks/scripts/sql/tpch/duckdb/q3_shipping_priority.sql +25 -0
- sql_benchmarks/scripts/sql/tpch/postgres/q3_shipping_priority.sql +25 -0
- sql_benchmarks/utils/__init__.py +0 -0
- sql_benchmarks/utils/common.py +260 -0
- sql_benchmarks/utils/ddl.py +47 -0
- sql_benchmarks/utils/hasher.py +124 -0
- sql_benchmarks/utils/integrity_monitor.py +55 -0
- sql_benchmarks/utils/providers.py +184 -0
- sql_benchmarks/utils/scaling.py +88 -0
- sql_benchmarks/utils/schema.py +118 -0
- sql_benchmarks/utils/semantic_auditor.py +81 -0
- sql_benchmarks/utils/system.py +100 -0
- sql_benchmarks/validator.py +60 -0
- sqlbenchdag-0.1.0.dist-info/METADATA +221 -0
- sqlbenchdag-0.1.0.dist-info/RECORD +153 -0
- sqlbenchdag-0.1.0.dist-info/WHEEL +5 -0
- sqlbenchdag-0.1.0.dist-info/entry_points.txt +2 -0
- sqlbenchdag-0.1.0.dist-info/licenses/LICENSE +202 -0
- sqlbenchdag-0.1.0.dist-info/top_level.txt +4 -0
- venv/bin/activate_this.py +59 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import tempfile
|
|
4
|
+
import time
|
|
5
|
+
from .constants import ROOT_DIR, DATA_DIR, RESULTS_DIR, VIOLATIONS_DIR, REPORTS_DIR
|
|
6
|
+
from .utils.integrity_monitor import IntegrityMonitor
|
|
7
|
+
|
|
8
|
+
class IsolationHarness:
|
|
9
|
+
"""
|
|
10
|
+
Provides a 'Clean Room' for experiment execution.
|
|
11
|
+
Redirects all file outputs to a scratchpad to prevent state contamination.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, experiment_id: str):
|
|
15
|
+
self.experiment_id = experiment_id
|
|
16
|
+
self.scratchpad_root = None
|
|
17
|
+
self._monitor = None
|
|
18
|
+
|
|
19
|
+
def provision(self) -> dict:
|
|
20
|
+
"""Creates the scratchpad and returns the redirection environment."""
|
|
21
|
+
self.scratchpad_root = tempfile.mkdtemp(prefix=f"sb_{self.experiment_id}_")
|
|
22
|
+
|
|
23
|
+
# Create standard layout inside scratchpad
|
|
24
|
+
paths = {
|
|
25
|
+
"SB_DATA_DIR": os.path.join(self.scratchpad_root, "data"),
|
|
26
|
+
"SB_RESULTS_DIR": os.path.join(self.scratchpad_root, "results"),
|
|
27
|
+
"SB_VIOLATIONS_DIR": os.path.join(self.scratchpad_root, "violations"),
|
|
28
|
+
"SB_REPORTS_DIR": os.path.join(self.scratchpad_root, "reports"),
|
|
29
|
+
"SCRATCHPAD_ROOT": self.scratchpad_root
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for p in paths.values():
|
|
33
|
+
os.makedirs(p, exist_ok=True)
|
|
34
|
+
|
|
35
|
+
# Ensure critical subdirectories exist
|
|
36
|
+
os.makedirs(os.path.join(paths["SB_DATA_DIR"], "staging"), exist_ok=True)
|
|
37
|
+
os.makedirs(os.path.join(paths["SB_DATA_DIR"], "duckdb"), exist_ok=True)
|
|
38
|
+
os.makedirs(os.path.join(paths["SB_RESULTS_DIR"], "fragments"), exist_ok=True)
|
|
39
|
+
|
|
40
|
+
# Snapshot the package source NOW: if any code changes while the
|
|
41
|
+
# experiment runs, the Experiment ID no longer describes what ran.
|
|
42
|
+
from .constants import PACKAGE_DIR
|
|
43
|
+
self._monitor = IntegrityMonitor(PACKAGE_DIR)
|
|
44
|
+
|
|
45
|
+
return paths
|
|
46
|
+
|
|
47
|
+
def check_integrity(self) -> list:
|
|
48
|
+
"""
|
|
49
|
+
Compares the package source against the snapshot taken at provision().
|
|
50
|
+
Returns drift entries (MODIFIED/ADDED/DELETED) — any code change during
|
|
51
|
+
an experiment invalidates the run, because the Experiment ID was
|
|
52
|
+
computed from the code as it stood at submission.
|
|
53
|
+
|
|
54
|
+
History note: a previous implementation here only inspected a
|
|
55
|
+
sentinel file named secure_pill.txt (planted by its own test) and
|
|
56
|
+
could never detect real tampering. Replaced with the IntegrityMonitor
|
|
57
|
+
snapshot mechanism this docstring's promises always implied.
|
|
58
|
+
"""
|
|
59
|
+
if self._monitor is None:
|
|
60
|
+
return []
|
|
61
|
+
return self._monitor.check_drift()
|
|
62
|
+
|
|
63
|
+
def cleanup(self):
|
|
64
|
+
"""Removes the scratchpad — and ONLY a scratchpad.
|
|
65
|
+
|
|
66
|
+
Guarded after a historical incident: a miscomputed path turned a
|
|
67
|
+
cleanup into a recursive deletion of real work ("brilliant
|
|
68
|
+
amnesia"). rmtree here refuses any path that is not a genuine
|
|
69
|
+
sb_-prefixed directory under the system temp dir. Refusal is loud;
|
|
70
|
+
a leaked scratchpad is recoverable, a deleted repo is not.
|
|
71
|
+
"""
|
|
72
|
+
if not self.scratchpad_root:
|
|
73
|
+
return
|
|
74
|
+
real = os.path.realpath(self.scratchpad_root)
|
|
75
|
+
tmp_root = os.path.realpath(tempfile.gettempdir())
|
|
76
|
+
if not (real.startswith(tmp_root + os.sep)
|
|
77
|
+
and os.path.basename(real).startswith("sb_")):
|
|
78
|
+
print(f"[CRITICAL] cleanup() refused: '{real}' is not a temp scratchpad. "
|
|
79
|
+
f"Leaving it untouched.")
|
|
80
|
+
return
|
|
81
|
+
if os.path.exists(real):
|
|
82
|
+
# Wait a beat for any lagging file handles
|
|
83
|
+
time.sleep(0.1)
|
|
84
|
+
shutil.rmtree(real)
|
sql_benchmarks/jobs.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from dagster import define_asset_job, AssetSelection
|
|
2
|
+
|
|
3
|
+
# Defines a job that selects ALL assets (Ingestion -> Benchmarks)
|
|
4
|
+
# This is what the Sensor triggers when it sees a new YAML file.
|
|
5
|
+
benchmark_job = define_asset_job(
|
|
6
|
+
name="benchmark_job",
|
|
7
|
+
selection=AssetSelection.all()
|
|
8
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from dagster import StaticPartitionsDefinition
|
|
2
|
+
from .config_loader import ConfigLoader
|
|
3
|
+
|
|
4
|
+
# Initialize the Configuration Compiler
|
|
5
|
+
try:
|
|
6
|
+
COMPILER = ConfigLoader()
|
|
7
|
+
except ValueError as e:
|
|
8
|
+
# If the compiler raises a structural error, we raise it here to fail hard at load time.
|
|
9
|
+
raise e
|
|
10
|
+
|
|
11
|
+
# Extract the compiled artifacts for Dagster
|
|
12
|
+
partitions_def = StaticPartitionsDefinition(COMPILER.partition_keys)
|
|
13
|
+
SCENARIO_CONFIG = COMPILER.scenario_config
|
|
14
|
+
|
|
15
|
+
# If the config file was missing, we must ensure Dagster boots cleanly
|
|
16
|
+
if not COMPILER.partition_keys:
|
|
17
|
+
partitions_def = StaticPartitionsDefinition(["init"])
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import polars as pl
|
|
3
|
+
import numpy as np
|
|
4
|
+
from dagster import MaterializeResult, MetadataValue
|
|
5
|
+
from ...utils.schema import TableDef # Strict Pydantic Model
|
|
6
|
+
from ...utils.providers import PROVIDER_REGISTRY
|
|
7
|
+
from ...constants import DEFAULT_CHUNK_SIZE
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def resolve_params_recursive(obj, params):
|
|
11
|
+
"""Recursively replace string values in obj that match keys in params."""
|
|
12
|
+
if isinstance(obj, dict):
|
|
13
|
+
return {k: resolve_params_recursive(v, params) for k, v in obj.items()}
|
|
14
|
+
elif isinstance(obj, list):
|
|
15
|
+
return [resolve_params_recursive(i, params) for i in obj]
|
|
16
|
+
elif isinstance(obj, str) and obj in params:
|
|
17
|
+
return params[obj]
|
|
18
|
+
# Handle Pydantic Models by dumping them first (if passed directly)
|
|
19
|
+
elif hasattr(obj, "model_dump"):
|
|
20
|
+
return resolve_params_recursive(obj.model_dump(), params)
|
|
21
|
+
return obj
|
|
22
|
+
|
|
23
|
+
def generate(context, params, table_name, target_path, dataset_config):
|
|
24
|
+
|
|
25
|
+
# 1. SCHEMA VALIDATION
|
|
26
|
+
if 'tables' not in dataset_config:
|
|
27
|
+
raise ValueError("Missing 'tables' section in dataset config.")
|
|
28
|
+
|
|
29
|
+
dataset_config = resolve_params_recursive(dataset_config, params)
|
|
30
|
+
|
|
31
|
+
tables = dataset_config.get("tables", {})
|
|
32
|
+
raw_table_def = tables.get(table_name)
|
|
33
|
+
if not raw_table_def:
|
|
34
|
+
raise ValueError(f"Table '{table_name}' not defined in dataset config.")
|
|
35
|
+
|
|
36
|
+
# Validate using Pydantic (Throws ValidationError if invalid)
|
|
37
|
+
# We strip unknown fields if strict mode is issue, but Schema is set to 'allow' extra.
|
|
38
|
+
table_model = TableDef(**tables[table_name])
|
|
39
|
+
|
|
40
|
+
# 2. RESOLVE ROW COUNT
|
|
41
|
+
# Priority: Matrix Params > Table Config
|
|
42
|
+
base_rows = params.get('rows')
|
|
43
|
+
if base_rows is None:
|
|
44
|
+
base_rows = table_model.rows
|
|
45
|
+
|
|
46
|
+
# Handle variable reference (e.g. rows: "small")
|
|
47
|
+
if isinstance(base_rows, str) and not base_rows.isdigit():
|
|
48
|
+
var_name = base_rows
|
|
49
|
+
if var_name in params:
|
|
50
|
+
base_rows = params[var_name]
|
|
51
|
+
else:
|
|
52
|
+
raise ValueError(f"Table '{table_name}' expects param '{var_name}' (from matrix) but it was missing.")
|
|
53
|
+
|
|
54
|
+
if base_rows is None:
|
|
55
|
+
raise ValueError(f"Could not resolve row count for '{table_name}'.")
|
|
56
|
+
|
|
57
|
+
row_count = int(base_rows)
|
|
58
|
+
|
|
59
|
+
# Helper to resolve rows for ANY table (current or target)
|
|
60
|
+
def _resolve_rows(t_name, t_def):
|
|
61
|
+
r = params.get('rows')
|
|
62
|
+
if r is None:
|
|
63
|
+
r = t_def.get('rows')
|
|
64
|
+
|
|
65
|
+
# Handle variable references
|
|
66
|
+
if isinstance(r, str) and not r.isdigit():
|
|
67
|
+
if r in params:
|
|
68
|
+
r = params[r]
|
|
69
|
+
else:
|
|
70
|
+
# Fallback: maybe the target table uses a fixed number?
|
|
71
|
+
# If we can't resolve it from params, we can't guess.
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
return int(r) if r else None
|
|
75
|
+
|
|
76
|
+
# Batch Generation Strategy
|
|
77
|
+
|
|
78
|
+
# Prepare parent directory
|
|
79
|
+
parent_dir = os.path.dirname(target_path)
|
|
80
|
+
if parent_dir:
|
|
81
|
+
os.makedirs(parent_dir, exist_ok=True)
|
|
82
|
+
|
|
83
|
+
# ... (rest of logic) ...
|
|
84
|
+
|
|
85
|
+
chunk_count = (row_count // DEFAULT_CHUNK_SIZE) + (1 if row_count % DEFAULT_CHUNK_SIZE > 0 else 0)
|
|
86
|
+
|
|
87
|
+
print(f"[Gen] Generating {row_count} rows in batches of {DEFAULT_CHUNK_SIZE}...")
|
|
88
|
+
|
|
89
|
+
# Initialize empty list for temp chunks
|
|
90
|
+
temp_files = []
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
for i in range(chunk_count):
|
|
94
|
+
offset = i * DEFAULT_CHUNK_SIZE
|
|
95
|
+
current_size = min(DEFAULT_CHUNK_SIZE, row_count - offset)
|
|
96
|
+
|
|
97
|
+
chunk_path = f"{target_path}.part_{i}"
|
|
98
|
+
_generate_chunk(offset, current_size, table_model, dataset_config, params, row_count, chunk_path,
|
|
99
|
+
base_seed=dataset_config.get("seed", 42))
|
|
100
|
+
temp_files.append(chunk_path)
|
|
101
|
+
print(f"[Gen] Batch {i+1} done.")
|
|
102
|
+
|
|
103
|
+
# Merge efficiently using Polars scan
|
|
104
|
+
# If single chunk, just rename
|
|
105
|
+
if len(temp_files) == 1:
|
|
106
|
+
import shutil
|
|
107
|
+
shutil.move(temp_files[0], target_path)
|
|
108
|
+
else:
|
|
109
|
+
pl.scan_parquet(f"{target_path}.part_*").collect().write_parquet(target_path)
|
|
110
|
+
|
|
111
|
+
finally:
|
|
112
|
+
# Cleanup
|
|
113
|
+
for f in temp_files:
|
|
114
|
+
if os.path.exists(f):
|
|
115
|
+
os.remove(f)
|
|
116
|
+
|
|
117
|
+
return MaterializeResult(
|
|
118
|
+
metadata={
|
|
119
|
+
"path": MetadataValue.path(target_path),
|
|
120
|
+
"row_count": MetadataValue.int(row_count),
|
|
121
|
+
"table": table_name
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def _generate_chunk(offset, size, table_model, dataset_config, params, total_rows, output_path,
|
|
126
|
+
base_seed=42):
|
|
127
|
+
# Enable reproducible generation. The base seed comes from dataset.seed
|
|
128
|
+
# in the YAML (default 42, preserving all pre-seed-param experiment IDs'
|
|
129
|
+
# data). Because the config is hashed, a different seed automatically
|
|
130
|
+
# yields a different Experiment ID — seed changes are never silent.
|
|
131
|
+
# Offset ensures different chunks get different sequences, but the same
|
|
132
|
+
# chunk always gets the same sequence.
|
|
133
|
+
np.random.seed(base_seed + offset)
|
|
134
|
+
|
|
135
|
+
data = {}
|
|
136
|
+
if not table_model.columns:
|
|
137
|
+
# Empty table with rows?
|
|
138
|
+
data["_row_id"] = np.arange(offset, offset + size) # Dummy
|
|
139
|
+
else:
|
|
140
|
+
for col_def in table_model.columns:
|
|
141
|
+
p_name = col_def.provider
|
|
142
|
+
generator_func = PROVIDER_REGISTRY.get(p_name)
|
|
143
|
+
|
|
144
|
+
if not generator_func:
|
|
145
|
+
raise ValueError(f"Unknown provider '{p_name}'")
|
|
146
|
+
|
|
147
|
+
kwargs = col_def.model_dump()
|
|
148
|
+
# Note: kwargs are already resolved at the top level
|
|
149
|
+
|
|
150
|
+
# Dynamic Params substitution - Removed as per instruction
|
|
151
|
+
# for k, v in kwargs.items():
|
|
152
|
+
# if isinstance(v, str) and v in params:
|
|
153
|
+
# kwargs[k] = params[v]
|
|
154
|
+
|
|
155
|
+
kwargs['table_name'] = params.get('table_name', 'unknown')
|
|
156
|
+
|
|
157
|
+
# Contextual Handling
|
|
158
|
+
# Foreign Key needs target_rows to generate valid IDs
|
|
159
|
+
if p_name in ["foreign_key", "foreign key"]:
|
|
160
|
+
target_table = col_def.target_table
|
|
161
|
+
if target_table:
|
|
162
|
+
tables = dataset_config.get("tables", {})
|
|
163
|
+
target_def = tables.get(target_table, {})
|
|
164
|
+
target_rows = None
|
|
165
|
+
|
|
166
|
+
# Resolve target table's row count
|
|
167
|
+
target_row_val = target_def.get('rows') or params.get('rows')
|
|
168
|
+
if isinstance(target_row_val, str) and target_row_val in params:
|
|
169
|
+
target_rows = int(params[target_row_val])
|
|
170
|
+
elif target_row_val is not None:
|
|
171
|
+
target_rows = int(target_row_val)
|
|
172
|
+
else:
|
|
173
|
+
target_rows = total_rows # Fallback to current table's row count
|
|
174
|
+
|
|
175
|
+
kwargs['target_rows'] = target_rows
|
|
176
|
+
|
|
177
|
+
# EXECUTE PROVIDER
|
|
178
|
+
# Note: Most providers don't care about offset (random).
|
|
179
|
+
# Sequence might? 'sequence' provider usually starts at 1.
|
|
180
|
+
# We need to handle 'sequence' specially or pass offset via kwargs if provider supports it.
|
|
181
|
+
if p_name == "sequence":
|
|
182
|
+
kwargs['start'] = offset + 1
|
|
183
|
+
|
|
184
|
+
# Text Concat needs existing data (in this chunk)
|
|
185
|
+
if p_name == "text_concat":
|
|
186
|
+
results = generator_func(size, existing_data=data, **kwargs)
|
|
187
|
+
else:
|
|
188
|
+
results = generator_func(size, **kwargs)
|
|
189
|
+
|
|
190
|
+
# Multi-column providers return a dict of {col_name: array}.
|
|
191
|
+
# Expand them directly into data instead of nesting under one key.
|
|
192
|
+
if isinstance(results, dict):
|
|
193
|
+
data.update(results)
|
|
194
|
+
else:
|
|
195
|
+
data[col_def.name] = results
|
|
196
|
+
|
|
197
|
+
df = pl.DataFrame(data)
|
|
198
|
+
|
|
199
|
+
# Apply Null Masks natively
|
|
200
|
+
for col_def in table_model.columns:
|
|
201
|
+
# null_probability will be a float here because it was resolved globally
|
|
202
|
+
if col_def.null_probability > 0:
|
|
203
|
+
mask = np.random.rand(size) < col_def.null_probability
|
|
204
|
+
|
|
205
|
+
# Use Polars expression to nullify values
|
|
206
|
+
# This preserves the underlying Arrow type (Int64, Utf8) instead of casting to Object
|
|
207
|
+
df = df.with_columns(
|
|
208
|
+
pl.when(pl.lit(mask)).then(None).otherwise(pl.col(col_def.name)).alias(col_def.name)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
df.write_parquet(output_path)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import polars as pl
|
|
2
|
+
import os
|
|
3
|
+
from dagster import MaterializeResult, MetadataValue
|
|
4
|
+
|
|
5
|
+
# CHANGED: 'output_dir' -> 'target_path'
|
|
6
|
+
def generate(context, params, table_name, target_path, dataset_config):
|
|
7
|
+
"""
|
|
8
|
+
Normalizes REAL data (CSV/Parquet/JSON) into standard Parquet for the platform.
|
|
9
|
+
"""
|
|
10
|
+
partition_key = context.partition_key
|
|
11
|
+
|
|
12
|
+
# 1. GET PATH
|
|
13
|
+
paths = dataset_config.get("paths", {})
|
|
14
|
+
source_template = paths.get(table_name)
|
|
15
|
+
|
|
16
|
+
if not source_template:
|
|
17
|
+
raise ValueError(f"CRITICAL: No path defined for table '{table_name}' in dataset.paths")
|
|
18
|
+
|
|
19
|
+
# INTERPOLATE
|
|
20
|
+
# Support {topology} or other vars from the partition params
|
|
21
|
+
source_file = source_template.format(**params)
|
|
22
|
+
|
|
23
|
+
if not os.path.exists(source_file):
|
|
24
|
+
raise FileNotFoundError(f"CRITICAL: Real data file not found: {source_file}")
|
|
25
|
+
|
|
26
|
+
context.log.info(f"Normalizing real data from {source_file}...")
|
|
27
|
+
|
|
28
|
+
# 2. STREAMING CONVERT (Lazy Evaluation)
|
|
29
|
+
# Ensure directory exists
|
|
30
|
+
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
|
31
|
+
|
|
32
|
+
file_ext = os.path.splitext(source_file)[1].lower()
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
# Create a LazyFrame (Scan) based on extension
|
|
36
|
+
if file_ext == ".csv":
|
|
37
|
+
q = pl.scan_csv(source_file, infer_schema_length=10000)
|
|
38
|
+
elif file_ext == ".parquet":
|
|
39
|
+
q = pl.scan_parquet(source_file)
|
|
40
|
+
elif file_ext in [".json", ".ndjson"]:
|
|
41
|
+
q = pl.scan_ndjson(source_file)
|
|
42
|
+
else:
|
|
43
|
+
raise ValueError(f"Unsupported file type: {file_ext}")
|
|
44
|
+
|
|
45
|
+
# 3. SINK TO PARQUET
|
|
46
|
+
# Use the explicit target_path provided by the factory
|
|
47
|
+
q.sink_parquet(target_path)
|
|
48
|
+
|
|
49
|
+
# 4. METADATA
|
|
50
|
+
row_count = pl.scan_parquet(target_path).select(pl.len()).collect().item()
|
|
51
|
+
|
|
52
|
+
except Exception as e:
|
|
53
|
+
raise RuntimeError(f"Failed to process {source_file}: {e}")
|
|
54
|
+
|
|
55
|
+
return MaterializeResult(
|
|
56
|
+
metadata={
|
|
57
|
+
"path": MetadataValue.path(target_path),
|
|
58
|
+
"row_count": MetadataValue.int(row_count),
|
|
59
|
+
"source_file": MetadataValue.path(source_file),
|
|
60
|
+
"loader_backend": "polars_streaming"
|
|
61
|
+
}
|
|
62
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import duckdb
|
|
2
|
+
import os
|
|
3
|
+
from dagster import MaterializeResult, MetadataValue
|
|
4
|
+
|
|
5
|
+
# CHANGED: 'output_dir' -> 'target_path'
|
|
6
|
+
def generate(context, params, table_name, target_path, dataset_config):
|
|
7
|
+
partition_key = context.partition_key
|
|
8
|
+
|
|
9
|
+
if 'tables' not in dataset_config:
|
|
10
|
+
raise ValueError("Critical: 'dataset.tables' missing in active.yaml")
|
|
11
|
+
|
|
12
|
+
if not dataset_config['tables'].get(table_name):
|
|
13
|
+
raise ValueError(f"Table '{table_name}' requested but not defined in YAML.")
|
|
14
|
+
|
|
15
|
+
scale_factor = params.get('scale_factor', 0.01)
|
|
16
|
+
|
|
17
|
+
context.log.info(f"Generating TPC-H table '{table_name}' with SF={scale_factor}...")
|
|
18
|
+
|
|
19
|
+
# 1. Setup DuckDB
|
|
20
|
+
con = duckdb.connect(database=':memory:')
|
|
21
|
+
con.install_extension("tpch")
|
|
22
|
+
con.load_extension("tpch")
|
|
23
|
+
|
|
24
|
+
# 2. Generate Data
|
|
25
|
+
con.execute(f"CALL dbgen(sf={scale_factor})")
|
|
26
|
+
|
|
27
|
+
# 3. Export with Type Casting
|
|
28
|
+
# Ensure directory exists (Defensive)
|
|
29
|
+
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
|
30
|
+
|
|
31
|
+
# Get columns for the table
|
|
32
|
+
cols_info = con.execute(f"DESCRIBE {table_name}").fetchall()
|
|
33
|
+
|
|
34
|
+
# Build a SELECT statement that casts Decimals to Doubles
|
|
35
|
+
select_parts = []
|
|
36
|
+
for col_name, col_type, _, _, _, _ in cols_info:
|
|
37
|
+
if "DECIMAL" in col_type:
|
|
38
|
+
select_parts.append(f"CAST({col_name} AS DOUBLE) AS {col_name}")
|
|
39
|
+
else:
|
|
40
|
+
select_parts.append(col_name)
|
|
41
|
+
|
|
42
|
+
select_query = ", ".join(select_parts)
|
|
43
|
+
|
|
44
|
+
# USE THE PASSED TARGET_PATH
|
|
45
|
+
con.execute(f"""
|
|
46
|
+
COPY (SELECT {select_query} FROM {table_name})
|
|
47
|
+
TO '{target_path}'
|
|
48
|
+
(FORMAT PARQUET, COMPRESSION 'SNAPPY')
|
|
49
|
+
""")
|
|
50
|
+
|
|
51
|
+
# 4. Metadata
|
|
52
|
+
row_count = con.execute(f"SELECT count(*) FROM {table_name}").fetchone()[0]
|
|
53
|
+
|
|
54
|
+
return MaterializeResult(
|
|
55
|
+
metadata={
|
|
56
|
+
"path": MetadataValue.path(target_path),
|
|
57
|
+
"row_count": MetadataValue.int(row_count),
|
|
58
|
+
"scale_factor": MetadataValue.float(scale_factor),
|
|
59
|
+
"source": "duckdb_tpch_extension"
|
|
60
|
+
}
|
|
61
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import socket
|
|
4
|
+
from dagster import ConfigurableResource
|
|
5
|
+
from typing import Dict, Any, Optional
|
|
6
|
+
from pydantic import ConfigDict, PrivateAttr
|
|
7
|
+
from .base_engine import IBenchmarkEngine
|
|
8
|
+
from .actian_client import ActianClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ActianEngine(ConfigurableResource):
|
|
12
|
+
"""
|
|
13
|
+
Dagster Resource for Actian Vector on EC2.
|
|
14
|
+
|
|
15
|
+
Manages SSH tunnel lifecycle and provides cold-cache enforcement
|
|
16
|
+
via remote service restart.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
# --- CONFIGURATION ---
|
|
20
|
+
# Local connection (through tunnel)
|
|
21
|
+
local_port: int = 27832
|
|
22
|
+
|
|
23
|
+
# Remote EC2 configuration
|
|
24
|
+
ec2_host: str = os.getenv("ACTIAN_EC2_HOST", "")
|
|
25
|
+
ec2_user: str = os.getenv("ACTIAN_EC2_USER", "ingres")
|
|
26
|
+
ssh_key_path: str = os.getenv("ACTIAN_SSH_KEY", os.path.expanduser("~/.ssh/benchmark-key-pair.pem"))
|
|
27
|
+
|
|
28
|
+
# Actian database configuration
|
|
29
|
+
database: str = os.getenv("ACTIAN_DATABASE", "benchmark_db")
|
|
30
|
+
db_user: str = os.getenv("ACTIAN_USER", "ingres")
|
|
31
|
+
db_password: str = os.getenv("ACTIAN_PASSWORD", "AntiGravity")
|
|
32
|
+
|
|
33
|
+
# Remote paths
|
|
34
|
+
remote_data_dir: str = "/tmp/benchmark_data"
|
|
35
|
+
vwload_path: str = "/opt/Actian/VectorVW/ingres/bin/vwload"
|
|
36
|
+
actian_sql_path: str = "/opt/Actian/VectorVW/ingres/bin/sql"
|
|
37
|
+
|
|
38
|
+
model_config = ConfigDict(extra='forbid')
|
|
39
|
+
|
|
40
|
+
# Private attributes for runtime state
|
|
41
|
+
_tunnel: Any = PrivateAttr(default=None)
|
|
42
|
+
_ssh_client: Any = PrivateAttr(default=None)
|
|
43
|
+
|
|
44
|
+
def _ensure_tunnel(self):
|
|
45
|
+
"""Establishes SSH tunnel if not already active."""
|
|
46
|
+
if self._tunnel is not None and self._tunnel.is_active:
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
if not self.ec2_host:
|
|
50
|
+
raise ValueError("ACTIAN_EC2_HOST environment variable must be set")
|
|
51
|
+
|
|
52
|
+
from sshtunnel import SSHTunnelForwarder
|
|
53
|
+
|
|
54
|
+
self._tunnel = SSHTunnelForwarder(
|
|
55
|
+
(self.ec2_host, 22),
|
|
56
|
+
ssh_username=self.ec2_user,
|
|
57
|
+
ssh_pkey=self.ssh_key_path,
|
|
58
|
+
remote_bind_address=('localhost', 27832),
|
|
59
|
+
local_bind_address=('localhost', self.local_port),
|
|
60
|
+
)
|
|
61
|
+
self._tunnel.start()
|
|
62
|
+
print(f"[Actian] SSH tunnel established: localhost:{self._tunnel.local_bind_port} -> {self.ec2_host}:27832")
|
|
63
|
+
|
|
64
|
+
# Give the tunnel a moment to stabilize
|
|
65
|
+
time.sleep(1)
|
|
66
|
+
|
|
67
|
+
def _ensure_ssh(self):
|
|
68
|
+
"""Establishes SSH client for remote commands."""
|
|
69
|
+
if self._ssh_client is not None:
|
|
70
|
+
# Check if connection is still alive
|
|
71
|
+
try:
|
|
72
|
+
self._ssh_client.exec_command("echo ok", timeout=5)
|
|
73
|
+
return
|
|
74
|
+
except Exception:
|
|
75
|
+
self._ssh_client = None
|
|
76
|
+
|
|
77
|
+
if not self.ec2_host:
|
|
78
|
+
raise ValueError("ACTIAN_EC2_HOST environment variable must be set")
|
|
79
|
+
|
|
80
|
+
import paramiko
|
|
81
|
+
# 1. Expand the path (handling both the env var and the default)
|
|
82
|
+
full_key_path = os.path.expanduser(self.ssh_key_path)
|
|
83
|
+
|
|
84
|
+
# 2. Architect Fix: Explicitly load as RSAKey.
|
|
85
|
+
# This bypasses the logic where Paramiko scans for DSSKey and crashes.
|
|
86
|
+
try:
|
|
87
|
+
pkey = paramiko.RSAKey.from_private_key_file(full_key_path)
|
|
88
|
+
except Exception as e:
|
|
89
|
+
raise RuntimeError(f"Could not load RSA key from {full_key_path}: {e}")
|
|
90
|
+
|
|
91
|
+
# 3. Connect using 'pkey' instead of 'key_filename'
|
|
92
|
+
self._ssh_client = paramiko.SSHClient()
|
|
93
|
+
self._ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
94
|
+
|
|
95
|
+
self._ssh_client.connect(
|
|
96
|
+
hostname=self.ec2_host,
|
|
97
|
+
username=self.ec2_user,
|
|
98
|
+
pkey=pkey, # Use the loaded object here
|
|
99
|
+
timeout=30
|
|
100
|
+
)
|
|
101
|
+
# self._ssh_client = paramiko.SSHClient()
|
|
102
|
+
# self._ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
103
|
+
# self._ssh_client.connect(
|
|
104
|
+
# hostname=self.ec2_host,
|
|
105
|
+
# username=self.ec2_user,
|
|
106
|
+
# key_filename=self.ssh_key_path,
|
|
107
|
+
# timeout=30
|
|
108
|
+
# )
|
|
109
|
+
print(f"[Actian] SSH connection established to {self.ec2_host}")
|
|
110
|
+
|
|
111
|
+
def _ssh_exec(self, command: str, timeout: int = 300) -> tuple[str, str, int]:
|
|
112
|
+
"""Execute a command on the remote EC2 instance."""
|
|
113
|
+
self._ensure_ssh()
|
|
114
|
+
|
|
115
|
+
stdin, stdout, stderr = self._ssh_client.exec_command(command, timeout=timeout)
|
|
116
|
+
exit_code = stdout.channel.recv_exit_status()
|
|
117
|
+
|
|
118
|
+
return stdout.read().decode(), stderr.read().decode(), exit_code
|
|
119
|
+
|
|
120
|
+
def _get_client(self) -> ActianClient:
|
|
121
|
+
"""Factory method for creating the Actian client."""
|
|
122
|
+
self._ensure_tunnel()
|
|
123
|
+
|
|
124
|
+
connection_params = {
|
|
125
|
+
"host": "localhost",
|
|
126
|
+
"port": self._tunnel.local_bind_port,
|
|
127
|
+
"user": self.db_user,
|
|
128
|
+
"password": self.db_password,
|
|
129
|
+
"database": self.database
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# Pass SSH details for vwload operations
|
|
133
|
+
ssh_params = {
|
|
134
|
+
"ec2_host": self.ec2_host,
|
|
135
|
+
"ec2_user": self.ec2_user,
|
|
136
|
+
"ssh_key_path": self.ssh_key_path,
|
|
137
|
+
"remote_data_dir": self.remote_data_dir,
|
|
138
|
+
"vwload_path": self.vwload_path,
|
|
139
|
+
"actian_sql_path": self.actian_sql_path,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return ActianClient(connection_params, ssh_params)
|
|
143
|
+
|
|
144
|
+
# --- IBenchmarkEngine Implementation ---
|
|
145
|
+
|
|
146
|
+
def get_engine_name(self) -> str:
|
|
147
|
+
return "actian"
|
|
148
|
+
|
|
149
|
+
def clear_cache(self, settings: dict = None):
|
|
150
|
+
"""
|
|
151
|
+
Enforces cold cache by restarting Actian Vector service on EC2.
|
|
152
|
+
|
|
153
|
+
This clears both:
|
|
154
|
+
1. X100 engine memory segments
|
|
155
|
+
2. Linux page cache (via service restart)
|
|
156
|
+
"""
|
|
157
|
+
print("[Actian] Clearing cache via service restart...")
|
|
158
|
+
|
|
159
|
+
# Restart Actian Vector service
|
|
160
|
+
stdout, stderr, exit_code = self._ssh_exec(
|
|
161
|
+
"sudo systemctl restart actian-vectorVW",
|
|
162
|
+
timeout=120
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if exit_code != 0:
|
|
166
|
+
raise RuntimeError(f"Failed to restart Actian service: {stderr}")
|
|
167
|
+
|
|
168
|
+
# Wait for service to be ready
|
|
169
|
+
self._wait_for_ready()
|
|
170
|
+
print("[Actian] Service restarted, cache cleared")
|
|
171
|
+
|
|
172
|
+
def run_query(self, sql: str, partition_key: str, engine_params: Dict[str, Any] = None) -> Optional[float]:
|
|
173
|
+
"""Execute a benchmark query with cold cache.
|
|
174
|
+
|
|
175
|
+
engine_params is the 'actian' namespace — accepted but not yet applied
|
|
176
|
+
(Vector session tuning is a future capability).
|
|
177
|
+
"""
|
|
178
|
+
self.clear_cache()
|
|
179
|
+
client = self._get_client()
|
|
180
|
+
return client.run_query(sql, {})
|
|
181
|
+
|
|
182
|
+
def bulk_load(self, filepath: str, table_name: str, partition_key: str) -> None:
|
|
183
|
+
"""
|
|
184
|
+
Bulk load data into Actian Vector.
|
|
185
|
+
|
|
186
|
+
1. SCP the file to EC2
|
|
187
|
+
2. Convert to CSV if needed (vwload prefers CSV)
|
|
188
|
+
3. Run vwload on EC2
|
|
189
|
+
"""
|
|
190
|
+
self._ensure_tunnel()
|
|
191
|
+
self._wait_for_ready()
|
|
192
|
+
|
|
193
|
+
client = self._get_client()
|
|
194
|
+
client.bulk_load(filepath, table_name, partition_key)
|
|
195
|
+
|
|
196
|
+
def _wait_for_ready(self, timeout: int = 120):
|
|
197
|
+
"""Wait for Actian to be ready to accept connections."""
|
|
198
|
+
self._ensure_tunnel()
|
|
199
|
+
|
|
200
|
+
start = time.time()
|
|
201
|
+
while time.time() - start < timeout:
|
|
202
|
+
try:
|
|
203
|
+
with socket.create_connection(
|
|
204
|
+
('localhost', self._tunnel.local_bind_port),
|
|
205
|
+
timeout=2
|
|
206
|
+
):
|
|
207
|
+
# Port is open, but let's verify the service is actually responding
|
|
208
|
+
time.sleep(2)
|
|
209
|
+
return
|
|
210
|
+
except (socket.timeout, ConnectionRefusedError, OSError):
|
|
211
|
+
time.sleep(2)
|
|
212
|
+
|
|
213
|
+
raise TimeoutError(
|
|
214
|
+
f"Actian Vector on {self.ec2_host} failed to respond within {timeout}s"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def cleanup(self):
|
|
218
|
+
"""Clean up SSH tunnel and connections."""
|
|
219
|
+
if self._ssh_client:
|
|
220
|
+
self._ssh_client.close()
|
|
221
|
+
self._ssh_client = None
|
|
222
|
+
|
|
223
|
+
if self._tunnel:
|
|
224
|
+
self._tunnel.stop()
|
|
225
|
+
self._tunnel = None
|
|
226
|
+
|
|
227
|
+
print("[Actian] Connections closed")
|
|
228
|
+
|
|
229
|
+
def __del__(self):
|
|
230
|
+
"""Ensure cleanup on garbage collection."""
|
|
231
|
+
try:
|
|
232
|
+
self.cleanup()
|
|
233
|
+
except Exception:
|
|
234
|
+
pass
|