1313import aniso8601
1414import sqlalchemy
1515import flask
16- from sqlalchemy import Float , String , Integer , Column , ForeignKey , Binary , DateTime
16+ from sqlalchemy import Float , String , Integer , Column , ForeignKey , Binary , Boolean , DateTime
1717from sqlalchemy .orm import relation
1818from sqlalchemy .orm .exc import ObjectDeletedError
1919from lnt .util import logger
@@ -756,6 +756,81 @@ class Baseline(self.base, ParameterizedMixin):
756756 def __str__ (self ):
757757 return "Baseline({})" .format (self .name )
758758
759+ # A/B testing tables. ABRun intentionally omits order_id so that A/B
760+ # runs never participate in trend analysis or FieldChange/Regression
761+ # detection.
762+
763+ class ABRun (self .base , ParameterizedMixin ):
764+ __tablename__ = db_key_name + '_ABRun'
765+
766+ fields = self .run_fields
767+ id = Column ("ID" , Integer , primary_key = True )
768+ machine_id = Column ("MachineID" , Integer , ForeignKey (Machine .id ),
769+ index = True )
770+ start_time = Column ("StartTime" , DateTime )
771+ end_time = Column ("EndTime" , DateTime )
772+ parameters_data = Column ("Parameters" , Binary , index = False ,
773+ unique = False )
774+
775+ machine = relation (Machine )
776+
777+ # Dynamic run-field columns. Create fresh Column objects rather
778+ # than reusing item.column, which points to the Run table.
779+ class_dict = locals ()
780+ for item in fields :
781+ class_dict [item .name ] = testsuite .make_run_column (item .name )
782+
783+ @property
784+ def parameters (self ):
785+ return dict (json .loads (self .parameters_data ))
786+
787+ @parameters .setter
788+ def parameters (self , data ):
789+ self .parameters_data = json .dumps (
790+ sorted (data .items ())).encode ("utf-8" )
791+
792+ class ABSample (self .base , ParameterizedMixin ):
793+ __tablename__ = db_key_name + '_ABSample'
794+
795+ fields = self .sample_fields
796+ id = Column ("ID" , Integer , primary_key = True )
797+ run_id = Column ("RunID" , Integer , ForeignKey (ABRun .id ), index = True )
798+ test_id = Column ("TestID" , Integer , ForeignKey (Test .id ),
799+ index = True )
800+
801+ run = relation (ABRun )
802+ test = relation (Test )
803+
804+ # Dynamic sample-field columns. Create fresh Column objects rather
805+ # than reusing item.column, which points to the Sample table.
806+ class_dict = locals ()
807+ for item in fields :
808+ class_dict [item .name ] = testsuite .make_sample_column (
809+ item .name , item .type .name )
810+
811+ ABRun .ab_samples = relation (ABSample , back_populates = 'run' ,
812+ cascade = "all, delete-orphan" )
813+
814+ class ABExperiment (self .base , ParameterizedMixin ):
815+ __tablename__ = db_key_name + '_ABExperiment'
816+
817+ fields = []
818+ id = Column ("ID" , Integer , primary_key = True )
819+ name = Column ("Name" , String (256 ))
820+ created_time = Column ("CreatedTime" , DateTime )
821+ extra = Column ("Extra" , String )
822+ pinned = Column ("Pinned" , Boolean , default = False )
823+
824+ # Two FK references to ABRun; foreign_keys disambiguates them.
825+ control_run_id = Column ("ControlRunID" , Integer ,
826+ ForeignKey (ABRun .id ))
827+ variant_run_id = Column ("VariantRunID" , Integer ,
828+ ForeignKey (ABRun .id ))
829+ control_run = relation (ABRun ,
830+ foreign_keys = [control_run_id ])
831+ variant_run = relation (ABRun ,
832+ foreign_keys = [variant_run_id ])
833+
759834 self .Machine = Machine
760835 self .Run = Run
761836 self .Test = Test
@@ -767,10 +842,15 @@ def __str__(self):
767842 self .RegressionIndicator = RegressionIndicator
768843 self .ChangeIgnore = ChangeIgnore
769844 self .Baseline = Baseline
845+ self .ABRun = ABRun
846+ self .ABSample = ABSample
847+ self .ABExperiment = ABExperiment
770848
771849 # Create the compound index we cannot declare inline.
772850 sqlalchemy .schema .Index ("ix_%s_Sample_RunID_TestID" % db_key_name ,
773851 Sample .run_id , Sample .test_id )
852+ sqlalchemy .schema .Index ("ix_%s_ABSample_RunID_TestID" % db_key_name ,
853+ ABSample .run_id , ABSample .test_id )
774854
775855 def create_tables (self , engine ):
776856 self .base .metadata .create_all (engine )
@@ -1125,6 +1205,99 @@ def importDataFromDict(self, session, data, config, select_machine,
11251205 self ._importSampleValues (session , data ['tests' ], run , config )
11261206 return run
11271207
1208+ def _importABRun (self , session , run_data , machine ):
1209+ """Create and insert an ABRun for the given machine.
1210+
1211+ No order tracking is performed; A/B runs are isolated from trend
1212+ analysis and regression detection.
1213+ """
1214+ run_parameters = run_data .copy ()
1215+ run_parameters .pop ('id' , None )
1216+ run_parameters .pop ('order_by' , None )
1217+ run_parameters .pop ('order_id' , None )
1218+ run_parameters .pop ('machine_id' , None )
1219+
1220+ start_time_str = run_parameters .pop ('start_time' , None )
1221+ if start_time_str :
1222+ try :
1223+ start_time = aniso8601 .parse_datetime (start_time_str )
1224+ except ValueError :
1225+ start_time = datetime .datetime .strptime (start_time_str ,
1226+ "%Y-%m-%d %H:%M:%S" )
1227+ else :
1228+ start_time = None
1229+
1230+ end_time_str = run_parameters .pop ('end_time' , None )
1231+ if end_time_str :
1232+ try :
1233+ end_time = aniso8601 .parse_datetime (end_time_str )
1234+ except ValueError :
1235+ end_time = datetime .datetime .strptime (end_time_str ,
1236+ "%Y-%m-%d %H:%M:%S" )
1237+ else :
1238+ end_time = None
1239+
1240+ run = self .ABRun ()
1241+ run .machine = machine
1242+ run .start_time = start_time
1243+ run .end_time = end_time
1244+ for item in self .run_fields :
1245+ value = run_parameters .pop (item .name , None )
1246+ run .set_field (item , value )
1247+ run .parameters = run_parameters
1248+ session .add (run )
1249+ return run
1250+
1251+ def _importABSampleValues (self , session , tests_data , run ):
1252+ """Create ABSample rows for the given tests data and ABRun.
1253+
1254+ Mirrors _importSampleValues but creates ABSample objects and skips
1255+ profile handling.
1256+ """
1257+ test_cache = dict ((test .name , test )
1258+ for test in session .query (self .Test ))
1259+ field_dict = dict ([(f .name , f ) for f in self .sample_fields ])
1260+ all_samples = []
1261+
1262+ for test_data in tests_data :
1263+ name = test_data ['name' ]
1264+ test = test_cache .get (name )
1265+ if test is None :
1266+ test = self .Test (test_data ['name' ])
1267+ test_cache [name ] = test
1268+ session .add (test )
1269+
1270+ samples = []
1271+ for key , values in test_data .items ():
1272+ if key == 'name' or key == 'id' or key .endswith ('_id' ):
1273+ continue
1274+ field = field_dict .get (key )
1275+ if field is None :
1276+ raise ValueError ("test %s: Metric %r unknown in suite" %
1277+ (name , key ))
1278+ if not isinstance (values , list ):
1279+ values = [values ]
1280+ while len (samples ) < len (values ):
1281+ sample = self .ABSample ()
1282+ sample .run = run
1283+ sample .test = test
1284+ samples .append (sample )
1285+ all_samples .append (sample )
1286+ for sample , value in zip (samples , values ):
1287+ sample .set_field (field , value )
1288+
1289+ session .add_all (all_samples )
1290+
1291+ def importABDataFromDict (self , session , data ):
1292+ """Import a report into ABRun/ABSample tables.
1293+
1294+ No order or regression tracking is performed.
1295+ """
1296+ machine = self ._getOrCreateMachine (session , data ['machine' ], 'match' )
1297+ run = self ._importABRun (session , data ['run' ], machine )
1298+ self ._importABSampleValues (session , data ['tests' ], run )
1299+ return run
1300+
11281301 # Simple query support (mostly used by templates)
11291302
11301303 def machines (self , session , name = None ):
0 commit comments