sqlbenchdag 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- infrastructure/debug_aws.py +40 -0
- scripts/dev/backfill_queries.py +44 -0
- scripts/dev/timestamp_capsule.py +49 -0
- scripts/dev/upgrade_capsule.py +63 -0
- scripts/dev/verify_capsule.py +79 -0
- scripts/dev/verify_portability.py +103 -0
- scripts/tools/_plotlib.py +43 -0
- scripts/tools/analyze_scaling.py +39 -0
- scripts/tools/demo_breach_detection.py +61 -0
- scripts/tools/extract_results.py +57 -0
- scripts/tools/find_kink.py +104 -0
- scripts/tools/gen_experiment_catalog.py +103 -0
- scripts/tools/load_parquet_to_local.py +98 -0
- scripts/tools/plot_execution_modes.py +82 -0
- scripts/tools/plot_scaling.py +91 -0
- scripts/tools/plot_selectivity.py +139 -0
- scripts/tools/plot_threads.py +95 -0
- scripts/tools/regenerate_dashboard.py +58 -0
- scripts/tools/verify_doc_claims.py +88 -0
- sql_benchmarks/__init__.py +3 -0
- sql_benchmarks/api/__init__.py +0 -0
- sql_benchmarks/api/app.py +30 -0
- sql_benchmarks/api/data/__init__.py +0 -0
- sql_benchmarks/api/data/catalog_reader.py +73 -0
- sql_benchmarks/api/data/reader.py +125 -0
- sql_benchmarks/api/logic/__init__.py +0 -0
- sql_benchmarks/api/logic/comparator.py +44 -0
- sql_benchmarks/api/logic/ranker.py +127 -0
- sql_benchmarks/api/models/__init__.py +0 -0
- sql_benchmarks/api/models/catalog.py +22 -0
- sql_benchmarks/api/models/experiments.py +20 -0
- sql_benchmarks/api/models/recommend.py +11 -0
- sql_benchmarks/api/models/results.py +66 -0
- sql_benchmarks/api/routers/__init__.py +0 -0
- sql_benchmarks/api/routers/catalog.py +19 -0
- sql_benchmarks/api/routers/experiments.py +79 -0
- sql_benchmarks/api/routers/recommend.py +22 -0
- sql_benchmarks/api/routers/results.py +51 -0
- sql_benchmarks/assets/__init__.py +4 -0
- sql_benchmarks/assets/benchmark_factory.py +222 -0
- sql_benchmarks/assets/data_factory.py +54 -0
- sql_benchmarks/assets/data_quality.py +91 -0
- sql_benchmarks/assets/ingestion_factory.py +88 -0
- sql_benchmarks/assets/maintenance.py +63 -0
- sql_benchmarks/assets/reporting.py +385 -0
- sql_benchmarks/assets/semantic_gate.py +76 -0
- sql_benchmarks/cli.py +69 -0
- sql_benchmarks/config_loader.py +104 -0
- sql_benchmarks/constants.py +60 -0
- sql_benchmarks/coordinator.py +282 -0
- sql_benchmarks/definitions.py +115 -0
- sql_benchmarks/experiments/templates/experiment_template.yaml +99 -0
- sql_benchmarks/harness.py +84 -0
- sql_benchmarks/jobs.py +8 -0
- sql_benchmarks/partitions.py +17 -0
- sql_benchmarks/plugins/data_sources/declarative_gen.py +211 -0
- sql_benchmarks/plugins/data_sources/local_file_loader.py +62 -0
- sql_benchmarks/plugins/data_sources/tpc_h.py +61 -0
- sql_benchmarks/resources/__init__.py +0 -0
- sql_benchmarks/resources/actian.py +234 -0
- sql_benchmarks/resources/actian_client.py +231 -0
- sql_benchmarks/resources/base_engine.py +58 -0
- sql_benchmarks/resources/base_schema_client.py +104 -0
- sql_benchmarks/resources/duckdb.py +58 -0
- sql_benchmarks/resources/duckdb_client.py +157 -0
- sql_benchmarks/resources/postgres.py +188 -0
- sql_benchmarks/resources/postgres_client.py +184 -0
- sql_benchmarks/resources/quack.py +56 -0
- sql_benchmarks/resources/quack_client.py +169 -0
- sql_benchmarks/resources/typedb_client.py +370 -0
- sql_benchmarks/resources/typedb_engine.py +303 -0
- sql_benchmarks/scripts/__init__.py +0 -0
- sql_benchmarks/scripts/sql/acid_test/duckdb/test.sql +1 -0
- sql_benchmarks/scripts/sql/acid_test/postgres/test.sql +1 -0
- sql_benchmarks/scripts/sql/analytical_wall/actian/analytical_wall.sql +15 -0
- sql_benchmarks/scripts/sql/analytical_wall/analytical_wall.sql.template +19 -0
- sql_benchmarks/scripts/sql/analytical_wall/duckdb/analytical_wall.sql +15 -0
- sql_benchmarks/scripts/sql/analytical_wall/postgres/analytical_wall.sql +15 -0
- sql_benchmarks/scripts/sql/group_by/duckdb/groupby_antipattern.sql +11 -0
- sql_benchmarks/scripts/sql/group_by/duckdb/groupby_recommended_cte.sql +19 -0
- sql_benchmarks/scripts/sql/group_by/postgres/groupby_antipattern.sql +13 -0
- sql_benchmarks/scripts/sql/group_by/postgres/groupby_recommended_pk.sql +11 -0
- sql_benchmarks/scripts/sql/hypergraph/duckdb/q_1hop.sql +5 -0
- sql_benchmarks/scripts/sql/hypergraph/duckdb/q_2hop.sql +6 -0
- sql_benchmarks/scripts/sql/hypergraph/duckdb/q_full.sql +9 -0
- sql_benchmarks/scripts/sql/hypergraph/typedb/q_1hop.sql +4 -0
- sql_benchmarks/scripts/sql/hypergraph/typedb/q_2hop.sql +5 -0
- sql_benchmarks/scripts/sql/hypergraph/typedb/q_full.sql +6 -0
- sql_benchmarks/scripts/sql/joins/duckdb/join_antipattern.sql +5 -0
- sql_benchmarks/scripts/sql/joins/duckdb/join_recommended.sql +4 -0
- sql_benchmarks/scripts/sql/joins/postgres/join_antipattern.sql +5 -0
- sql_benchmarks/scripts/sql/joins/postgres/join_recommended.sql +4 -0
- sql_benchmarks/scripts/sql/null_logic/duckdb/2VL_identity.sql +14 -0
- sql_benchmarks/scripts/sql/null_logic/duckdb/3VL_identity.sql +5 -0
- sql_benchmarks/scripts/sql/null_logic/postgres/2VL_identity.sql +14 -0
- sql_benchmarks/scripts/sql/null_logic/postgres/3VL_identity.sql +5 -0
- sql_benchmarks/scripts/sql/null_sentinel/duckdb/2VL_identity.sql +14 -0
- sql_benchmarks/scripts/sql/null_sentinel/duckdb/3VL_identity.sql +5 -0
- sql_benchmarks/scripts/sql/null_sentinel/duckdb/sentinel_optimization.sql +16 -0
- sql_benchmarks/scripts/sql/null_sentinel/postgres/2VL_identity.sql +14 -0
- sql_benchmarks/scripts/sql/null_sentinel/postgres/3VL_identity.sql +5 -0
- sql_benchmarks/scripts/sql/null_sentinel/postgres/sentinel_optimization.sql +21 -0
- sql_benchmarks/scripts/sql/recursion/duckdb/recursive_cte.sql +26 -0
- sql_benchmarks/scripts/sql/recursion/postgres/recursive_cte.sql +30 -0
- sql_benchmarks/scripts/sql/recursive_graph/duckdb/q_bounded_3hop.sql +17 -0
- sql_benchmarks/scripts/sql/recursive_graph/duckdb/q_fixed_2hop.sql +6 -0
- sql_benchmarks/scripts/sql/recursive_graph/duckdb/q_transitive_closure.sql +17 -0
- sql_benchmarks/scripts/sql/recursive_graph/typedb/q_bounded_3hop.sql +6 -0
- sql_benchmarks/scripts/sql/recursive_graph/typedb/q_fixed_2hop.sql +5 -0
- sql_benchmarks/scripts/sql/recursive_graph/typedb/q_transitive_closure.sql +7 -0
- sql_benchmarks/scripts/sql/security/acid_resilience/duckdb/test.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/duckdb/q_0_1_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/duckdb/q_10_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/duckdb/q_1_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/duckdb/q_20_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/duckdb/q_5_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/duckdb/q_filler.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/postgres/q_0_1_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/postgres/q_10_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/postgres/q_1_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/postgres/q_20_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/postgres/q_5_percent.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/postgres/q_filler.sql +1 -0
- sql_benchmarks/scripts/sql/selectivity/typedb/q_0_1_percent.sql +4 -0
- sql_benchmarks/scripts/sql/selectivity/typedb/q_10_percent.sql +4 -0
- sql_benchmarks/scripts/sql/selectivity/typedb/q_1_percent.sql +4 -0
- sql_benchmarks/scripts/sql/selectivity/typedb/q_20_percent.sql +4 -0
- sql_benchmarks/scripts/sql/selectivity/typedb/q_5_percent.sql +4 -0
- sql_benchmarks/scripts/sql/selectivity/typedb/q_filler.sql +4 -0
- sql_benchmarks/scripts/sql/sort_spill/duckdb/sort_full.sql +25 -0
- sql_benchmarks/scripts/sql/sort_spill/duckdb/sort_wide_rows.sql +25 -0
- sql_benchmarks/scripts/sql/sort_spill/postgres/sort_full.sql +25 -0
- sql_benchmarks/scripts/sql/sort_spill/postgres/sort_wide_rows.sql +26 -0
- sql_benchmarks/scripts/sql/tpch/duckdb/q3_shipping_priority.sql +25 -0
- sql_benchmarks/scripts/sql/tpch/postgres/q3_shipping_priority.sql +25 -0
- sql_benchmarks/utils/__init__.py +0 -0
- sql_benchmarks/utils/common.py +260 -0
- sql_benchmarks/utils/ddl.py +47 -0
- sql_benchmarks/utils/hasher.py +124 -0
- sql_benchmarks/utils/integrity_monitor.py +55 -0
- sql_benchmarks/utils/providers.py +184 -0
- sql_benchmarks/utils/scaling.py +88 -0
- sql_benchmarks/utils/schema.py +118 -0
- sql_benchmarks/utils/semantic_auditor.py +81 -0
- sql_benchmarks/utils/system.py +100 -0
- sql_benchmarks/validator.py +60 -0
- sqlbenchdag-0.1.0.dist-info/METADATA +221 -0
- sqlbenchdag-0.1.0.dist-info/RECORD +153 -0
- sqlbenchdag-0.1.0.dist-info/WHEEL +5 -0
- sqlbenchdag-0.1.0.dist-info/entry_points.txt +2 -0
- sqlbenchdag-0.1.0.dist-info/licenses/LICENSE +202 -0
- sqlbenchdag-0.1.0.dist-info/top_level.txt +4 -0
- venv/bin/activate_this.py +59 -0
|
@@ -0,0 +1,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}")
|