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,20 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ExperimentSubmitRequest(BaseModel):
|
|
6
|
+
config_yaml: str
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ExperimentSubmitResponse(BaseModel):
|
|
10
|
+
experiment_id: str
|
|
11
|
+
status: str # "queued" | "duplicate" | "rejected"
|
|
12
|
+
detail: Optional[str] = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ExperimentStatus(BaseModel):
|
|
16
|
+
experiment_id: str
|
|
17
|
+
status: str # "queued" | "running" | "complete" | "not_found"
|
|
18
|
+
has_results: bool
|
|
19
|
+
fragment_count: int
|
|
20
|
+
has_csv: bool
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import Dict, List
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RecommendResponse(BaseModel):
|
|
6
|
+
recommended_engine: str
|
|
7
|
+
confidence: str # "high" | "medium" | "low"
|
|
8
|
+
reasoning: str
|
|
9
|
+
supporting_experiments: List[str]
|
|
10
|
+
engine_scores: Dict[str, float] # {engine: mean_duration_seconds}
|
|
11
|
+
caveats: List[str]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import Any, Dict, List, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class FragmentMeta(BaseModel):
|
|
6
|
+
timestamp: str
|
|
7
|
+
experiment_id: str
|
|
8
|
+
dagster_run_id: str
|
|
9
|
+
engine: str
|
|
10
|
+
asset: str
|
|
11
|
+
partition: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FragmentMetrics(BaseModel):
|
|
15
|
+
duration_seconds: float
|
|
16
|
+
replication_factor: int
|
|
17
|
+
# Raw per-replication measurements; None for fragments written before
|
|
18
|
+
# raw capture (or DNF sentinels, which carry an empty list).
|
|
19
|
+
durations_raw: Optional[List[float]] = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Fragment(BaseModel):
|
|
23
|
+
meta: FragmentMeta
|
|
24
|
+
metrics: FragmentMetrics
|
|
25
|
+
parameters: Dict[str, Any]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ExperimentSummary(BaseModel):
|
|
29
|
+
experiment_id: str
|
|
30
|
+
suite: Optional[str] = None
|
|
31
|
+
engines: List[str]
|
|
32
|
+
partition_count: int
|
|
33
|
+
fragment_count: int
|
|
34
|
+
has_csv: bool
|
|
35
|
+
has_dashboard: bool
|
|
36
|
+
created_at: Optional[float] = None # unix timestamp from metadata
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ExperimentResult(BaseModel):
|
|
40
|
+
experiment_id: str
|
|
41
|
+
config: Optional[Dict[str, Any]] = None
|
|
42
|
+
summary: ExperimentSummary
|
|
43
|
+
fragments: List[Fragment]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class EngineRanking(BaseModel):
|
|
47
|
+
engine: str
|
|
48
|
+
mean_duration_seconds: float
|
|
49
|
+
median_duration_seconds: float
|
|
50
|
+
p95_duration_seconds: float
|
|
51
|
+
sample_count: int
|
|
52
|
+
rank: int
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CompareResult(BaseModel):
|
|
56
|
+
experiment_id: str
|
|
57
|
+
suite: Optional[str] = None
|
|
58
|
+
partition: Optional[str] = None
|
|
59
|
+
rankings: List[EngineRanking]
|
|
60
|
+
winner: str
|
|
61
|
+
speedup_vs_slowest: float
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ResultsListResponse(BaseModel):
|
|
65
|
+
experiments: List[ExperimentSummary]
|
|
66
|
+
total: int
|
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from fastapi import APIRouter
|
|
2
|
+
|
|
3
|
+
from ..data.catalog_reader import CatalogReader
|
|
4
|
+
from ..models.catalog import CatalogEnginesResponse, CatalogSuitesResponse
|
|
5
|
+
|
|
6
|
+
router = APIRouter(prefix="/v1/catalog", tags=["catalog"])
|
|
7
|
+
_reader = CatalogReader()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@router.get("/engines", response_model=CatalogEnginesResponse)
|
|
11
|
+
def list_engines():
|
|
12
|
+
"""List available database engines and the test suites they support."""
|
|
13
|
+
return _reader.get_engines_response()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.get("/suites", response_model=CatalogSuitesResponse)
|
|
17
|
+
def list_suites():
|
|
18
|
+
"""List all benchmark test suites with their SQL content per engine."""
|
|
19
|
+
return _reader.get_suites_response()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import yaml
|
|
4
|
+
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
|
5
|
+
|
|
6
|
+
from ...constants import CONFIG_ARCHIVE_DIR, EXPERIMENTS_DIR, ROOT_DIR
|
|
7
|
+
from ...coordinator import ExperimentCoordinator
|
|
8
|
+
from ...utils.hasher import generate_experiment_hash
|
|
9
|
+
from ...validator import ExperimentValidator
|
|
10
|
+
from ..data.reader import ResultReader
|
|
11
|
+
from ..models.experiments import ExperimentStatus, ExperimentSubmitRequest, ExperimentSubmitResponse
|
|
12
|
+
|
|
13
|
+
router = APIRouter(prefix="/v1/experiments", tags=["experiments"])
|
|
14
|
+
_reader = ResultReader()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _run_experiment(yaml_path: str):
|
|
18
|
+
coordinator = ExperimentCoordinator(yaml_path, headless=True)
|
|
19
|
+
coordinator.run()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.post("", response_model=ExperimentSubmitResponse, status_code=202)
|
|
23
|
+
def submit_experiment(body: ExperimentSubmitRequest, background_tasks: BackgroundTasks):
|
|
24
|
+
"""
|
|
25
|
+
Submit a new benchmark experiment as a YAML config string.
|
|
26
|
+
Returns immediately with the experiment ID. Use /status to poll progress.
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
config = yaml.safe_load(body.config_yaml)
|
|
30
|
+
except yaml.YAMLError as e:
|
|
31
|
+
raise HTTPException(status_code=422, detail=f"Invalid YAML: {e}")
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
ExperimentValidator.validate(config, source_label="api_submission")
|
|
35
|
+
except ValueError as e:
|
|
36
|
+
raise HTTPException(status_code=422, detail=str(e))
|
|
37
|
+
|
|
38
|
+
exp_id = generate_experiment_hash(config, ROOT_DIR)
|
|
39
|
+
|
|
40
|
+
if os.path.exists(os.path.join(CONFIG_ARCHIVE_DIR, f"config_{exp_id}.yaml")):
|
|
41
|
+
return ExperimentSubmitResponse(
|
|
42
|
+
experiment_id=exp_id,
|
|
43
|
+
status="duplicate",
|
|
44
|
+
detail="Results already exist for this experiment. Retrieve them at /v1/results/{exp_id}",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
queue_dir = os.path.join(EXPERIMENTS_DIR, "queue")
|
|
48
|
+
os.makedirs(queue_dir, exist_ok=True)
|
|
49
|
+
yaml_path = os.path.join(queue_dir, f"{exp_id}.yaml")
|
|
50
|
+
with open(yaml_path, "w") as f:
|
|
51
|
+
yaml.dump(config, f, sort_keys=False)
|
|
52
|
+
|
|
53
|
+
background_tasks.add_task(_run_experiment, yaml_path)
|
|
54
|
+
|
|
55
|
+
return ExperimentSubmitResponse(experiment_id=exp_id, status="queued")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@router.get("/{exp_id}/status", response_model=ExperimentStatus)
|
|
59
|
+
def get_status(exp_id: str):
|
|
60
|
+
"""Check the status of a submitted experiment."""
|
|
61
|
+
fragments = _reader.get_fragments(exp_id)
|
|
62
|
+
has_csv = _reader.has_csv(exp_id)
|
|
63
|
+
|
|
64
|
+
if _reader.is_complete(exp_id):
|
|
65
|
+
status = "complete"
|
|
66
|
+
elif _reader.results_exist(exp_id):
|
|
67
|
+
status = "running"
|
|
68
|
+
elif _reader.is_queued(exp_id):
|
|
69
|
+
status = "queued"
|
|
70
|
+
else:
|
|
71
|
+
status = "not_found"
|
|
72
|
+
|
|
73
|
+
return ExperimentStatus(
|
|
74
|
+
experiment_id=exp_id,
|
|
75
|
+
status=status,
|
|
76
|
+
has_results=_reader.results_exist(exp_id),
|
|
77
|
+
fragment_count=len(fragments),
|
|
78
|
+
has_csv=has_csv,
|
|
79
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Query
|
|
4
|
+
|
|
5
|
+
from ..data.reader import ResultReader
|
|
6
|
+
from ..logic.ranker import recommend_engine
|
|
7
|
+
from ..models.recommend import RecommendResponse
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/v1", tags=["recommend"])
|
|
10
|
+
_reader = ResultReader()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.get("/recommend", response_model=RecommendResponse)
|
|
14
|
+
def recommend(
|
|
15
|
+
suite: Optional[str] = Query(None, description="Test suite name (e.g. analytical_wall)"),
|
|
16
|
+
scale: Optional[str] = Query(None, description="Partition key substring to filter by scale (e.g. 'large')"),
|
|
17
|
+
):
|
|
18
|
+
"""
|
|
19
|
+
Get an engine recommendation based on pre-computed benchmark data.
|
|
20
|
+
Returns the fastest engine for the given suite and scale, with confidence and reasoning.
|
|
21
|
+
"""
|
|
22
|
+
return recommend_engine(_reader, suite=suite, scale=scale)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, HTTPException, Query
|
|
4
|
+
|
|
5
|
+
from ..data.reader import ResultReader
|
|
6
|
+
from ..logic.comparator import compare_experiment
|
|
7
|
+
from ..models.results import CompareResult, ExperimentResult, ResultsListResponse
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/v1/results", tags=["results"])
|
|
10
|
+
_reader = ResultReader()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.get("", response_model=ResultsListResponse)
|
|
14
|
+
def list_results(
|
|
15
|
+
suite: Optional[str] = Query(None, description="Filter by test suite name"),
|
|
16
|
+
engine: Optional[str] = Query(None, description="Filter by engine name"),
|
|
17
|
+
limit: int = Query(50, ge=1, le=500),
|
|
18
|
+
offset: int = Query(0, ge=0),
|
|
19
|
+
):
|
|
20
|
+
"""List completed benchmark experiments, optionally filtered by suite or engine."""
|
|
21
|
+
summaries = _reader.filter_experiments(suite=suite, engine=engine)
|
|
22
|
+
summaries.sort(key=lambda s: s.created_at or 0, reverse=True)
|
|
23
|
+
page = summaries[offset: offset + limit]
|
|
24
|
+
return ResultsListResponse(experiments=page, total=len(summaries))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.get("/{exp_id}", response_model=ExperimentResult)
|
|
28
|
+
def get_result(exp_id: str):
|
|
29
|
+
"""Get full benchmark results for a specific experiment ID."""
|
|
30
|
+
if not _reader.results_exist(exp_id):
|
|
31
|
+
raise HTTPException(status_code=404, detail=f"Experiment '{exp_id}' not found")
|
|
32
|
+
summary = _reader.build_summary(exp_id)
|
|
33
|
+
fragments = _reader.get_fragments(exp_id)
|
|
34
|
+
config = _reader.get_config(exp_id)
|
|
35
|
+
return ExperimentResult(
|
|
36
|
+
experiment_id=exp_id,
|
|
37
|
+
config=config,
|
|
38
|
+
summary=summary,
|
|
39
|
+
fragments=fragments,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.get("/{exp_id}/compare", response_model=CompareResult)
|
|
44
|
+
def compare_result(
|
|
45
|
+
exp_id: str,
|
|
46
|
+
partition: Optional[str] = Query(None, description="Filter to a specific partition key"),
|
|
47
|
+
):
|
|
48
|
+
"""Get a ranked cross-engine performance comparison for an experiment."""
|
|
49
|
+
if not _reader.results_exist(exp_id):
|
|
50
|
+
raise HTTPException(status_code=404, detail=f"Experiment '{exp_id}' not found")
|
|
51
|
+
return compare_experiment(exp_id, _reader, partition=partition)
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import datetime
|
|
3
|
+
import os
|
|
4
|
+
import glob
|
|
5
|
+
import time
|
|
6
|
+
import jinja2
|
|
7
|
+
import statistics
|
|
8
|
+
from dagster import asset, AssetExecutionContext, MaterializeResult, MetadataValue
|
|
9
|
+
from ..partitions import partitions_def, SCENARIO_CONFIG
|
|
10
|
+
from ..constants import RESULTS_DIR
|
|
11
|
+
from ..utils.common import load_context, get_tables_used_in_sql, get_target_sql_dir, infer_metadata_from_sql, get_engine_asset_prefix, get_engine_sql_dialect, get_engine_benchmark_group, get_scoped_asset_name
|
|
12
|
+
|
|
13
|
+
CTX = load_context()
|
|
14
|
+
ACTIVE_ENGINES = CTX['engines']
|
|
15
|
+
EXPERIMENT_META = CTX['meta']
|
|
16
|
+
EXP_ID = EXPERIMENT_META.get("experiment_id", "unknown")
|
|
17
|
+
VALID_TABLES = set(CTX['tables'])
|
|
18
|
+
FULL_CONFIG = CTX['full_config']
|
|
19
|
+
REPLICATION_FACTOR = FULL_CONFIG.get("execution", {}).get("replication", 1)
|
|
20
|
+
|
|
21
|
+
def _smart_cast(val):
|
|
22
|
+
if isinstance(val, (int, float, bool)): return val
|
|
23
|
+
return str(val)
|
|
24
|
+
|
|
25
|
+
def write_benchmark_fragment(experiment_id, run_id, engine, asset_name, pk, durations, params):
|
|
26
|
+
"""
|
|
27
|
+
Writes the atomic result fragment to disk.
|
|
28
|
+
Isolates the 'Scientific Proof' logic from the Dagster asset.
|
|
29
|
+
"""
|
|
30
|
+
# Results are isolated by experiment_id in RESULTS_DIR
|
|
31
|
+
fragment_path = os.path.join(
|
|
32
|
+
RESULTS_DIR,
|
|
33
|
+
experiment_id,
|
|
34
|
+
"fragments",
|
|
35
|
+
f"{asset_name}__{pk}.json"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
os.makedirs(os.path.dirname(fragment_path), exist_ok=True)
|
|
39
|
+
|
|
40
|
+
payload = {
|
|
41
|
+
"meta": {
|
|
42
|
+
"timestamp": datetime.datetime.now().isoformat(),
|
|
43
|
+
"experiment_id": experiment_id,
|
|
44
|
+
"dagster_run_id": run_id,
|
|
45
|
+
"engine": engine,
|
|
46
|
+
"asset": asset_name,
|
|
47
|
+
"partition": pk
|
|
48
|
+
},
|
|
49
|
+
"metrics": {
|
|
50
|
+
"duration_seconds": statistics.mean(durations),
|
|
51
|
+
# Raw per-replication measurements: the spread under identical
|
|
52
|
+
# conditions is itself evidence (thermal drift, scheduler noise,
|
|
53
|
+
# cold-cache approximation). Mean alone hides it.
|
|
54
|
+
"durations_raw": durations,
|
|
55
|
+
"replication_factor": REPLICATION_FACTOR
|
|
56
|
+
},
|
|
57
|
+
"parameters": params # The "Jagged" Context
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
with open(fragment_path, "w") as f:
|
|
61
|
+
json.dump(payload, f, default=str, indent=2)
|
|
62
|
+
|
|
63
|
+
return fragment_path
|
|
64
|
+
|
|
65
|
+
def make_benchmark_asset(name, engine, used_tables, raw_template, static_meta, extra_context=None):
|
|
66
|
+
prefix = get_engine_asset_prefix(engine)
|
|
67
|
+
deps = [get_scoped_asset_name(f"{prefix}{t}_table", EXP_ID) for t in used_tables]
|
|
68
|
+
asset_base_name = f"{prefix}benchmark_{name}"
|
|
69
|
+
asset_scoped_name = get_scoped_asset_name(asset_base_name, EXP_ID)
|
|
70
|
+
tags = {}
|
|
71
|
+
|
|
72
|
+
# Single-Lane Limit for engines with exclusive infrastructure:
|
|
73
|
+
# postgres restarts a shared Docker container; quack variants bind fixed ports.
|
|
74
|
+
if engine in ("postgres", "quack", "quack_pushdown"):
|
|
75
|
+
tags["dagster/concurrency_key"] = f"{engine}_exclusive"
|
|
76
|
+
tags["experiment_scope"] = "partitioned"
|
|
77
|
+
|
|
78
|
+
@asset(
|
|
79
|
+
name=asset_scoped_name,
|
|
80
|
+
partitions_def=partitions_def,
|
|
81
|
+
deps=deps,
|
|
82
|
+
group_name=get_engine_benchmark_group(engine),
|
|
83
|
+
required_resource_keys={engine},
|
|
84
|
+
op_tags=tags
|
|
85
|
+
)
|
|
86
|
+
def _benchmark(context: AssetExecutionContext):
|
|
87
|
+
db = getattr(context.resources, engine)
|
|
88
|
+
pk = context.partition_key
|
|
89
|
+
params = SCENARIO_CONFIG.get(pk, {})
|
|
90
|
+
# Each engine receives ONLY its own namespace of engine_params
|
|
91
|
+
# (assembled by config_loader from execution.engine_params + namespaced
|
|
92
|
+
# matrix dimensions like 'postgres.work_mem').
|
|
93
|
+
engine_params = params.get("engine_params", {}).get(engine, {})
|
|
94
|
+
dims = {k: v for k, v in params.items() if k != "engine_params"}
|
|
95
|
+
|
|
96
|
+
# SQL render — dims feeds template variables; engine_params stays out of it
|
|
97
|
+
render_ctx = {f"{t}_table": f"{t}_{pk}" for t in used_tables}
|
|
98
|
+
render_ctx.update(dims)
|
|
99
|
+
sql = jinja2.Template(raw_template).render(render_ctx)
|
|
100
|
+
|
|
101
|
+
# 3. Execution (Replicated)
|
|
102
|
+
# None return means the engine signalled a non-fatal failure (e.g. TypeDB
|
|
103
|
+
# stack overflow on recursive queries). We stop after the first None and
|
|
104
|
+
# record the run as DNF rather than crashing the entire Dagster step.
|
|
105
|
+
durations = []
|
|
106
|
+
dnf = False
|
|
107
|
+
for _ in range(REPLICATION_FACTOR):
|
|
108
|
+
duration = db.run_query(sql=sql, partition_key=pk, engine_params=engine_params)
|
|
109
|
+
if duration is None:
|
|
110
|
+
dnf = True
|
|
111
|
+
break
|
|
112
|
+
durations.append(duration)
|
|
113
|
+
|
|
114
|
+
if dnf:
|
|
115
|
+
context.log.warning(
|
|
116
|
+
f"Engine '{engine}' returned None for partition '{pk}' — "
|
|
117
|
+
f"recording as DNF (did-not-finish). "
|
|
118
|
+
f"Likely cause: server crash / stack overflow / OOM during query evaluation."
|
|
119
|
+
)
|
|
120
|
+
# Write a sentinel fragment directly (bypass write_benchmark_fragment
|
|
121
|
+
# which requires at least one duration measurement)
|
|
122
|
+
experiment_id = EXPERIMENT_META.get("experiment_id", "unknown")
|
|
123
|
+
fragment_path = os.path.join(
|
|
124
|
+
RESULTS_DIR, experiment_id, "fragments",
|
|
125
|
+
f"{asset_scoped_name}__{pk}.json"
|
|
126
|
+
)
|
|
127
|
+
os.makedirs(os.path.dirname(fragment_path), exist_ok=True)
|
|
128
|
+
import json as _json, datetime as _dt
|
|
129
|
+
_json.dump({
|
|
130
|
+
"meta": {
|
|
131
|
+
"timestamp": _dt.datetime.now().isoformat(),
|
|
132
|
+
"experiment_id": experiment_id,
|
|
133
|
+
"dagster_run_id": context.run.run_id,
|
|
134
|
+
"engine": engine,
|
|
135
|
+
"asset": asset_scoped_name,
|
|
136
|
+
"partition": pk,
|
|
137
|
+
},
|
|
138
|
+
"metrics": {"duration_seconds": None, "durations_raw": [], "replication_factor": 0, "dnf": True},
|
|
139
|
+
# dnf lives in metrics; duplicating it into parameters leaked a
|
|
140
|
+
# redundant lowercase 'dnf' column into the flattened CSV.
|
|
141
|
+
"parameters": params,
|
|
142
|
+
}, open(fragment_path, "w"), default=str, indent=2)
|
|
143
|
+
return MaterializeResult(metadata={
|
|
144
|
+
"dnf": MetadataValue.bool(True),
|
|
145
|
+
"engine": MetadataValue.text(engine),
|
|
146
|
+
"partition": MetadataValue.text(pk),
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
# 4. Write Fragment (The Isolated Call)
|
|
150
|
+
experiment_id = EXPERIMENT_META.get("experiment_id", "unknown")
|
|
151
|
+
|
|
152
|
+
write_benchmark_fragment(
|
|
153
|
+
experiment_id=experiment_id,
|
|
154
|
+
run_id=context.run.run_id,
|
|
155
|
+
engine=engine,
|
|
156
|
+
asset_name=asset_scoped_name,
|
|
157
|
+
pk=pk,
|
|
158
|
+
durations=durations,
|
|
159
|
+
params=dims,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
meta = {
|
|
163
|
+
"duration": MetadataValue.float(statistics.mean(durations)),
|
|
164
|
+
"sql": MetadataValue.md(f"```sql\n{sql}\n```"),
|
|
165
|
+
"experiment_id": experiment_id,
|
|
166
|
+
"config_engine": engine,
|
|
167
|
+
**{k: _smart_cast(v) for k, v in static_meta.items()},
|
|
168
|
+
**{f"dim_{k}": _smart_cast(v) for k, v in dims.items()},
|
|
169
|
+
}
|
|
170
|
+
return MaterializeResult(metadata=meta)
|
|
171
|
+
|
|
172
|
+
return _benchmark
|
|
173
|
+
|
|
174
|
+
def get_benchmark_assets():
|
|
175
|
+
assets = []
|
|
176
|
+
|
|
177
|
+
if not CTX:
|
|
178
|
+
return []
|
|
179
|
+
|
|
180
|
+
ACTIVE_ENGINES = CTX.get('engines')
|
|
181
|
+
if not ACTIVE_ENGINES:
|
|
182
|
+
return []
|
|
183
|
+
|
|
184
|
+
FULL_CONFIG = CTX.get('full_config', {})
|
|
185
|
+
dataset_cfg = CTX.get('dataset_config', {})
|
|
186
|
+
VALID_TABLES = CTX.get('tables', set())
|
|
187
|
+
|
|
188
|
+
target_dir = get_target_sql_dir(FULL_CONFIG)
|
|
189
|
+
|
|
190
|
+
for engine in ACTIVE_ENGINES:
|
|
191
|
+
path = os.path.join(target_dir, get_engine_sql_dialect(engine))
|
|
192
|
+
if not os.path.exists(path): continue
|
|
193
|
+
|
|
194
|
+
for f in glob.glob(os.path.join(path, "*.sql")):
|
|
195
|
+
if os.path.getsize(f) == 0:
|
|
196
|
+
print(f"[WARN] Skipping empty benchmark file: {f}")
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
base = os.path.basename(f).replace(".sql", "")
|
|
200
|
+
tables, raw = get_tables_used_in_sql(f, VALID_TABLES)
|
|
201
|
+
static_meta = infer_metadata_from_sql(raw, dataset_cfg)
|
|
202
|
+
|
|
203
|
+
# Context Injection: Enable {{ column_name }} in SQL
|
|
204
|
+
col_ctx = {}
|
|
205
|
+
if dataset_cfg and 'tables' in dataset_cfg:
|
|
206
|
+
for t in tables:
|
|
207
|
+
t_def = dataset_cfg['tables'].get(t, {})
|
|
208
|
+
for c in t_def.get('columns', []):
|
|
209
|
+
col_ctx[c['name']] = c['name']
|
|
210
|
+
|
|
211
|
+
asset_wrapper = make_benchmark_asset(base, engine, tables, raw, static_meta, extra_context=col_ctx)
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
asset_obj = asset_wrapper.to_asset_def()
|
|
215
|
+
except AttributeError:
|
|
216
|
+
asset_obj = asset_wrapper
|
|
217
|
+
|
|
218
|
+
assets.append(asset_obj)
|
|
219
|
+
|
|
220
|
+
return assets
|
|
221
|
+
|
|
222
|
+
benchmark_assets = get_benchmark_assets()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import os
|
|
3
|
+
from dagster import asset, AssetExecutionContext
|
|
4
|
+
from ..partitions import partitions_def, SCENARIO_CONFIG
|
|
5
|
+
from ..constants import DATA_DIR
|
|
6
|
+
from ..utils.common import load_context, get_data_dependencies, get_scoped_asset_name
|
|
7
|
+
|
|
8
|
+
from ..plugins.data_sources import declarative_gen
|
|
9
|
+
|
|
10
|
+
# 1. LOAD CONTEXT
|
|
11
|
+
CTX = load_context()
|
|
12
|
+
EXP_ID = CTX['meta'].get("experiment_id", "unknown")
|
|
13
|
+
|
|
14
|
+
OUTPUT_DIR = os.path.join(DATA_DIR, "staging")
|
|
15
|
+
|
|
16
|
+
def make_data_asset(table_name):
|
|
17
|
+
|
|
18
|
+
raw_deps = get_data_dependencies(table_name, CTX['table_defs'])
|
|
19
|
+
asset_deps = [get_scoped_asset_name(f"{d}_parquet", EXP_ID) for d in raw_deps]
|
|
20
|
+
scoped_name = get_scoped_asset_name(f"{table_name}_parquet", EXP_ID)
|
|
21
|
+
|
|
22
|
+
@asset(
|
|
23
|
+
name=scoped_name,
|
|
24
|
+
partitions_def=partitions_def,
|
|
25
|
+
group_name="data_generation",
|
|
26
|
+
deps=asset_deps,
|
|
27
|
+
description=f"Generates {table_name}"
|
|
28
|
+
)
|
|
29
|
+
def _generator(context: AssetExecutionContext):
|
|
30
|
+
partition_key = context.partition_key
|
|
31
|
+
params = SCENARIO_CONFIG[partition_key]
|
|
32
|
+
|
|
33
|
+
# Load Plugin from Config
|
|
34
|
+
plugin_name = CTX['dataset_config']['source']
|
|
35
|
+
module = importlib.import_module(plugin_name)
|
|
36
|
+
|
|
37
|
+
# 1. Construct the path
|
|
38
|
+
file_name = f"{table_name}_{partition_key}.parquet"
|
|
39
|
+
target_path = os.path.join(OUTPUT_DIR, file_name)
|
|
40
|
+
|
|
41
|
+
# 2. Ensure folder exists
|
|
42
|
+
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
|
43
|
+
|
|
44
|
+
# 3. Call Plugin
|
|
45
|
+
return module.generate(
|
|
46
|
+
context=context,
|
|
47
|
+
params=params,
|
|
48
|
+
table_name=table_name,
|
|
49
|
+
target_path=target_path,
|
|
50
|
+
dataset_config=CTX['dataset_config']
|
|
51
|
+
)
|
|
52
|
+
return _generator
|
|
53
|
+
|
|
54
|
+
data_assets = [make_data_asset(t) for t in CTX['tables']]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from dagster import asset, AssetExecutionContext, MetadataValue, Output
|
|
2
|
+
from typing import List
|
|
3
|
+
import polars as pl
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from ..constants import DATA_DIR, RESULTS_DIR
|
|
8
|
+
from ..utils.common import load_context, get_scoped_asset_name
|
|
9
|
+
from ..partitions import partitions_def
|
|
10
|
+
|
|
11
|
+
# Resolved inside assets to stay dynamic
|
|
12
|
+
|
|
13
|
+
CTX = load_context()
|
|
14
|
+
EXP_ID = CTX['meta'].get("experiment_id", "unknown")
|
|
15
|
+
|
|
16
|
+
quality_assets: List[object] = []
|
|
17
|
+
|
|
18
|
+
def make_quality_asset(table_name):
|
|
19
|
+
"""
|
|
20
|
+
Creates a validation asset for a specific table.
|
|
21
|
+
Reads the staging Parquet file and verifies stats.
|
|
22
|
+
"""
|
|
23
|
+
scoped_name = get_scoped_asset_name(f"{table_name}_quality", EXP_ID)
|
|
24
|
+
deps = [get_scoped_asset_name(f"{table_name}_parquet", EXP_ID)]
|
|
25
|
+
|
|
26
|
+
@asset(
|
|
27
|
+
name=scoped_name,
|
|
28
|
+
group_name="data_generation",
|
|
29
|
+
partitions_def=partitions_def,
|
|
30
|
+
deps=deps,
|
|
31
|
+
description=f"Validates {table_name} distribution."
|
|
32
|
+
)
|
|
33
|
+
def _validate(context: AssetExecutionContext):
|
|
34
|
+
partition_key = context.partition_key
|
|
35
|
+
|
|
36
|
+
# Resolve EXP_ID inside the function for dynamic behavior (essential for testing)
|
|
37
|
+
ctx = load_context()
|
|
38
|
+
current_exp_id = ctx['meta'].get("experiment_id", "unknown")
|
|
39
|
+
|
|
40
|
+
# 2. LOAD DATA
|
|
41
|
+
filename = f"{table_name}_{partition_key}.parquet"
|
|
42
|
+
parquet_path = os.path.join(DATA_DIR, "staging", filename)
|
|
43
|
+
|
|
44
|
+
if not os.path.exists(parquet_path):
|
|
45
|
+
raise FileNotFoundError(f"Staging file not found: {parquet_path}")
|
|
46
|
+
|
|
47
|
+
# Read Data
|
|
48
|
+
df = pl.read_parquet(parquet_path)
|
|
49
|
+
row_count = df.height
|
|
50
|
+
|
|
51
|
+
stats = {
|
|
52
|
+
"rows": row_count,
|
|
53
|
+
"columns": {}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Compute Stats
|
|
57
|
+
for col in df.columns:
|
|
58
|
+
null_count = df[col].null_count()
|
|
59
|
+
col_stats = {
|
|
60
|
+
"null_count": null_count,
|
|
61
|
+
"null_percent": null_count / row_count if row_count > 0 else 0,
|
|
62
|
+
"cardinality": df[col].n_unique(),
|
|
63
|
+
"dtype": str(df[col].dtype)
|
|
64
|
+
}
|
|
65
|
+
stats["columns"][col] = col_stats
|
|
66
|
+
|
|
67
|
+
if row_count == 0:
|
|
68
|
+
raise ValueError(f"Table {table_name} is empty!")
|
|
69
|
+
|
|
70
|
+
# Results are isolated by current_exp_id in RESULTS_DIR
|
|
71
|
+
target_path = os.path.join(RESULTS_DIR, current_exp_id, "data_stats", f"{table_name}_{partition_key}.stats.json")
|
|
72
|
+
stats_dir = os.path.dirname(target_path)
|
|
73
|
+
os.makedirs(stats_dir, exist_ok=True)
|
|
74
|
+
|
|
75
|
+
with open(target_path, "w") as f:
|
|
76
|
+
json.dump(stats, f, indent=2)
|
|
77
|
+
|
|
78
|
+
return Output(
|
|
79
|
+
value=target_path,
|
|
80
|
+
metadata={
|
|
81
|
+
"stats": MetadataValue.json(stats),
|
|
82
|
+
"profile_path": MetadataValue.path(target_path),
|
|
83
|
+
"archive_location": MetadataValue.path(stats_dir)
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
return _validate
|
|
87
|
+
|
|
88
|
+
# Generate for all tables
|
|
89
|
+
if CTX:
|
|
90
|
+
for t in CTX.get('tables', []):
|
|
91
|
+
quality_assets.append(make_quality_asset(t))
|