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,231 @@
1
+ import os
2
+ import time
3
+ import tempfile
4
+ import polars as pl
5
+ from typing import Dict, Any, Optional
6
+
7
+
8
+ class ActianClient:
9
+ """
10
+ Stateful Client for Actian Vector on EC2.
11
+
12
+ Executes queries via SSH and handles bulk loading via SCP + vwload.
13
+ """
14
+
15
+ def __init__(self, connection_params: Dict[str, Any], ssh_params: Dict[str, Any]):
16
+ self.connection_params = connection_params
17
+ self.ssh_params = ssh_params
18
+ self._ssh_client = None
19
+
20
+ def _ensure_ssh(self):
21
+ """Establishes SSH client for remote commands."""
22
+ if self._ssh_client is not None:
23
+ try:
24
+ transport = self._ssh_client.get_transport()
25
+ if transport and transport.is_active():
26
+ return
27
+ except Exception:
28
+ pass
29
+ self._ssh_client = None
30
+
31
+ import paramiko
32
+
33
+ self._ssh_client = paramiko.SSHClient()
34
+ self._ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
35
+ self._ssh_client.connect(
36
+ hostname=self.ssh_params["ec2_host"],
37
+ username=self.ssh_params["ec2_user"],
38
+ key_filename=self.ssh_params["ssh_key_path"],
39
+ timeout=30
40
+ )
41
+
42
+ def _ssh_exec(self, command: str, timeout: int = 300) -> tuple[str, str, int]:
43
+ """Execute a command on the remote EC2 instance."""
44
+ self._ensure_ssh()
45
+
46
+ stdin, stdout, stderr = self._ssh_client.exec_command(command, timeout=timeout)
47
+ exit_code = stdout.channel.recv_exit_status()
48
+
49
+ return stdout.read().decode(), stderr.read().decode(), exit_code
50
+
51
+ def _scp_to_remote(self, local_path: str, remote_path: str):
52
+ """Copy a file to the remote EC2 instance."""
53
+ self._ensure_ssh()
54
+
55
+ from scp import SCPClient
56
+
57
+ # Ensure remote directory exists
58
+ remote_dir = os.path.dirname(remote_path)
59
+ self._ssh_exec(f"mkdir -p {remote_dir}")
60
+
61
+ with SCPClient(self._ssh_client.get_transport()) as scp:
62
+ scp.put(local_path, remote_path)
63
+
64
+ print(f"[Actian] Uploaded {local_path} -> {remote_path}")
65
+
66
+ def run_query(self, sql: str, scenario_params: Dict[str, Any]) -> Optional[float]:
67
+ """
68
+ Execute a SQL query on Actian Vector via SSH.
69
+
70
+ Uses the native `sql` command on EC2 for reliable execution
71
+ without requiring local ODBC drivers.
72
+ """
73
+ database = self.connection_params["database"]
74
+ sql_path = self.ssh_params["actian_sql_path"]
75
+
76
+ # Escape single quotes in SQL for shell
77
+ escaped_sql = sql.replace("'", "'\\''")
78
+
79
+ # Build command: echo SQL | sql database
80
+ # The -s flag suppresses headers for cleaner output
81
+ command = f"echo '{escaped_sql}' | {sql_path} {database}"
82
+
83
+ start = time.time()
84
+ stdout, stderr, exit_code = self._ssh_exec(command, timeout=600)
85
+ duration = time.time() - start
86
+
87
+ if exit_code != 0:
88
+ raise RuntimeError(f"Actian query failed: {stderr}\nSQL: {sql[:200]}...")
89
+
90
+ return duration
91
+
92
+ def bulk_load(self, file_path: str, table_name: str, partition_key: str = None):
93
+ """
94
+ High-performance bulk load into Actian Vector using vwload.
95
+
96
+ Steps:
97
+ 1. Convert Parquet to CSV locally (vwload prefers CSV)
98
+ 2. SCP the CSV to EC2
99
+ 3. Create table schema on Actian
100
+ 4. Run vwload on EC2
101
+ 5. Cleanup remote files
102
+ """
103
+ print(f"[Actian] Bulk loading {file_path} into table '{table_name}'...")
104
+
105
+ remote_data_dir = self.ssh_params["remote_data_dir"]
106
+ vwload_path = self.ssh_params["vwload_path"]
107
+ database = self.connection_params["database"]
108
+
109
+ # 1. Read schema and convert to CSV
110
+ df = pl.read_parquet(file_path)
111
+
112
+ with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as tmp:
113
+ local_csv_path = tmp.name
114
+ df.write_csv(local_csv_path, include_header=True)
115
+
116
+ try:
117
+ # 2. SCP to EC2
118
+ remote_csv_path = f"{remote_data_dir}/{table_name}.csv"
119
+ self._scp_to_remote(local_csv_path, remote_csv_path)
120
+
121
+ # 3. Create table schema
122
+ self._create_table_schema(table_name, df)
123
+
124
+ # 4. Run vwload
125
+ # vwload syntax: vwload [options] dbname tablename filename
126
+ vwload_cmd = (
127
+ f"{vwload_path} "
128
+ f"--fdelim ',' "
129
+ f"--header "
130
+ f"--log /tmp/vwload_{table_name}.log "
131
+ f"{database} {table_name} {remote_csv_path}"
132
+ )
133
+
134
+ print(f"[Actian] Running vwload: {vwload_cmd}")
135
+ stdout, stderr, exit_code = self._ssh_exec(vwload_cmd, timeout=600)
136
+
137
+ if exit_code != 0:
138
+ # Try to get the log file for more details
139
+ log_stdout, _, _ = self._ssh_exec(f"cat /tmp/vwload_{table_name}.log 2>/dev/null || echo 'No log'")
140
+ raise RuntimeError(
141
+ f"vwload failed (exit {exit_code}):\n"
142
+ f"stderr: {stderr}\n"
143
+ f"log: {log_stdout}"
144
+ )
145
+
146
+ print(f"[Actian] Loaded {df.height} rows into {table_name}")
147
+
148
+ # 5. Optimize table for analytics
149
+ self._optimize_table(table_name)
150
+
151
+ # 6. Cleanup remote CSV
152
+ self._ssh_exec(f"rm -f {remote_csv_path}")
153
+
154
+ finally:
155
+ # Cleanup local temp file
156
+ if os.path.exists(local_csv_path):
157
+ os.remove(local_csv_path)
158
+
159
+ def _create_table_schema(self, table_name: str, df: pl.DataFrame):
160
+ """Create table schema in Actian based on Polars DataFrame."""
161
+
162
+ # Map Polars types to Actian Vector types
163
+ type_map = {
164
+ pl.Int8: "TINYINT",
165
+ pl.Int16: "SMALLINT",
166
+ pl.Int32: "INTEGER",
167
+ pl.Int64: "BIGINT",
168
+ pl.UInt8: "SMALLINT",
169
+ pl.UInt16: "INTEGER",
170
+ pl.UInt32: "BIGINT",
171
+ pl.UInt64: "BIGINT",
172
+ pl.Float32: "FLOAT4",
173
+ pl.Float64: "FLOAT8",
174
+ pl.String: "VARCHAR(1000)",
175
+ pl.Utf8: "VARCHAR(1000)",
176
+ pl.Boolean: "BOOLEAN",
177
+ pl.Date: "DATE",
178
+ pl.Datetime: "TIMESTAMP",
179
+ }
180
+
181
+ columns = []
182
+ for col_name, dtype in df.schema.items():
183
+ # Get base type, handle parameterized types like Datetime
184
+ actian_type = type_map.get(type(dtype), None)
185
+ if actian_type is None:
186
+ actian_type = type_map.get(dtype, "VARCHAR(1000)")
187
+
188
+ # Handle datetime subtypes
189
+ if isinstance(dtype, pl.Datetime):
190
+ actian_type = "TIMESTAMP"
191
+
192
+ columns.append(f'"{col_name}" {actian_type}')
193
+
194
+ columns_sql = ", ".join(columns)
195
+
196
+ # Drop and recreate for clean state
197
+ drop_sql = f"DROP TABLE IF EXISTS {table_name}"
198
+ create_sql = f"CREATE TABLE {table_name} ({columns_sql})"
199
+
200
+ self._execute_sql(drop_sql)
201
+ self._execute_sql(create_sql)
202
+
203
+ print(f"[Actian] Created table {table_name} with {len(columns)} columns")
204
+
205
+ def _optimize_table(self, table_name: str):
206
+ """Optimize table for analytical queries."""
207
+ # MODIFY TO COMBINE reorganizes data for better scan performance
208
+ try:
209
+ self._execute_sql(f"MODIFY {table_name} TO COMBINE")
210
+ print(f"[Actian] Optimized table {table_name}")
211
+ except Exception as e:
212
+ print(f"[Actian] Warning: MODIFY TO COMBINE failed (non-fatal): {e}")
213
+
214
+ def _execute_sql(self, sql: str):
215
+ """Execute a SQL statement (DDL/utility) without timing."""
216
+ database = self.connection_params["database"]
217
+ sql_path = self.ssh_params["actian_sql_path"]
218
+
219
+ escaped_sql = sql.replace("'", "'\\''")
220
+ command = f"echo '{escaped_sql}' | {sql_path} {database}"
221
+
222
+ stdout, stderr, exit_code = self._ssh_exec(command, timeout=120)
223
+
224
+ if exit_code != 0:
225
+ raise RuntimeError(f"SQL execution failed: {stderr}\nSQL: {sql}")
226
+
227
+ def close(self):
228
+ """Close SSH connection."""
229
+ if self._ssh_client:
230
+ self._ssh_client.close()
231
+ self._ssh_client = None
@@ -0,0 +1,58 @@
1
+ # sql_benchmarks_dagster/resources/base_engine.py
2
+ from abc import ABC, abstractmethod
3
+ from typing import Dict, Any, Optional, Protocol, runtime_checkable
4
+
5
+ @runtime_checkable
6
+ class IBenchmarkEngine(Protocol):
7
+ """
8
+ ABSTRACT BASE CLASS: Defines the mandatory interface for all
9
+ pluggable benchmark engines (SQL, Graph, etc.).
10
+
11
+ The benchmark and ingestion factories rely ONLY on this contract.
12
+ """
13
+
14
+ @abstractmethod
15
+ def get_engine_name(self) -> str:
16
+ """Returns the canonical name of the engine (e.g., 'duckdb', 'postgres', 'neo4j')."""
17
+ raise NotImplementedError
18
+
19
+ @abstractmethod
20
+ def clear_cache(self):
21
+ """
22
+ Resets the database state to ensure a 'Cold Cache' benchmark.
23
+ """
24
+ pass
25
+
26
+ @abstractmethod
27
+ def run_query(self,
28
+ sql: str,
29
+ partition_key: str,
30
+ engine_params: Dict[str, Any] = None) -> Optional[float]:
31
+ """
32
+ Executes the query for benchmarking and forces result collection.
33
+
34
+ Args:
35
+ sql (str): The SQL or Cypher query to run.
36
+ partition_key (str): The unique key for the scenario/database instance.
37
+ engine_params (Dict): THIS engine's own tuning namespace, already
38
+ sliced by the benchmark factory from the
39
+ config's namespaced engine_params block
40
+ (e.g. the postgres engine receives
41
+ {'work_mem': '64MB'}). Each engine owns its
42
+ vocabulary; engines without tunables accept
43
+ the kwarg and ignore it.
44
+
45
+ Returns:
46
+ Optional[float]: The execution duration in seconds, if measured internally.
47
+ """
48
+ raise NotImplementedError
49
+
50
+ @abstractmethod
51
+ def bulk_load(self, filepath: str, target_table_name: str, partition_key: str) -> None:
52
+ """
53
+ Handles the engine-specific logic for efficiently loading a file (e.g., Parquet).
54
+
55
+ Note: The partition_key is required to map the ingestion to the correct physical
56
+ database file (e.g., benchmark_tiny_ssd.duckdb).
57
+ """
58
+ raise NotImplementedError
@@ -0,0 +1,104 @@
1
+ """
2
+ Abstract base for schema-first (non-relational) database clients.
3
+
4
+ SQL clients (DuckDB, Postgres, Actian) bulk-load via a single file-level
5
+ operation (COPY / read_parquet / vwload). Schema-first databases require a
6
+ different three-step sequence:
7
+
8
+ 1. Prepare the data store (drop / create database, collection, graph …)
9
+ 2. Define the schema (entity types, attributes, constraints …)
10
+ 3. Insert rows in batches (no native bulk-file path)
11
+
12
+ This class owns that orchestration via the Template Method pattern.
13
+ Concrete subclasses implement the three abstract steps for their specific
14
+ database. A future Neo4j, ArangoDB, or Weaviate client would extend this
15
+ class and implement only those three methods.
16
+ """
17
+
18
+ from abc import ABC, abstractmethod
19
+ from typing import Any, Dict, Optional
20
+
21
+ import polars as pl
22
+
23
+
24
+ class SchemaFirstClient(ABC):
25
+ """
26
+ Template-method base for schema-first database clients.
27
+
28
+ Subclass contract
29
+ -----------------
30
+ Implement the three abstract methods below. Do NOT override
31
+ ``bulk_load`` unless you genuinely need to change the loading sequence.
32
+ """
33
+
34
+ #: Rows per write transaction. Subclasses may override.
35
+ BATCH_SIZE: int = 200
36
+
37
+ # ------------------------------------------------------------------
38
+ # Public interface
39
+ # ------------------------------------------------------------------
40
+
41
+ def bulk_load(self, filepath: str, entity_type: str) -> None:
42
+ """
43
+ Load a Parquet file into the database.
44
+
45
+ Sequence (fixed):
46
+ 1. _prepare_store — wipe and recreate the data store
47
+ 2. _define_schema — declare types / structure
48
+ 3. _insert_batch — write rows in BATCH_SIZE chunks
49
+ """
50
+ df = pl.read_parquet(filepath)
51
+
52
+ self._prepare_store(entity_type)
53
+ self._define_schema(entity_type, df)
54
+
55
+ rows = df.to_dicts()
56
+ for i in range(0, len(rows), self.BATCH_SIZE):
57
+ self._insert_batch(entity_type, rows[i : i + self.BATCH_SIZE])
58
+
59
+ print(
60
+ f"[{type(self).__name__}] Loaded {len(rows)} rows"
61
+ f" into '{entity_type}'"
62
+ )
63
+
64
+ @abstractmethod
65
+ def run_query(self, query: str, scenario_params: Dict[str, Any]) -> Optional[float]:
66
+ """
67
+ Execute a query and return the wall-clock duration in seconds.
68
+
69
+ Implementations must force full result materialisation before
70
+ stopping the clock (consistent with how DuckDB and Actian are timed).
71
+ """
72
+ raise NotImplementedError
73
+
74
+ # ------------------------------------------------------------------
75
+ # Abstract steps (implement in subclass)
76
+ # ------------------------------------------------------------------
77
+
78
+ @abstractmethod
79
+ def _prepare_store(self, entity_type: str) -> None:
80
+ """
81
+ Drop any existing data store and create a fresh one.
82
+
83
+ Examples:
84
+ TypeDB → drop + create database
85
+ Neo4j → drop + create named graph
86
+ MongoDB → drop + create collection
87
+ """
88
+ raise NotImplementedError
89
+
90
+ @abstractmethod
91
+ def _define_schema(self, entity_type: str, df: pl.DataFrame) -> None:
92
+ """
93
+ Declare the schema / structure for ``entity_type``.
94
+
95
+ ``df`` is provided so implementations can derive the schema from
96
+ the DataFrame's column names and dtypes rather than requiring a
97
+ separate schema file.
98
+ """
99
+ raise NotImplementedError
100
+
101
+ @abstractmethod
102
+ def _insert_batch(self, entity_type: str, rows: list) -> None:
103
+ """Persist one batch of plain-dict rows to the data store."""
104
+ raise NotImplementedError
@@ -0,0 +1,58 @@
1
+ import os
2
+ from dagster import ConfigurableResource
3
+ from typing import Dict, Any, Optional
4
+ from .base_engine import IBenchmarkEngine
5
+ from .duckdb_client import DuckDBClient
6
+ from pydantic import ConfigDict
7
+ from ..utils.system import thrash_os_cache
8
+
9
+ # The Engine is the immutable facade that satisfies the contract.
10
+ class DuckDBEngine(ConfigurableResource):
11
+ # --- CONFIGURATION (Immutable) ---
12
+ data_folder: str # This is the only configuration it holds
13
+ model_config = ConfigDict(extra='forbid')
14
+ # --- FACTORY METHOD ---
15
+ # This is the Factory Method: It creates the worker/client instance.
16
+ def _get_client(self) -> 'DuckDBClient':
17
+ """Instantiates the stateful client for execution."""
18
+ return DuckDBClient(data_folder=self.data_folder)
19
+
20
+ # --- IBenchmarkEngine Implementation (Delegation) ---
21
+
22
+ def get_engine_name(self) -> str:
23
+ return "duckdb"
24
+
25
+ def bulk_load(self, filepath: str, table_name: str, partition_key: str, table_def: dict = None) -> None:
26
+ """Delegates the bulk loading operation to the client."""
27
+ client = self._get_client()
28
+ client.bulk_load(filepath, table_name, partition_key, table_def)
29
+
30
+
31
+ def run_query(self,
32
+ sql: str,
33
+ partition_key: str,
34
+ engine_params: Dict[str, Any] = None) -> Optional[float]:
35
+ """Delegates the core benchmarking query execution to the client."""
36
+ self.clear_cache()
37
+
38
+ client = self._get_client()
39
+ # Delegate the call using the exact method signature
40
+ return client.run_query(sql=sql,
41
+ partition_key=partition_key,
42
+ engine_params=engine_params
43
+ )
44
+
45
+ # --- Utility Methods ---
46
+
47
+ def _get_db_path(self, partition_key: str):
48
+ """Calculates the database file path using the partition_key (still required for path lookups)."""
49
+ # NOTE: This method can safely remain here as it only involves string manipulation, not I/O.
50
+ if partition_key is None:
51
+ return os.path.join(self.data_folder, "benchmark.duckdb")
52
+
53
+ db_filename = f"benchmark_{partition_key}.duckdb"
54
+ return os.path.join(self.data_folder, db_filename)
55
+
56
+ def clear_cache(self):
57
+
58
+ thrash_os_cache()
@@ -0,0 +1,157 @@
1
+ import duckdb
2
+ import os
3
+ import re
4
+ import time
5
+ from contextlib import contextmanager
6
+ from typing import Dict, Any, Optional
7
+ from ..utils.system import thrash_os_cache
8
+ from .base_engine import IBenchmarkEngine
9
+
10
+ _SAFE_IDENTIFIER = re.compile(r"^\w+$") # letters, digits, underscores only
11
+
12
+
13
+ def _assert_safe_identifier(value: str, label: str) -> None:
14
+ if not _SAFE_IDENTIFIER.match(value):
15
+ raise ValueError(f"Unsafe {label}: '{value}'. Only word characters allowed.")
16
+
17
+
18
+ # The 'duckdb' engine_params namespace vocabulary: session settings applied
19
+ # via SET before the measured query. Strict allowlist — an unknown key is a
20
+ # config error and must fail loudly, never be silently ignored.
21
+ DUCKDB_SETTING_KEYS = frozenset({
22
+ "threads",
23
+ "memory_limit",
24
+ })
25
+
26
+ # SET cannot use parameter binding; values are interpolated as quoted
27
+ # literals, so restrict the alphabet (covers ints and sizes like '1GB').
28
+ _SAFE_SETTING_VALUE = re.compile(r"^[A-Za-z0-9._]+$")
29
+
30
+
31
+ def _apply_engine_params(con, engine_params: Dict[str, Any]) -> None:
32
+ for key, value in (engine_params or {}).items():
33
+ if key not in DUCKDB_SETTING_KEYS:
34
+ raise ValueError(
35
+ f"Unknown duckdb engine_params key '{key}'. "
36
+ f"Allowed: {sorted(DUCKDB_SETTING_KEYS)}"
37
+ )
38
+ if not _SAFE_SETTING_VALUE.match(str(value)):
39
+ raise ValueError(f"Unsafe value for duckdb setting '{key}': {value!r}")
40
+ con.execute(f"SET {key} = '{value}'")
41
+
42
+ # Note: DuckDBClient does NOT inherit ConfigurableResource
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Module-level db-file tracker (mirrors the TypeDBEngine pattern).
46
+ # The first bulk_load call for a given db file deletes any stale copy
47
+ # (clean slate). Subsequent tables are added to the same file via
48
+ # CREATE OR REPLACE TABLE, so multi-table experiments (e.g. hypergraph
49
+ # supply chain) work correctly. Keyed by db_path — not partition_key —
50
+ # because multiple engines (duckdb, quack) load the same partitions into
51
+ # different data folders. Cleared naturally between runs because each
52
+ # execute_run.py invocation starts a fresh Python process.
53
+ # ---------------------------------------------------------------------------
54
+ _INITIALIZED_DB_FILES: set = set()
55
+
56
+ class DuckDBClient:
57
+ """
58
+ STATEFUL CLIENT: Holds the execution logic and performs all I/O operations,
59
+ ensuring the Engine resource remains immutable.
60
+ """
61
+
62
+ def __init__(self, data_folder: str):
63
+ # Configuration required for execution, held on the stateful object
64
+ self.data_folder = data_folder
65
+
66
+ # --- IBenchmarkEngine Implementation (Execution Logic) ---
67
+
68
+ def bulk_load(self, filepath: str, target_table_name: str, partition_key: str, table_def: dict = None) -> None:
69
+ # table_def is accepted for interface symmetry; DuckDB is columnar and
70
+ # relies on automatic min-max zonemaps, so declared B-tree indexes/PKs
71
+ # are not applied here.
72
+ db_path = self._get_db_path(partition_key)
73
+
74
+ # Validate inputs before constructing SQL.
75
+ # Table name: DuckDB doesn't support parameterized identifiers, so we validate
76
+ # with a strict allowlist regex (word chars only) before interpolation.
77
+ # Filepath: use read_parquet(?) parameterized binding for the value.
78
+ _assert_safe_identifier(target_table_name, "table name")
79
+ filepath = os.path.realpath(filepath) # resolve symlinks / traversal
80
+
81
+ # First table for this db file: wipe any stale copy for a clean
82
+ # slate. Subsequent tables are added to the same file via
83
+ # CREATE OR REPLACE TABLE so all tables coexist.
84
+ if db_path not in _INITIALIZED_DB_FILES:
85
+ if os.path.exists(db_path):
86
+ try:
87
+ os.remove(db_path)
88
+ except OSError as e:
89
+ print(f"[WARN] Could not remove existing DB file {db_path}: {e}")
90
+ _INITIALIZED_DB_FILES.add(db_path)
91
+
92
+ os.makedirs(os.path.dirname(db_path), exist_ok=True)
93
+ with duckdb.connect(db_path, read_only=False) as con:
94
+ con.execute(
95
+ f"CREATE OR REPLACE TABLE {target_table_name} AS SELECT * FROM read_parquet(?)",
96
+ [filepath],
97
+ )
98
+
99
+ def run_query(self,
100
+ sql: str,
101
+ partition_key: str,
102
+ engine_params: Dict[str, Any] = None) -> Optional[float]:
103
+ """Execute a benchmark query against the partition's DuckDB file.
104
+
105
+ engine_params is the 'duckdb' namespace (threads, memory_limit),
106
+ applied via SET before timing starts so the measurement reflects the
107
+ configured execution, not the configuration itself.
108
+ """
109
+ db_path = self._get_db_path(partition_key)
110
+
111
+ # Enable RW for Sentinel experiments (CREATE TABLE)
112
+ with duckdb.connect(db_path, read_only=False) as con:
113
+ _apply_engine_params(con, engine_params)
114
+ start = time.time()
115
+ # In DuckDB, multiple statements in one string are executed if separated by semicolon
116
+ # .sql() executes them.
117
+ con.sql(sql).fetchall()
118
+ end = time.time()
119
+
120
+ return end - start
121
+
122
+ # --- Internal/Utility Methods (Moved here) ---
123
+
124
+ def _get_db_path(self, partition_key: str):
125
+ """Calculates the database file path using the partition_key."""
126
+ if partition_key is None:
127
+ return os.path.join(self.data_folder, "benchmark.duckdb")
128
+
129
+ db_filename = f"benchmark_{partition_key}.duckdb"
130
+ return os.path.join(self.data_folder, db_filename)
131
+
132
+ # --- Utility Methods ---
133
+ # execute_query, get_connection, and execute_on_file are moved here
134
+ # as they all involve I/O and state.
135
+
136
+ def execute_query(self, sql: str, partition_key: str = None, read_only: bool = False, is_benchmark: bool = False):
137
+ db_path = self._get_db_path(partition_key)
138
+
139
+ if not read_only:
140
+ os.makedirs(os.path.dirname(db_path), exist_ok=True)
141
+
142
+ with duckdb.connect(db_path, read_only=read_only) as con:
143
+ result = con.execute(sql)
144
+ if is_benchmark:
145
+ result.fetchall()
146
+
147
+ @contextmanager
148
+ def get_connection(self, db_path: str, read_only: bool = False):
149
+ con = duckdb.connect(db_path, read_only=read_only)
150
+ try:
151
+ yield con
152
+ finally:
153
+ con.close()
154
+
155
+ def execute_on_file(self, sql: str, target_path: str):
156
+ with self.get_connection(target_path) as con:
157
+ con.execute(sql)