Skip to content

Commit e6a8d6a

Browse files
committed
[π˜€π—½π—Ώ] initial version
Created using spr 1.3.7
2 parents 0c307b6 + 7dc7a48 commit e6a8d6a

13 files changed

Lines changed: 1367 additions & 2 deletions

File tree

β€Žlnt/server/db/migrations/new_suite.pyβ€Ž

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from . import upgrade_2_to_3
33
from . import upgrade_7_to_8
44
from . import upgrade_8_to_9
5+
from . import upgrade_18_to_19
56

67

78
def init_new_testsuite(engine, session, name):
@@ -17,3 +18,6 @@ def init_new_testsuite(engine, session, name):
1718
session.commit()
1819
upgrade_8_to_9.upgrade_testsuite(engine, session, name)
1920
session.commit()
21+
ts_defn = session.query(upgrade_0_to_1.TestSuite).filter_by(name=name).first()
22+
upgrade_18_to_19.upgrade_testsuite(engine, ts_defn.db_key_name)
23+
session.commit()
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Add ABRun, ABSample, and ABExperiment tables for A/B performance testing.
2+
3+
ABRun intentionally omits order_id so that A/B test runs never participate
4+
in trend analysis or FieldChange/Regression detection.
5+
6+
This migration creates the AB tables with their dynamic run/sample field
7+
columns following the same column-type rules as upgrade_0_to_1.
8+
"""
9+
10+
import sqlalchemy
11+
from sqlalchemy import (Boolean, Column, DateTime, Float, ForeignKey, Integer,
12+
LargeBinary, String, select)
13+
from sqlalchemy.orm import sessionmaker
14+
from sqlalchemy.ext.declarative import declarative_base
15+
16+
import lnt.server.db.migrations.upgrade_0_to_1 as upgrade_0_to_1
17+
from lnt.server.db.migrations.util import introspect_table
18+
19+
20+
def _add_ab_tables(test_suite):
21+
"""Return a Base with ABRun, ABSample, and ABExperiment for test_suite.
22+
23+
Machine and Test stubs are included in the same Base so that FK
24+
references resolve during create_all."""
25+
db_key_name = test_suite.db_key_name
26+
Base = declarative_base()
27+
28+
class Machine(Base):
29+
__tablename__ = db_key_name + '_Machine'
30+
__table_args__ = {'extend_existing': True}
31+
id = Column("ID", Integer, primary_key=True)
32+
33+
class Test(Base):
34+
__tablename__ = db_key_name + '_Test'
35+
__table_args__ = {'extend_existing': True}
36+
id = Column("ID", Integer, primary_key=True)
37+
38+
class ABRun(Base):
39+
__tablename__ = db_key_name + '_ABRun'
40+
id = Column("ID", Integer, primary_key=True)
41+
machine_id = Column("MachineID", Integer, ForeignKey(Machine.id),
42+
index=True)
43+
start_time = Column("StartTime", DateTime)
44+
end_time = Column("EndTime", DateTime)
45+
parameters_data = Column("Parameters", LargeBinary)
46+
47+
class_dict = locals()
48+
for item in test_suite.run_fields:
49+
class_dict[item.name] = Column(item.name, String(256))
50+
51+
class ABSample(Base):
52+
__tablename__ = db_key_name + '_ABSample'
53+
id = Column("ID", Integer, primary_key=True)
54+
run_id = Column("RunID", Integer, ForeignKey(ABRun.id), index=True)
55+
test_id = Column("TestID", Integer, ForeignKey(Test.id), index=True)
56+
57+
class_dict = locals()
58+
for item in test_suite.sample_fields:
59+
if item.type.name == 'Real':
60+
class_dict[item.name] = Column(item.name, Float)
61+
elif item.type.name == 'Status':
62+
class_dict[item.name] = Column(
63+
item.name, Integer,
64+
ForeignKey(upgrade_0_to_1.StatusKind.id))
65+
elif item.type.name == 'Hash':
66+
class_dict[item.name] = Column(item.name, String)
67+
68+
class ABExperiment(Base):
69+
__tablename__ = db_key_name + '_ABExperiment'
70+
id = Column("ID", Integer, primary_key=True)
71+
name = Column("Name", String(256))
72+
created_time = Column("CreatedTime", DateTime)
73+
extra = Column("Extra", String)
74+
pinned = Column("Pinned", Boolean, default=False)
75+
control_run_id = Column("ControlRunID", Integer,
76+
ForeignKey(ABRun.id))
77+
variant_run_id = Column("VariantRunID", Integer,
78+
ForeignKey(ABRun.id))
79+
80+
return Base
81+
82+
83+
def upgrade_testsuite(engine, db_key_name):
84+
"""Create the AB tables for a single test suite identified by db_key_name."""
85+
session = sessionmaker(engine)()
86+
try:
87+
test_suite = session.query(upgrade_0_to_1.TestSuite).filter_by(
88+
db_key_name=db_key_name).first()
89+
if test_suite is None:
90+
return
91+
Base = _add_ab_tables(test_suite)
92+
# Only create new tables; use checkfirst=True (the default) so
93+
# existing tables are left untouched.
94+
Base.metadata.create_all(engine, checkfirst=True)
95+
finally:
96+
session.close()
97+
98+
99+
def upgrade(engine):
100+
"""Create AB tables for every existing test suite."""
101+
test_suite_table = introspect_table(engine, 'TestSuite')
102+
103+
with engine.begin() as trans:
104+
suites = list(trans.execute(select([test_suite_table.c.DBKeyName])))
105+
106+
for (db_key_name,) in suites:
107+
upgrade_testsuite(engine, db_key_name)

β€Žlnt/server/db/testsuitedb.pyβ€Ž

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import aniso8601
1414
import sqlalchemy
1515
import flask
16-
from sqlalchemy import Float, String, Integer, Column, ForeignKey, Binary, DateTime
16+
from sqlalchemy import Float, String, Integer, Column, ForeignKey, Binary, Boolean, DateTime
1717
from sqlalchemy.orm import relation
1818
from sqlalchemy.orm.exc import ObjectDeletedError
1919
from 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):

β€Žlnt/server/reporting/analysis.pyβ€Ž

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,3 +419,29 @@ def _load_samples_for_runs(self, session, run_ids, only_tests):
419419
self.profile_map[(run_id, test_id)] = profile_id
420420

421421
self.loaded_run_ids |= to_load
422+
423+
424+
class ABRunInfo(RunInfo):
425+
"""Like RunInfo but queries ABSample instead of Sample.
426+
427+
Uses getattr(ts.ABSample, f.name) to reference columns rather than
428+
f.column, which points to the Sample table.
429+
"""
430+
431+
def _load_samples_for_runs(self, session, run_ids, only_tests):
432+
to_load = set(run_ids) - self.loaded_run_ids
433+
if not to_load:
434+
return
435+
436+
ABSample = self.testsuite.ABSample
437+
columns = [ABSample.run_id, ABSample.test_id]
438+
columns.extend(
439+
getattr(ABSample, f.name) for f in self.testsuite.sample_fields)
440+
q = session.query(*columns)
441+
if only_tests:
442+
q = q.filter(ABSample.test_id.in_(only_tests))
443+
q = q.filter(ABSample.run_id.in_(to_load))
444+
for (run_id, test_id, *sample_values) in q:
445+
self.sample_map[(run_id, test_id)] = sample_values
446+
447+
self.loaded_run_ids |= to_load

0 commit comments

Comments
Β (0)