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.
Files changed (153) hide show
  1. infrastructure/debug_aws.py +40 -0
  2. scripts/dev/backfill_queries.py +44 -0
  3. scripts/dev/timestamp_capsule.py +49 -0
  4. scripts/dev/upgrade_capsule.py +63 -0
  5. scripts/dev/verify_capsule.py +79 -0
  6. scripts/dev/verify_portability.py +103 -0
  7. scripts/tools/_plotlib.py +43 -0
  8. scripts/tools/analyze_scaling.py +39 -0
  9. scripts/tools/demo_breach_detection.py +61 -0
  10. scripts/tools/extract_results.py +57 -0
  11. scripts/tools/find_kink.py +104 -0
  12. scripts/tools/gen_experiment_catalog.py +103 -0
  13. scripts/tools/load_parquet_to_local.py +98 -0
  14. scripts/tools/plot_execution_modes.py +82 -0
  15. scripts/tools/plot_scaling.py +91 -0
  16. scripts/tools/plot_selectivity.py +139 -0
  17. scripts/tools/plot_threads.py +95 -0
  18. scripts/tools/regenerate_dashboard.py +58 -0
  19. scripts/tools/verify_doc_claims.py +88 -0
  20. sql_benchmarks/__init__.py +3 -0
  21. sql_benchmarks/api/__init__.py +0 -0
  22. sql_benchmarks/api/app.py +30 -0
  23. sql_benchmarks/api/data/__init__.py +0 -0
  24. sql_benchmarks/api/data/catalog_reader.py +73 -0
  25. sql_benchmarks/api/data/reader.py +125 -0
  26. sql_benchmarks/api/logic/__init__.py +0 -0
  27. sql_benchmarks/api/logic/comparator.py +44 -0
  28. sql_benchmarks/api/logic/ranker.py +127 -0
  29. sql_benchmarks/api/models/__init__.py +0 -0
  30. sql_benchmarks/api/models/catalog.py +22 -0
  31. sql_benchmarks/api/models/experiments.py +20 -0
  32. sql_benchmarks/api/models/recommend.py +11 -0
  33. sql_benchmarks/api/models/results.py +66 -0
  34. sql_benchmarks/api/routers/__init__.py +0 -0
  35. sql_benchmarks/api/routers/catalog.py +19 -0
  36. sql_benchmarks/api/routers/experiments.py +79 -0
  37. sql_benchmarks/api/routers/recommend.py +22 -0
  38. sql_benchmarks/api/routers/results.py +51 -0
  39. sql_benchmarks/assets/__init__.py +4 -0
  40. sql_benchmarks/assets/benchmark_factory.py +222 -0
  41. sql_benchmarks/assets/data_factory.py +54 -0
  42. sql_benchmarks/assets/data_quality.py +91 -0
  43. sql_benchmarks/assets/ingestion_factory.py +88 -0
  44. sql_benchmarks/assets/maintenance.py +63 -0
  45. sql_benchmarks/assets/reporting.py +385 -0
  46. sql_benchmarks/assets/semantic_gate.py +76 -0
  47. sql_benchmarks/cli.py +69 -0
  48. sql_benchmarks/config_loader.py +104 -0
  49. sql_benchmarks/constants.py +60 -0
  50. sql_benchmarks/coordinator.py +282 -0
  51. sql_benchmarks/definitions.py +115 -0
  52. sql_benchmarks/experiments/templates/experiment_template.yaml +99 -0
  53. sql_benchmarks/harness.py +84 -0
  54. sql_benchmarks/jobs.py +8 -0
  55. sql_benchmarks/partitions.py +17 -0
  56. sql_benchmarks/plugins/data_sources/declarative_gen.py +211 -0
  57. sql_benchmarks/plugins/data_sources/local_file_loader.py +62 -0
  58. sql_benchmarks/plugins/data_sources/tpc_h.py +61 -0
  59. sql_benchmarks/resources/__init__.py +0 -0
  60. sql_benchmarks/resources/actian.py +234 -0
  61. sql_benchmarks/resources/actian_client.py +231 -0
  62. sql_benchmarks/resources/base_engine.py +58 -0
  63. sql_benchmarks/resources/base_schema_client.py +104 -0
  64. sql_benchmarks/resources/duckdb.py +58 -0
  65. sql_benchmarks/resources/duckdb_client.py +157 -0
  66. sql_benchmarks/resources/postgres.py +188 -0
  67. sql_benchmarks/resources/postgres_client.py +184 -0
  68. sql_benchmarks/resources/quack.py +56 -0
  69. sql_benchmarks/resources/quack_client.py +169 -0
  70. sql_benchmarks/resources/typedb_client.py +370 -0
  71. sql_benchmarks/resources/typedb_engine.py +303 -0
  72. sql_benchmarks/scripts/__init__.py +0 -0
  73. sql_benchmarks/scripts/sql/acid_test/duckdb/test.sql +1 -0
  74. sql_benchmarks/scripts/sql/acid_test/postgres/test.sql +1 -0
  75. sql_benchmarks/scripts/sql/analytical_wall/actian/analytical_wall.sql +15 -0
  76. sql_benchmarks/scripts/sql/analytical_wall/analytical_wall.sql.template +19 -0
  77. sql_benchmarks/scripts/sql/analytical_wall/duckdb/analytical_wall.sql +15 -0
  78. sql_benchmarks/scripts/sql/analytical_wall/postgres/analytical_wall.sql +15 -0
  79. sql_benchmarks/scripts/sql/group_by/duckdb/groupby_antipattern.sql +11 -0
  80. sql_benchmarks/scripts/sql/group_by/duckdb/groupby_recommended_cte.sql +19 -0
  81. sql_benchmarks/scripts/sql/group_by/postgres/groupby_antipattern.sql +13 -0
  82. sql_benchmarks/scripts/sql/group_by/postgres/groupby_recommended_pk.sql +11 -0
  83. sql_benchmarks/scripts/sql/hypergraph/duckdb/q_1hop.sql +5 -0
  84. sql_benchmarks/scripts/sql/hypergraph/duckdb/q_2hop.sql +6 -0
  85. sql_benchmarks/scripts/sql/hypergraph/duckdb/q_full.sql +9 -0
  86. sql_benchmarks/scripts/sql/hypergraph/typedb/q_1hop.sql +4 -0
  87. sql_benchmarks/scripts/sql/hypergraph/typedb/q_2hop.sql +5 -0
  88. sql_benchmarks/scripts/sql/hypergraph/typedb/q_full.sql +6 -0
  89. sql_benchmarks/scripts/sql/joins/duckdb/join_antipattern.sql +5 -0
  90. sql_benchmarks/scripts/sql/joins/duckdb/join_recommended.sql +4 -0
  91. sql_benchmarks/scripts/sql/joins/postgres/join_antipattern.sql +5 -0
  92. sql_benchmarks/scripts/sql/joins/postgres/join_recommended.sql +4 -0
  93. sql_benchmarks/scripts/sql/null_logic/duckdb/2VL_identity.sql +14 -0
  94. sql_benchmarks/scripts/sql/null_logic/duckdb/3VL_identity.sql +5 -0
  95. sql_benchmarks/scripts/sql/null_logic/postgres/2VL_identity.sql +14 -0
  96. sql_benchmarks/scripts/sql/null_logic/postgres/3VL_identity.sql +5 -0
  97. sql_benchmarks/scripts/sql/null_sentinel/duckdb/2VL_identity.sql +14 -0
  98. sql_benchmarks/scripts/sql/null_sentinel/duckdb/3VL_identity.sql +5 -0
  99. sql_benchmarks/scripts/sql/null_sentinel/duckdb/sentinel_optimization.sql +16 -0
  100. sql_benchmarks/scripts/sql/null_sentinel/postgres/2VL_identity.sql +14 -0
  101. sql_benchmarks/scripts/sql/null_sentinel/postgres/3VL_identity.sql +5 -0
  102. sql_benchmarks/scripts/sql/null_sentinel/postgres/sentinel_optimization.sql +21 -0
  103. sql_benchmarks/scripts/sql/recursion/duckdb/recursive_cte.sql +26 -0
  104. sql_benchmarks/scripts/sql/recursion/postgres/recursive_cte.sql +30 -0
  105. sql_benchmarks/scripts/sql/recursive_graph/duckdb/q_bounded_3hop.sql +17 -0
  106. sql_benchmarks/scripts/sql/recursive_graph/duckdb/q_fixed_2hop.sql +6 -0
  107. sql_benchmarks/scripts/sql/recursive_graph/duckdb/q_transitive_closure.sql +17 -0
  108. sql_benchmarks/scripts/sql/recursive_graph/typedb/q_bounded_3hop.sql +6 -0
  109. sql_benchmarks/scripts/sql/recursive_graph/typedb/q_fixed_2hop.sql +5 -0
  110. sql_benchmarks/scripts/sql/recursive_graph/typedb/q_transitive_closure.sql +7 -0
  111. sql_benchmarks/scripts/sql/security/acid_resilience/duckdb/test.sql +1 -0
  112. sql_benchmarks/scripts/sql/selectivity/duckdb/q_0_1_percent.sql +1 -0
  113. sql_benchmarks/scripts/sql/selectivity/duckdb/q_10_percent.sql +1 -0
  114. sql_benchmarks/scripts/sql/selectivity/duckdb/q_1_percent.sql +1 -0
  115. sql_benchmarks/scripts/sql/selectivity/duckdb/q_20_percent.sql +1 -0
  116. sql_benchmarks/scripts/sql/selectivity/duckdb/q_5_percent.sql +1 -0
  117. sql_benchmarks/scripts/sql/selectivity/duckdb/q_filler.sql +1 -0
  118. sql_benchmarks/scripts/sql/selectivity/postgres/q_0_1_percent.sql +1 -0
  119. sql_benchmarks/scripts/sql/selectivity/postgres/q_10_percent.sql +1 -0
  120. sql_benchmarks/scripts/sql/selectivity/postgres/q_1_percent.sql +1 -0
  121. sql_benchmarks/scripts/sql/selectivity/postgres/q_20_percent.sql +1 -0
  122. sql_benchmarks/scripts/sql/selectivity/postgres/q_5_percent.sql +1 -0
  123. sql_benchmarks/scripts/sql/selectivity/postgres/q_filler.sql +1 -0
  124. sql_benchmarks/scripts/sql/selectivity/typedb/q_0_1_percent.sql +4 -0
  125. sql_benchmarks/scripts/sql/selectivity/typedb/q_10_percent.sql +4 -0
  126. sql_benchmarks/scripts/sql/selectivity/typedb/q_1_percent.sql +4 -0
  127. sql_benchmarks/scripts/sql/selectivity/typedb/q_20_percent.sql +4 -0
  128. sql_benchmarks/scripts/sql/selectivity/typedb/q_5_percent.sql +4 -0
  129. sql_benchmarks/scripts/sql/selectivity/typedb/q_filler.sql +4 -0
  130. sql_benchmarks/scripts/sql/sort_spill/duckdb/sort_full.sql +25 -0
  131. sql_benchmarks/scripts/sql/sort_spill/duckdb/sort_wide_rows.sql +25 -0
  132. sql_benchmarks/scripts/sql/sort_spill/postgres/sort_full.sql +25 -0
  133. sql_benchmarks/scripts/sql/sort_spill/postgres/sort_wide_rows.sql +26 -0
  134. sql_benchmarks/scripts/sql/tpch/duckdb/q3_shipping_priority.sql +25 -0
  135. sql_benchmarks/scripts/sql/tpch/postgres/q3_shipping_priority.sql +25 -0
  136. sql_benchmarks/utils/__init__.py +0 -0
  137. sql_benchmarks/utils/common.py +260 -0
  138. sql_benchmarks/utils/ddl.py +47 -0
  139. sql_benchmarks/utils/hasher.py +124 -0
  140. sql_benchmarks/utils/integrity_monitor.py +55 -0
  141. sql_benchmarks/utils/providers.py +184 -0
  142. sql_benchmarks/utils/scaling.py +88 -0
  143. sql_benchmarks/utils/schema.py +118 -0
  144. sql_benchmarks/utils/semantic_auditor.py +81 -0
  145. sql_benchmarks/utils/system.py +100 -0
  146. sql_benchmarks/validator.py +60 -0
  147. sqlbenchdag-0.1.0.dist-info/METADATA +221 -0
  148. sqlbenchdag-0.1.0.dist-info/RECORD +153 -0
  149. sqlbenchdag-0.1.0.dist-info/WHEEL +5 -0
  150. sqlbenchdag-0.1.0.dist-info/entry_points.txt +2 -0
  151. sqlbenchdag-0.1.0.dist-info/licenses/LICENSE +202 -0
  152. sqlbenchdag-0.1.0.dist-info/top_level.txt +4 -0
  153. 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,4 @@
1
+ from . import data_factory
2
+ from . import ingestion_factory
3
+ from . import benchmark_factory
4
+ from . import data_quality
@@ -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))