Skip to content

Commit 8c9d782

Browse files
[WIP] Add new datasets and tests for NYC taxi, FHV, TPC-DS, Clickbench queries (#1614)
* Add new datasets and tests for NYC taxi, FHV, TPC-DS, Clickbench queries * fix: download smaller clickbench dataset instead of changing its size * fix: correct curl command options for downloading datasets in integration tests * update duckdb data generation for TPC-DS * add python-dotenv to test requirements * Add metrics recording for integration tests and update test execution output * Enhance result set comparison with detailed mismatch recording and update metrics handling --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent afbcf22 commit 8c9d782

81 files changed

Lines changed: 2577 additions & 70 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/integration-tests.yml

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,16 +128,79 @@ jobs:
128128
- name: Set up DuckDB
129129
uses: opt-nc/setup-duckdb-action@v1.1.1
130130

131-
- name: Download datasets
131+
- name: Download and Prepare Datasets
132132
working-directory: ./test/integration
133133
run: |
134+
# Download existing files
134135
curl -O https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2025-01.parquet
136+
curl -O https://d37ci6vzurychx.cloudfront.net/trip-data/green_tripdata_2025-01.parquet
137+
curl -O https://d37ci6vzurychx.cloudfront.net/trip-data/fhv_tripdata_2025-01.parquet
138+
curl -o hits.parquet https://storage.googleapis.com/glaredb-bench/data/clickbench/partitioned/hits_35.parquet
135139
curl -O https://blobs.duckdb.org/data/tpch-sf1.db
136-
duckdb tpch-sf1.db -c "COPY customer TO customer.parquet (FORMAT 'PARQUET');COPY lineitem TO lineitem.parquet (FORMAT 'PARQUET');COPY nation TO nation.parquet (FORMAT 'PARQUET');COPY orders TO orders.parquet (FORMAT 'PARQUET');COPY part TO part.parquet (FORMAT 'PARQUET');COPY partsupp TO partsupp.parquet (FORMAT 'PARQUET');COPY region TO region.parquet (FORMAT 'PARQUET');COPY supplier TO supplier.parquet (FORMAT 'PARQUET');"
137-
echo $PWD
138-
ls -l
140+
141+
# Verify downloaded files exist
142+
echo "Verifying downloaded files..."
143+
for file in yellow_tripdata_2025-01.parquet green_tripdata_2025-01.parquet fhv_tripdata_2025-01.parquet tpch-sf1.db hits.parquet; do
144+
if [ ! -f "$file" ]; then
145+
echo "ERROR: Failed to download $file"
146+
exit 1
147+
else
148+
echo "✓ $file exists ($(du -h "$file" | cut -f1))"
149+
fi
150+
done
151+
152+
# Create the target directory for ClickBench data
153+
mkdir -p tpch_data
154+
mkdir -p tpcds_data
155+
156+
# Export TPC-H tables to the new directory
157+
echo "Generating TPC-H data..."
158+
duckdb tpch-sf1.db -c "
159+
COPY customer TO 'tpch_data/customer.parquet' (FORMAT 'PARQUET');
160+
COPY lineitem TO 'tpch_data/lineitem.parquet' (FORMAT 'PARQUET');
161+
COPY nation TO 'tpch_data/nation.parquet' (FORMAT 'PARQUET');
162+
COPY orders TO 'tpch_data/orders.parquet' (FORMAT 'PARQUET');
163+
COPY part TO 'tpch_data/part.parquet' (FORMAT 'PARQUET');
164+
COPY partsupp TO 'tpch_data/partsupp.parquet' (FORMAT 'PARQUET');
165+
COPY region TO 'tpch_data/region.parquet' (FORMAT 'PARQUET');
166+
COPY supplier TO 'tpch_data/supplier.parquet' (FORMAT 'PARQUET');
167+
"
168+
169+
# Verify TPC-H data generation
170+
echo "Verifying TPC-H data files..."
171+
tpch_files=(customer.parquet lineitem.parquet nation.parquet orders.parquet part.parquet partsupp.parquet region.parquet supplier.parquet)
172+
for file in "${tpch_files[@]}"; do
173+
if [ ! -f "tpch_data/$file" ]; then
174+
echo "ERROR: Failed to generate tpch_data/$file"
175+
exit 1
176+
else
177+
echo "✓ tpch_data/$file exists ($(du -h "tpch_data/$file" | cut -f1))"
178+
fi
179+
done
180+
181+
# Generate and export TPC-DS tables
182+
echo "Generating TPC-DS data..."
183+
duckdb -c "CALL dsdgen(sf=1); EXPORT DATABASE 'tpcds_data' (FORMAT PARQUET);"
184+
185+
# Verify TPC-DS data directories
186+
if [ ! "$(ls -A tpcds_data)" ]; then
187+
echo "ERROR: Failed to generate TPC-DS data files"
188+
exit 1
189+
else
190+
echo "✓ TPC-DS data generated ($(du -sh tpcds_data | cut -f1))"
191+
echo "Files: $(ls tpcds_data | wc -l)"
192+
fi
193+
194+
echo "All datasets successfully prepared."
195+
echo "Working directory contents:"
196+
ls -la
139197

140198
- name: Run integration tests
141199
working-directory: ./test/integration
142200
run: pytest -v
143201

202+
- name: Display test failures
203+
if: always()
204+
working-directory: ./test/integration
205+
run: python display_test_failures.py
206+

test/integration/conftest.py

Lines changed: 228 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from typing import Dict, Any, Callable, List, Optional, Tuple
55

66
import pytest
7+
from dotenv import load_dotenv
8+
load_dotenv()
79

810

911
def _get(key: str, default: Optional[str] = None) -> Optional[str]:
@@ -293,37 +295,107 @@ def _load_dataset_fixture(
293295

294296

295297
def compare_result_sets(
296-
a: List[Tuple[Any, ...]],
297-
b: List[Tuple[Any, ...]],
298-
rel_tol: float = 1e-6,
299-
abs_tol: float = 1e-9,
298+
a: List[Tuple[Any, ...]],
299+
b: List[Tuple[Any, ...]],
300+
rel_tol: float = 1e-6,
301+
abs_tol: float = 1e-9,
302+
metrics_recorder=None,
303+
query_id=None,
300304
) -> Tuple[bool, str]:
301305
"""Compare two result sets with type tolerance and order-insensitive.
302306
303307
Returns (ok, message). On failure, message contains a small diff.
308+
If metrics_recorder and query_id are provided, detailed mismatch info is recorded.
304309
"""
310+
mismatches = []
311+
312+
# Check row count
305313
if len(a) != len(b):
314+
mismatch_info = {
315+
"type": "row_count_mismatch",
316+
"spark_rows": len(a),
317+
"embucket_rows": len(b)
318+
}
319+
if metrics_recorder and query_id:
320+
metrics_recorder.add_mismatch(query_id, mismatch_info)
306321
return False, f"Row count differs: {len(a)} vs {len(b)}"
322+
307323
sa = _sort_rows(a)
308324
sb = _sort_rows(b)
309325
import math
310326

327+
# Check row data
311328
for i, (ra, rb) in enumerate(zip(sa, sb)):
329+
row_mismatches = {}
330+
312331
if len(ra) != len(rb):
313-
return False, f"Row {i} length differs: {len(ra)} vs {len(rb)}"
332+
mismatch_info = {
333+
"type": "row_structure_mismatch",
334+
"row_index": i,
335+
"spark_columns": len(ra),
336+
"embucket_columns": len(rb)
337+
}
338+
mismatches.append(mismatch_info)
339+
if len(mismatches) >= 10: # Limit number of mismatches collected
340+
break
341+
continue
342+
314343
for j, (va, vb) in enumerate(zip(ra, rb)):
315344
if va == vb:
316345
continue
346+
317347
# Handle numeric approx
318348
if _is_number(va) and _is_number(vb):
319349
if math.isclose(float(va), float(vb), rel_tol=rel_tol, abs_tol=abs_tol):
320350
continue
321-
return False, f"Row {i}, col {j} numeric mismatch: {va} vs {vb}"
351+
row_mismatches[j] = {
352+
"spark_value": va,
353+
"embucket_value": vb,
354+
"type": "numeric_mismatch"
355+
}
356+
continue
357+
322358
# Normalize None vs empty string edge cases cautiously
323359
if va in (None, "") and vb in (None, ""):
324360
continue
361+
325362
if str(va) != str(vb):
326-
return False, f"Row {i}, col {j} differs: {va} vs {vb}"
363+
row_mismatches[j] = {
364+
"spark_value": va,
365+
"embucket_value": vb,
366+
"type": "value_mismatch"
367+
}
368+
369+
if row_mismatches:
370+
mismatches.append({
371+
"type": "data_mismatch",
372+
"row_index": i,
373+
"columns": row_mismatches
374+
})
375+
376+
if len(mismatches) >= 10: # Limit number of mismatches collected
377+
break
378+
379+
if mismatches:
380+
if metrics_recorder and query_id:
381+
metrics_recorder.add_mismatch(query_id, {
382+
"total_mismatches": len(mismatches),
383+
"details": mismatches[:10] # Limit to first 10 for reasonable size
384+
})
385+
386+
# Format first mismatch for error message
387+
first = mismatches[0]
388+
if first["type"] == "row_structure_mismatch":
389+
msg = f"Row {first['row_index']} length differs: {first['spark_columns']} vs {first['embucket_columns']}"
390+
elif first["type"] == "data_mismatch":
391+
col_idx = next(iter(first["columns"]))
392+
col_info = first["columns"][col_idx]
393+
msg = f"Row {first['row_index']}, col {col_idx} differs: {col_info['spark_value']} vs {col_info['embucket_value']}"
394+
else:
395+
msg = f"Data mismatch in {len(mismatches)} rows"
396+
397+
return False, msg
398+
327399
return True, "OK"
328400

329401

@@ -460,7 +532,7 @@ def spark_engine(spark) -> SparkEngine:
460532

461533
# NYC Taxi Dataset Fixtures
462534
@pytest.fixture(scope="session")
463-
def nyc_taxi(request, test_run_id):
535+
def nyc_yellow_taxi(request, test_run_id):
464536
"""Parameterized NYC taxi fixture that accepts engine type.
465537
466538
Use with indirect=True parametrization:
@@ -476,6 +548,40 @@ def nyc_taxi(request, test_run_id):
476548
return _load_dataset_fixture("nyc_taxi_yellow", engine, test_run_id, engine_type)
477549

478550

551+
@pytest.fixture(scope="session")
552+
def nyc_green_taxi(request, test_run_id):
553+
"""Parameterized NYC green taxi fixture that accepts engine type.
554+
555+
Use with indirect=True parametrization:
556+
@pytest.mark.parametrize('nyc_taxi_green', ['spark', 'embucket'], indirect=True)
557+
"""
558+
engine_type = request.param
559+
if engine_type not in ["spark", "embucket"]:
560+
raise ValueError(
561+
f"Unknown engine type: {engine_type}. Use 'spark' or 'embucket'."
562+
)
563+
564+
engine = request.getfixturevalue(f"{engine_type}_engine")
565+
return _load_dataset_fixture("nyc_taxi_green", engine, test_run_id, engine_type)
566+
567+
568+
@pytest.fixture(scope="session")
569+
def fhv(request, test_run_id):
570+
"""Parameterized NYC green taxi fixture that accepts engine type.
571+
572+
Use with indirect=True parametrization:
573+
@pytest.mark.parametrize('nyc_taxi_green', ['spark', 'embucket'], indirect=True)
574+
"""
575+
engine_type = request.param
576+
if engine_type not in ["spark", "embucket"]:
577+
raise ValueError(
578+
f"Unknown engine type: {engine_type}. Use 'spark' or 'embucket'."
579+
)
580+
581+
engine = request.getfixturevalue(f"{engine_type}_engine")
582+
return _load_dataset_fixture("fhv", engine, test_run_id, engine_type)
583+
584+
479585
# TPC-H Dataset Fixtures
480586
@pytest.fixture(scope="session")
481587
def tpch_table(request, test_run_id):
@@ -532,3 +638,117 @@ def tpch_full(request, test_run_id):
532638
)
533639

534640
return loaded_tables
641+
642+
643+
@pytest.fixture(scope="session")
644+
def tpcds_full(request, test_run_id):
645+
"""Parameterized TPC-DS complete dataset fixture that accepts engine type.
646+
647+
Loads all TPC-DS tables with the specified engine and returns as dict.
648+
Use with indirect=True parametrization:
649+
@pytest.mark.parametrize('tpcds_full', ['spark', 'embucket'], indirect=True)
650+
"""
651+
engine_type = request.param
652+
tables = [
653+
"call_center", "catalog_page", "catalog_returns", "catalog_sales",
654+
"customer", "customer_address", "customer_demographics", "date_dim",
655+
"household_demographics", "income_band", "inventory", "item",
656+
"promotion", "reason", "ship_mode", "store", "store_returns",
657+
"store_sales", "time_dim", "warehouse", "web_page", "web_returns",
658+
"web_sales", "web_site"
659+
]
660+
661+
if engine_type not in ["spark", "embucket"]:
662+
raise ValueError(
663+
f"Unknown engine type: {engine_type}. Use 'spark' or 'embucket'."
664+
)
665+
666+
engine = request.getfixturevalue(f"{engine_type}_engine")
667+
668+
# Load all tables with the specified engine
669+
loaded_tables = {}
670+
for table in tables:
671+
dataset_name = f"tpcds_{table}"
672+
loaded_tables[table] = _load_dataset_fixture(
673+
dataset_name, engine, test_run_id, engine_type
674+
)
675+
676+
return loaded_tables
677+
678+
679+
@pytest.fixture(scope="session")
680+
def clickbench_hits(request, test_run_id):
681+
"""Parameterized Clickbench hits fixture that accepts engine type.
682+
683+
Use with indirect=True parametrization:
684+
@pytest.mark.parametrize('clickbench_hits', ['spark', 'embucket'], indirect=True)
685+
"""
686+
engine_type = request.param
687+
if engine_type not in ["spark", "embucket"]:
688+
raise ValueError(
689+
f"Unknown engine type: {engine_type}. Use 'spark' or 'embucket'."
690+
)
691+
692+
engine = request.getfixturevalue(f"{engine_type}_engine")
693+
return _load_dataset_fixture("clickbench_hits", engine, test_run_id, engine_type)
694+
695+
696+
# ---- Simple perf metrics plumbing ------------------------------------------
697+
from pathlib import Path
698+
from datetime import datetime
699+
import csv, os, socket, platform, json, time
700+
701+
702+
class MetricsRecorder:
703+
FIELDS = [
704+
"test_run_id", "dataset",
705+
"query_id", "rows_spark", "rows_embucket",
706+
"time_spark_ms", "time_embucket_ms", "speedup_vs_spark",
707+
"passed", "nodeid", "created_at"
708+
]
709+
710+
def __init__(self, outfile: Path, test_run_id: str):
711+
self.outfile = Path(outfile)
712+
self.test_run_id = test_run_id
713+
self.rows = []
714+
self.mismatches = {} # Store detailed mismatches by query_id
715+
self.outfile.parent.mkdir(parents=True, exist_ok=True)
716+
717+
def add(self, **kw):
718+
row = {k: kw.get(k) for k in self.FIELDS}
719+
row["test_run_id"] = self.test_run_id
720+
row["created_at"] = datetime.utcnow().isoformat(timespec="seconds") + "Z"
721+
self.rows.append(row)
722+
723+
def add_mismatch(self, query_id, mismatch_details):
724+
"""Record detailed mismatch information for a query."""
725+
if query_id not in self.mismatches:
726+
self.mismatches[query_id] = []
727+
self.mismatches[query_id].append(mismatch_details)
728+
729+
def flush(self):
730+
# Write metrics CSV
731+
write_header = not self.outfile.exists()
732+
with self.outfile.open("a", newline="") as f:
733+
w = csv.DictWriter(f, fieldnames=self.FIELDS)
734+
if write_header:
735+
w.writeheader()
736+
for r in self.rows:
737+
w.writerow(r)
738+
739+
# Write mismatches JSON if there are any
740+
if self.mismatches:
741+
mismatch_file = self.outfile.with_name(f"mismatches_{self.test_run_id}.json")
742+
with mismatch_file.open("w") as f:
743+
json.dump(self.mismatches, f, indent=2, default=str)
744+
745+
self.rows.clear()
746+
747+
@pytest.fixture(scope="session")
748+
def metrics_recorder(test_run_id) -> MetricsRecorder:
749+
# You can override paths via env if you like
750+
artifacts_dir = Path(os.getenv("INTEGRATION_ARTIFACTS_DIR", "artifacts"))
751+
artifacts_dir.mkdir(parents=True, exist_ok=True)
752+
rec = MetricsRecorder(artifacts_dir / f"metrics_{test_run_id}.csv", test_run_id)
753+
yield rec
754+
rec.flush()

0 commit comments

Comments
 (0)