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,188 @@
1
+ # sql_benchmarks_dagster/resources/postgres.py (Configuration and Delegation)
2
+ import time
3
+ import os
4
+ from dagster import ConfigurableResource
5
+ from typing import Dict, Any, Optional
6
+ import socket
7
+ import docker
8
+ from docker.errors import NotFound, APIError
9
+ from .base_engine import IBenchmarkEngine # We import it for type hinting, but don't inherit
10
+ from .postgres_client import PostgresClient
11
+ from pydantic import ConfigDict, PrivateAttr
12
+ from sqlalchemy import create_engine, text
13
+ from sqlalchemy.engine.url import make_url
14
+ from sqlalchemy.exc import OperationalError
15
+ from ..utils.system import thrash_os_cache
16
+ from ..constants import DATA_DIR
17
+
18
+ # Inheritance is simplified to prevent MRO conflicts. It satisfies IBenchmarkEngine via Protocol.
19
+ class PostgresEngine(ConfigurableResource):
20
+
21
+ # --- CONFIGURATION (Immutable) ---
22
+ connection_string: str
23
+ container_name: str = "benchmark_postgres"
24
+ docker_image: str = "postgres:15"
25
+ model_config = ConfigDict(extra='forbid')
26
+
27
+ _runtime_connection_string: Optional[str] = PrivateAttr(default=None)
28
+
29
+ # --- FACTORY METHOD ---
30
+ def _get_client(self) -> PostgresClient:
31
+ # Use runtime string if set (dynamic port), else config default
32
+ target_conn = self._runtime_connection_string or self.connection_string
33
+ return PostgresClient(target_conn)
34
+
35
+ # --- IBenchmarkEngine Implementation (Delegation) ---
36
+ def clear_cache(self, settings: dict = None):
37
+ """
38
+ Enforces a multi-layer Cold Cache:
39
+ 1. Host Layer: Floods host RAM to evict OS Page Cache.
40
+ 2. Database Layer: Recreates the container to wipe DBMS buffer pools.
41
+ """
42
+ thrash_os_cache()
43
+ self.setup_docker(settings)
44
+ self._wait_for_ready()
45
+
46
+ def run_query(self, sql: str, partition_key: str, engine_params: Dict[str, Any] = None) -> Optional[float]:
47
+ # engine_params is the 'postgres' namespace: session settings (work_mem, ...)
48
+ self.clear_cache(engine_params)
49
+ client = self._get_client()
50
+ return client.run_query(sql=sql, pg_settings=engine_params)
51
+
52
+ def bulk_load(self, filepath: str, table_name: str, partition_key: str, table_def: dict = None) -> None:
53
+ self.setup_docker()
54
+ self._wait_for_ready()
55
+ client = self._get_client()
56
+ client.bulk_load(filepath, table_name, partition_key, table_def)
57
+
58
+ def get_engine_name(self) -> str:
59
+ return "postgres"
60
+
61
+ def get_engine(self):
62
+ target_conn = self._runtime_connection_string or self.connection_string
63
+ return create_engine(target_conn)
64
+
65
+ # --- EXTERNAL/SYSTEM/CONFIG HELPERS ---
66
+ def _get_port_from_url(self) -> int:
67
+ try:
68
+ target_conn = self._runtime_connection_string or self.connection_string
69
+ url = make_url(target_conn)
70
+ return url.port or 5432
71
+ except Exception:
72
+ return 5432
73
+
74
+ def _check_port_available(self, port: int) -> bool:
75
+ """Returns True if port is free (connect returns non-zero)."""
76
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
77
+ return s.connect_ex(('localhost', port)) != 0
78
+
79
+ def _find_free_port(self, start_port: int = 5432) -> int:
80
+ port = start_port
81
+ while True:
82
+ if self._check_port_available(port):
83
+ return port
84
+ port += 1
85
+
86
+ def _wait_for_ready(self, timeout=30):
87
+ start = time.time()
88
+ while time.time() - start < timeout:
89
+ try:
90
+ # Use dynamic engine creation
91
+ engine = self.get_engine()
92
+ with engine.connect() as conn:
93
+ conn.execute(text("SELECT 1"))
94
+ return
95
+ except Exception:
96
+ time.sleep(1)
97
+ raise TimeoutError("Postgres container timed out during restart.")
98
+
99
+ def _kill_zombie_container(self):
100
+ """
101
+ Forcefully removes the container using Docker SDK.
102
+ """
103
+ client = docker.from_env()
104
+ try:
105
+ container = client.containers.get(self.container_name)
106
+ container.remove(force=True)
107
+ except NotFound:
108
+ pass # Already gone
109
+ except APIError as e:
110
+ raise RuntimeError(f"Failed to cleanup container {self.container_name}: {e}")
111
+
112
+ def setup_docker(self, settings: dict = None):
113
+ """
114
+ Robust Provisioning using Docker SDK
115
+ """
116
+ # 1. CLEANUP
117
+ self._kill_zombie_container()
118
+
119
+ target_port = self._get_port_from_url()
120
+
121
+ # 2. DYNAMIC PORT ALLOCATION (Auto-Resolve Conflict)
122
+ # Check if the desired port is free. If not, find a new one.
123
+ is_free = False
124
+ for _ in range(5):
125
+ if self._check_port_available(target_port):
126
+ is_free = True
127
+ break
128
+ time.sleep(1)
129
+
130
+ if not is_free:
131
+ print(f"[WARN] Port {target_port} is busy. searching for free port...")
132
+ new_port = self._find_free_port(start_port=target_port + 1)
133
+ print(f"[INFO] Switched to available port: {new_port}")
134
+ target_port = new_port
135
+
136
+ # CRITICAL: Update connection_string so clients connect to the new port
137
+ url = make_url(self.connection_string)
138
+ new_url = url.set(port=new_port)
139
+ self._runtime_connection_string = str(new_url)
140
+
141
+ # 3. PREPARE FILESYSTEM
142
+ # A dedicated home for the DB files prevents "Directory not empty" errors.
143
+ db_storage_path = os.path.join(DATA_DIR, "postgres_db")
144
+ os.makedirs(db_storage_path, exist_ok=True)
145
+
146
+ # 4. BUILD & RUN CONTAINER
147
+ client = docker.from_env()
148
+
149
+ # Construct config commands
150
+ # Postgres entrypoint interprets arguments as config flags if they start with -c
151
+ command_args = []
152
+ if settings:
153
+ for key, val in settings.items():
154
+ command_args.extend(["-c", f"{key}={val}"])
155
+
156
+ # Retry logic for "Port is already allocated" race condition
157
+ max_retries = 5
158
+ for attempt in range(max_retries):
159
+ try:
160
+ client.containers.run(
161
+ image=self.docker_image,
162
+ name=self.container_name,
163
+ detach=True,
164
+ ports={f'5432/tcp': target_port},
165
+ volumes={
166
+ 'pg_bench_data': {'bind': '/var/lib/postgresql/data', 'mode': 'rw'},
167
+ DATA_DIR: {'bind': '/mnt/data', 'mode': 'rw'}
168
+ },
169
+ environment={
170
+ "POSTGRES_PASSWORD": make_url(self.connection_string).password or "password",
171
+ "POSTGRES_HOST_AUTH_METHOD": "trust",
172
+ "POSTGRES_DB": make_url(self.connection_string).database
173
+ },
174
+ shm_size="2gb",
175
+ command=command_args
176
+ )
177
+ break # Success
178
+ except APIError as e:
179
+ # If port is busy or conflict, wait and retry
180
+ if "port is already allocated" in str(e) or "Conflict" in str(e):
181
+ if attempt < max_retries - 1:
182
+ time.sleep(2)
183
+ # Try cleanup again just in case
184
+ self._kill_zombie_container()
185
+ continue
186
+ raise RuntimeError(f"Postgres failed to start via Docker SDK: {e}")
187
+
188
+ # No wait here. Orchestration layer handles the wait.
@@ -0,0 +1,184 @@
1
+ import io
2
+ import time
3
+ import polars as pl
4
+ import pyarrow.parquet as pq
5
+ from sqlalchemy import create_engine, text
6
+ from typing import Dict, Any, Optional
7
+
8
+ # Module-level constant so callers can identify pg-setting dimension keys
9
+ # without importing the class or reaching into its internals.
10
+ PG_SETTING_KEYS = frozenset({
11
+ "work_mem",
12
+ "random_page_cost",
13
+ "enable_hashjoin",
14
+ "enable_nestloop",
15
+ "enable_seqscan",
16
+ "enable_sort",
17
+ "enable_mergejoin",
18
+ "effective_cache_size",
19
+ "max_parallel_workers_per_gather",
20
+ })
21
+
22
+
23
+ class PostgresClient:
24
+ """
25
+ STATEFUL CLIENT: Restored original logic using Polars + PyArrow for robust ingestion.
26
+ """
27
+
28
+ def __init__(self, connection_string: str):
29
+ self.connection_string = connection_string
30
+ self.engine = create_engine(connection_string)
31
+
32
+ # Allowlist of Postgres settings the benchmark harness is permitted to set.
33
+ # Prevents arbitrary SQL injection via pg_settings YAML keys.
34
+ # Also exported as a module-level constant for callers that need to
35
+ # identify which dimension keys are pg settings (without coupling to this class).
36
+ _ALLOWED_PG_SETTINGS = PG_SETTING_KEYS
37
+
38
+ def run_query(self, sql: str, partition_key: str = None, pg_settings: Dict[str, Any] = None) -> Optional[float]:
39
+ """Execute a benchmark query, optionally applying Postgres session settings first.
40
+
41
+ partition_key is accepted but unused — present for interface symmetry with DuckDBClient.
42
+ pg_settings must only contain keys from PG_SETTING_KEYS (enforced below).
43
+ """
44
+ pg_settings = pg_settings or {}
45
+
46
+ with self.engine.connect() as conn:
47
+ for key, val in pg_settings.items():
48
+ if key not in PG_SETTING_KEYS:
49
+ raise ValueError(f"pg_setting '{key}' is not in the allowlist.")
50
+ # Key is allowlisted (safe to interpolate); value is parameterized.
51
+ conn.execute(text(f"SET {key} = :val"), {"val": str(val)})
52
+
53
+ start = time.time()
54
+ conn.execute(text(sql))
55
+ conn.commit()
56
+ return time.time() - start
57
+
58
+ # Updated signature to accept partition_key (passed from factory), even if unused logic-wise
59
+ def bulk_load(self, file_path: str, table_name: str, partition_key: str = None, table_def: dict = None):
60
+ print(f"Streaming {file_path} to {table_name}...")
61
+
62
+ if file_path.endswith(".parquet"):
63
+ # 1. Infer Schema using Polars (Fixes UndefinedTable error)
64
+ df_schema = pl.scan_parquet(file_path).limit(1).collect()
65
+ self._create_schema(table_name, df_schema)
66
+ # 2. Stream Data
67
+ self._stream_parquet(file_path, table_name)
68
+
69
+ elif file_path.endswith(".json"):
70
+ self._stream_json(file_path, table_name)
71
+ elif file_path.endswith(".csv"):
72
+ self._stream_csv(file_path, table_name)
73
+
74
+ self._execute_internal(f"ANALYZE {table_name};")
75
+
76
+ # Apply declared schema constraints (PK, indexes, FKs) AFTER the bulk
77
+ # load — building them post-load is correct and faster than per-row.
78
+ # This is one-time setup cost, outside the timed query loop.
79
+ if table_def:
80
+ self._apply_constraints(table_def, table_name, partition_key)
81
+
82
+ def _apply_constraints(self, table_def: dict, table_name: str, partition_key: str):
83
+ from ..utils.ddl import PostgresDDLGenerator
84
+ gen = PostgresDDLGenerator(table_def, table_name, partition_key)
85
+ statements = []
86
+ pk_sql = gen.generate_pk_sql()
87
+ if pk_sql:
88
+ statements.append(pk_sql)
89
+ statements.extend(gen.generate_index_sqls())
90
+ statements.extend(gen.generate_fk_sqls())
91
+ for sql in statements:
92
+ self._execute_internal(sql)
93
+ if statements:
94
+ # Refresh stats so the planner can choose an index scan.
95
+ self._execute_internal(f"ANALYZE {table_name};")
96
+
97
+ def _create_schema(self, table_name: str, sample_df: pl.DataFrame):
98
+ """
99
+ Creates the table schema dynamically using a robust Type Map.
100
+ """
101
+ # Improved Type Map as requested
102
+ type_map = {
103
+ pl.Int8: "SMALLINT",
104
+ pl.Int16: "SMALLINT",
105
+ pl.Int32: "INTEGER",
106
+ pl.Int64: "BIGINT",
107
+ pl.Float32: "REAL",
108
+ pl.Float64: "DOUBLE PRECISION",
109
+ pl.String: "TEXT",
110
+ pl.Utf8: "TEXT",
111
+ pl.Boolean: "BOOLEAN",
112
+ pl.Date: "DATE",
113
+ pl.Datetime: "TIMESTAMP",
114
+ pl.Object: "TEXT"
115
+ }
116
+
117
+ cols = []
118
+ for name, dtype in sample_df.schema.items():
119
+ # Default to TEXT if not found, or use the mapped type
120
+ pg_type = type_map.get(dtype, "TEXT")
121
+ # Handle datetime subclasses if necessary
122
+ if isinstance(dtype, pl.Datetime):
123
+ pg_type = "TIMESTAMP"
124
+
125
+ cols.append(f'"{name}" {pg_type}')
126
+
127
+ # Clean slate: Drop and Create
128
+ ddl = f"DROP TABLE IF EXISTS {table_name}; CREATE TABLE {table_name} ({', '.join(cols)});"
129
+ self._execute_internal(ddl)
130
+
131
+ def _stream_parquet(self, path: str, table_name: str, batch_size: int = 500_000):
132
+ # Use the engine's raw connection (Restored your try/finally logic)
133
+ raw_conn = self.engine.raw_connection()
134
+ try:
135
+ with raw_conn.cursor() as cur:
136
+ parquet_file = pq.ParquetFile(path)
137
+
138
+ for batch in parquet_file.iter_batches(batch_size=batch_size):
139
+ df = pl.from_arrow(batch)
140
+ self._copy_buffer(cur, df, table_name)
141
+
142
+ raw_conn.commit()
143
+ finally:
144
+ raw_conn.close()
145
+
146
+ def _stream_json(self, path: str, table_name: str):
147
+ raw_conn = self.engine.raw_connection()
148
+ try:
149
+ with raw_conn.cursor() as cur:
150
+ df = pl.read_ndjson(path)
151
+ self._create_schema(table_name, df)
152
+ self._copy_buffer(cur, df, table_name)
153
+ raw_conn.commit()
154
+ finally:
155
+ raw_conn.close()
156
+
157
+ def _stream_csv(self, path: str, table_name: str):
158
+ raw_conn = self.engine.raw_connection()
159
+ try:
160
+ with open(path, 'r') as f:
161
+ with raw_conn.cursor() as cur:
162
+ cur.copy_expert(f"COPY {table_name} FROM STDIN WITH (FORMAT CSV, HEADER)", f)
163
+ raw_conn.commit()
164
+ finally:
165
+ raw_conn.close()
166
+
167
+ def _copy_buffer(self, cursor, df: pl.DataFrame, table_name: str):
168
+ """
169
+ High-performance buffer write using Polars CSV serializer.
170
+ """
171
+ csv_buffer = io.StringIO()
172
+ # write_csv is extremely fast in Polars
173
+ df.write_csv(csv_buffer, include_header=False)
174
+ csv_buffer.seek(0)
175
+ cursor.copy_expert(
176
+ f"COPY {table_name} FROM STDIN WITH (FORMAT CSV)",
177
+ csv_buffer
178
+ )
179
+
180
+ def _execute_internal(self, sql: str):
181
+ """Helper to execute DDL/Utility queries"""
182
+ with self.engine.connect() as conn:
183
+ conn.execute(text(sql))
184
+ conn.commit()
@@ -0,0 +1,56 @@
1
+ import os
2
+ from typing import Dict, Any, Optional
3
+
4
+ from dagster import ConfigurableResource
5
+ from pydantic import ConfigDict
6
+
7
+ from ..utils.system import thrash_os_cache
8
+ from .quack_client import QuackClient
9
+
10
+
11
+ class QuackEngine(ConfigurableResource):
12
+ """
13
+ DuckDB-over-Quack: the same storage and SQL dialect as the duckdb engine,
14
+ measured through the Quack client-server protocol (HTTP) instead of
15
+ in-process calls. Comparing 'duckdb' vs 'quack' on identical scenarios
16
+ isolates the protocol cost.
17
+
18
+ Uses its OWN data_folder so its database files never contend with the
19
+ in-process duckdb engine's files within the same experiment.
20
+ """
21
+ data_folder: str
22
+ port: int = 9494
23
+ token: str = "sb-local-quack-token"
24
+ # attach mode (False): client-side planning via USE remote.
25
+ # pushdown (True): SQL text shipped via remote.query() — server-side execution.
26
+ pushdown: bool = False
27
+ model_config = ConfigDict(extra='forbid')
28
+
29
+ def _get_client(self) -> QuackClient:
30
+ return QuackClient(data_folder=self.data_folder, port=self.port,
31
+ token=self.token, pushdown=self.pushdown)
32
+
33
+ # --- IBenchmarkEngine Implementation (Delegation) ---
34
+
35
+ def get_engine_name(self) -> str:
36
+ return "quack"
37
+
38
+ def bulk_load(self, filepath: str, table_name: str, partition_key: str, table_def: dict = None) -> None:
39
+ client = self._get_client()
40
+ client.bulk_load(filepath, table_name, partition_key, table_def)
41
+
42
+ def run_query(self,
43
+ sql: str,
44
+ partition_key: str,
45
+ engine_params: Dict[str, Any] = None) -> Optional[float]:
46
+ """engine_params is the 'quack' namespace — reserved, not yet applied."""
47
+ self.clear_cache()
48
+ client = self._get_client()
49
+ return client.run_query(sql=sql,
50
+ partition_key=partition_key,
51
+ engine_params=engine_params)
52
+
53
+ def clear_cache(self):
54
+ # OS page cache flush; the per-query server restart in QuackClient
55
+ # provides the engine-level cold start.
56
+ thrash_os_cache()
@@ -0,0 +1,169 @@
1
+ """
2
+ QuackClient: benchmark client for DuckDB's Quack client-server protocol
3
+ (beta since DuckDB 1.5.3).
4
+
5
+ Topology per measurement (mirrors the Postgres cold-cache methodology):
6
+ 1. Any previous server on this port is stopped (cold start guarantee).
7
+ 2. A fresh server subprocess opens the partition's .duckdb file and serves
8
+ it via CALL quack_serve over HTTP, bound to localhost only.
9
+ 3. The measurement connection ATTACHes with 'quack:localhost:<port>',
10
+ switches default catalog with USE, and executes the SAME SQL files as
11
+ the in-process duckdb engine — measured time therefore includes Quack
12
+ protocol serialization and HTTP transport, which is the point.
13
+ 4. The server is stopped after the measurement.
14
+
15
+ Server startup and ATTACH handshake happen BEFORE the timer starts; only
16
+ query execution + result materialisation are measured.
17
+ """
18
+ import os
19
+ import re
20
+ import subprocess
21
+ import sys
22
+ import time
23
+ from typing import Any, Dict, Optional
24
+
25
+ import duckdb
26
+
27
+ from .duckdb_client import DuckDBClient
28
+
29
+ # Token is interpolated into SQL on both server and client side; restrict the
30
+ # alphabet so a misconfigured env var fails loudly instead of injecting SQL.
31
+ _SAFE_TOKEN = re.compile(r"^[A-Za-z0-9_-]{8,}$")
32
+
33
+ # Module-level server registry keyed by port (facades create a fresh client
34
+ # per call — instance state would orphan server processes).
35
+ _SERVERS: Dict[int, subprocess.Popen] = {}
36
+
37
+ # Runs in a subprocess: open the partition db, serve it, block forever.
38
+ # db_path and port arrive via argv; the token via env (kept out of `ps`).
39
+ _SERVER_SCRIPT = """
40
+ import os, sys, time
41
+ import duckdb
42
+ db_path, port = sys.argv[1], sys.argv[2]
43
+ token = os.environ["SB_QUACK_TOKEN"]
44
+ con = duckdb.connect(db_path)
45
+ con.execute("LOAD quack;")
46
+ con.execute("CALL quack_serve('quack:localhost:%s', token := '%s')" % (port, token))
47
+ print("READY", flush=True)
48
+ while True:
49
+ time.sleep(3600)
50
+ """
51
+
52
+
53
+ class QuackClient:
54
+ """STATEFUL CLIENT: owns server lifecycle + measurement I/O for Quack."""
55
+
56
+ def __init__(self, data_folder: str, port: int = 9494, token: str = "",
57
+ pushdown: bool = False):
58
+ if not _SAFE_TOKEN.match(token):
59
+ raise ValueError(
60
+ "Quack token must be >=8 chars of [A-Za-z0-9_-] "
61
+ "(it is interpolated into quack_serve/ATTACH statements)."
62
+ )
63
+ self.data_folder = data_folder
64
+ self.port = port
65
+ self.token = token
66
+ # Execution mode. attach (default): the CLIENT plans the query against
67
+ # the remote catalog (USE remote) — table data may stream over HTTP.
68
+ # pushdown: the SQL text is shipped via remote.query('...') and
69
+ # executes fully SERVER-side; only the result set crosses the wire.
70
+ # Comparing the two isolates where Quack spends its time.
71
+ self.pushdown = pushdown
72
+ # File-level operations (db path layout, parquet bulk load) are
73
+ # identical to the in-process duckdb engine — compose, don't copy.
74
+ self._duck = DuckDBClient(data_folder=data_folder)
75
+
76
+ # --- IBenchmarkEngine-facing operations -------------------------------
77
+
78
+ def bulk_load(self, filepath: str, target_table_name: str, partition_key: str, table_def: dict = None) -> None:
79
+ # The server holds the db file open; loading requires exclusive access.
80
+ self.stop_server()
81
+ self._duck.bulk_load(filepath, target_table_name, partition_key, table_def)
82
+
83
+ def run_query(self,
84
+ sql: str,
85
+ partition_key: str,
86
+ engine_params: Dict[str, Any] = None) -> Optional[float]:
87
+ """Cold-start a server for the partition, measure the query over Quack.
88
+
89
+ engine_params is the 'quack' namespace — reserved for server-side
90
+ settings; not yet applied.
91
+ """
92
+ db_path = self._duck._get_db_path(partition_key)
93
+ self.stop_server()
94
+ proc = self._start_server(db_path)
95
+ con = duckdb.connect()
96
+ try:
97
+ con.execute("LOAD quack;")
98
+ self._attach_with_retry(con, proc)
99
+
100
+ if self.pushdown:
101
+ # Ship the SQL text; execution happens entirely server-side.
102
+ # Single-quote doubling is the only escaping SQL strings need.
103
+ wrapped = "FROM remote.query('{}')".format(sql.replace("'", "''"))
104
+ start = time.time()
105
+ con.sql(wrapped).fetchall()
106
+ end = time.time()
107
+ else:
108
+ con.execute("USE remote")
109
+ start = time.time()
110
+ con.sql(sql).fetchall()
111
+ end = time.time()
112
+ return end - start
113
+ except duckdb.NotImplementedException as e:
114
+ # A protocol capability gap, not a config error: e.g. attach mode
115
+ # cannot run multi-table joins in the Quack beta ("Multiple
116
+ # streaming scans ... not currently supported"). Record as DNF —
117
+ # the limitation IS the measurement. Anything else still raises.
118
+ print(f"[Quack] DNF — protocol limitation: {e}")
119
+ return None
120
+ finally:
121
+ con.close()
122
+ self.stop_server()
123
+
124
+ # --- Server lifecycle --------------------------------------------------
125
+
126
+ def stop_server(self) -> None:
127
+ proc = _SERVERS.pop(self.port, None)
128
+ if proc is None:
129
+ return
130
+ proc.terminate()
131
+ try:
132
+ proc.wait(timeout=10)
133
+ except subprocess.TimeoutExpired:
134
+ proc.kill()
135
+ proc.wait()
136
+
137
+ def _start_server(self, db_path: str) -> subprocess.Popen:
138
+ env = {**os.environ, "SB_QUACK_TOKEN": self.token}
139
+ proc = subprocess.Popen(
140
+ [sys.executable, "-c", _SERVER_SCRIPT, db_path, str(self.port)],
141
+ env=env,
142
+ stdout=subprocess.PIPE,
143
+ stderr=subprocess.PIPE,
144
+ text=True,
145
+ )
146
+ _SERVERS[self.port] = proc
147
+ return proc
148
+
149
+ def _attach_with_retry(self, con, proc: subprocess.Popen, timeout_s: float = 15.0) -> None:
150
+ """ATTACH doubles as the readiness probe. Fails loudly with server stderr."""
151
+ uri = f"quack:localhost:{self.port}"
152
+ deadline = time.time() + timeout_s
153
+ last_err = None
154
+ while time.time() < deadline:
155
+ if proc.poll() is not None:
156
+ _, stderr = proc.communicate()
157
+ raise RuntimeError(
158
+ f"Quack server exited (code {proc.returncode}) before serving "
159
+ f"{uri}. Stderr:\n{stderr}"
160
+ )
161
+ try:
162
+ con.execute(
163
+ f"ATTACH '{uri}' AS remote (TOKEN '{self.token}', DISABLE_SSL true)"
164
+ )
165
+ return
166
+ except duckdb.Error as e:
167
+ last_err = e
168
+ time.sleep(0.2)
169
+ raise RuntimeError(f"Quack server at {uri} not ready after {timeout_s}s: {last_err}")