44from typing import Dict , Any , Callable , List , Optional , Tuple
55
66import pytest
7+ from dotenv import load_dotenv
8+ load_dotenv ()
79
810
911def _get (key : str , default : Optional [str ] = None ) -> Optional [str ]:
@@ -293,37 +295,107 @@ def _load_dataset_fixture(
293295
294296
295297def 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" )
481587def 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