Skip to content

Commit 7880a2b

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

17 files changed

Lines changed: 1861 additions & 2 deletions

β€Žlnt/lnttool/__init__.pyβ€Ž

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from .runtest import group_runtest
1414
from .send_daily_report import action_send_daily_report
1515
from .send_run_comparison import action_send_run_comparison
16+
from .abtest import group_abtest
17+
from .expire_abtests import action_expire_abtests
1618
from .showtests import action_showtests
1719
from .submit import action_submit
1820
from .updatedb import action_updatedb
@@ -45,6 +47,8 @@ def main():
4547
main.add_command(action_checkformat)
4648
main.add_command(action_convert)
4749
main.add_command(action_create)
50+
main.add_command(group_abtest)
51+
main.add_command(action_expire_abtests)
4852
main.add_command(action_import)
4953
main.add_command(action_importreport)
5054
main.add_command(action_profile)

β€Žlnt/lnttool/abtest.pyβ€Ž

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""lnt abtest β€” manage A/B performance experiments on a remote LNT server."""
2+
import json
3+
import ssl
4+
import urllib.error
5+
import urllib.request
6+
7+
import certifi
8+
import click
9+
10+
11+
def _api_url(server_url, database, testsuite, *path_parts):
12+
base = '%s/api/db_%s/v4/%s' % (server_url.rstrip('/'), database, testsuite)
13+
if path_parts:
14+
return '%s/%s' % (base, '/'.join(str(p) for p in path_parts))
15+
return base
16+
17+
18+
def _api_request(method, url, body=None, auth_token=None):
19+
headers = {'Content-Type': 'application/json', 'Accept': 'application/json'}
20+
if auth_token:
21+
headers['AuthToken'] = auth_token
22+
data = json.dumps(body).encode() if body is not None else None
23+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
24+
context = ssl.create_default_context(cafile=certifi.where())
25+
try:
26+
resp = urllib.request.urlopen(req, context=context)
27+
return json.loads(resp.read())
28+
except urllib.error.HTTPError as e:
29+
body_text = e.read().decode(errors='replace')
30+
raise click.ClickException('HTTP %d: %s' % (e.code, body_text))
31+
except urllib.error.URLError as e:
32+
raise click.ClickException('Could not connect to %s: %s' % (url, e))
33+
34+
35+
@click.group("abtest")
36+
def group_abtest():
37+
"""manage A/B performance experiments on a remote LNT server"""
38+
39+
40+
@group_abtest.command("create")
41+
@click.argument("server_url")
42+
@click.option("--database", default="default", show_default=True,
43+
help="LNT database name")
44+
@click.option("--testsuite", "-s", default="nts", show_default=True,
45+
help="testsuite name")
46+
@click.option("--name", default="",
47+
help="human-readable experiment name")
48+
@click.option("--control", "control_file",
49+
type=click.Path(exists=True), default=None,
50+
help="control run report JSON")
51+
@click.option("--variant", "variant_file",
52+
type=click.Path(exists=True), default=None,
53+
help="variant run report JSON")
54+
@click.option("--auth-token", envvar="LNT_AUTH_TOKEN",
55+
help="API auth token (or set LNT_AUTH_TOKEN)")
56+
def action_abtest_create(server_url, database, testsuite, name,
57+
control_file, variant_file, auth_token):
58+
"""Create an A/B experiment on a remote LNT server.
59+
60+
\b
61+
Two workflows are supported:
62+
63+
Atomic β€” both runs available at the same time:
64+
65+
lnt abtest create SERVER --name "pr-42" \\
66+
--control control.json --variant variant.json
67+
68+
Two-phase β€” control and variant submitted by independent CI jobs:
69+
70+
# Orchestrator: create the experiment and capture the ID
71+
ID=$(lnt abtest create SERVER --name "pr-42")
72+
73+
# Control CI job
74+
lnt abtest submit SERVER $ID --control control.json
75+
76+
# Variant CI job
77+
lnt abtest submit SERVER $ID --variant variant.json
78+
"""
79+
if bool(control_file) != bool(variant_file):
80+
raise click.UsageError(
81+
"Provide both --control and --variant for atomic creation, "
82+
"or neither to create a pending experiment.")
83+
84+
body = {'name': name}
85+
if control_file:
86+
with open(control_file) as f:
87+
body['control'] = json.load(f)
88+
with open(variant_file) as f:
89+
body['variant'] = json.load(f)
90+
91+
url = _api_url(server_url, database, testsuite, 'abtest')
92+
result = _api_request('POST', url, body=body, auth_token=auth_token)
93+
94+
# Print just the ID to stdout so scripts can capture it with $(...).
95+
click.echo(result['id'])
96+
exp_url = result.get('url')
97+
if exp_url:
98+
click.echo('Experiment: %s' % exp_url, err=True)
99+
100+
101+
@group_abtest.command("submit")
102+
@click.argument("server_url")
103+
@click.argument("experiment_id", type=int)
104+
@click.option("--database", default="default", show_default=True,
105+
help="LNT database name")
106+
@click.option("--testsuite", "-s", default="nts", show_default=True,
107+
help="testsuite name")
108+
@click.option("--control", "control_file",
109+
type=click.Path(exists=True), default=None,
110+
help="submit this JSON as the control run")
111+
@click.option("--variant", "variant_file",
112+
type=click.Path(exists=True), default=None,
113+
help="submit this JSON as the variant run")
114+
@click.option("--auth-token", envvar="LNT_AUTH_TOKEN",
115+
help="API auth token (or set LNT_AUTH_TOKEN)")
116+
def action_abtest_submit(server_url, experiment_id, database, testsuite,
117+
control_file, variant_file, auth_token):
118+
"""Submit a control or variant run to an existing A/B experiment.
119+
120+
\b
121+
Used in the two-phase workflow after 'lnt abtest create' has returned an ID:
122+
123+
lnt abtest submit SERVER ID --control control.json
124+
lnt abtest submit SERVER ID --variant variant.json
125+
"""
126+
if not control_file and not variant_file:
127+
raise click.UsageError("Provide --control or --variant.")
128+
if control_file and variant_file:
129+
raise click.UsageError(
130+
"Provide --control or --variant, not both. "
131+
"To submit both at once use 'lnt abtest create'.")
132+
133+
role = 'control' if control_file else 'variant'
134+
report_file = control_file or variant_file
135+
136+
with open(report_file) as f:
137+
body = json.load(f)
138+
139+
url = _api_url(server_url, database, testsuite, 'abtest', experiment_id, role)
140+
_api_request('POST', url, body=body, auth_token=auth_token)
141+
click.echo('Submitted %s run for experiment %d.' % (role, experiment_id))
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import contextlib
2+
import datetime
3+
import re
4+
5+
import click
6+
7+
8+
def _parse_age(value):
9+
"""Parse an age string like '90d', '4w', '6m', '1y' into a cutoff datetime."""
10+
m = re.fullmatch(r'(\d+)([dwmy])', value)
11+
if not m:
12+
raise click.BadParameter(
13+
"expected a positive integer followed by d/w/m/y "
14+
"(e.g. 90d, 4w, 6m, 1y)",
15+
param_hint="'--older-than'")
16+
n, unit = int(m.group(1)), m.group(2)
17+
days = {'d': n, 'w': n * 7, 'm': n * 30, 'y': n * 365}[unit]
18+
return datetime.datetime.utcnow() - datetime.timedelta(days=days)
19+
20+
21+
@click.command("expire-abtests")
22+
@click.argument("instance_path", type=click.UNPROCESSED)
23+
@click.option("--database", default="default", show_default=True,
24+
help="database to expire experiments from")
25+
@click.option("--testsuite", "-s", default="nts", show_default=True,
26+
help="testsuite to expire experiments from")
27+
@click.option("--older-than", "older_than", required=True,
28+
help="delete experiments older than this age (e.g. 90d, 4w, 6m, 1y)")
29+
@click.option("--dry-run", is_flag=True,
30+
help="print what would be deleted without making any changes")
31+
def action_expire_abtests(instance_path, database, testsuite,
32+
older_than, dry_run):
33+
"""Delete unpinned A/B experiments older than a given age.
34+
35+
\b
36+
Removes unpinned ABExperiment records (and their associated ABRun and
37+
ABSample rows) whose creation time predates the specified age threshold.
38+
Pinned ('Keep Forever') experiments are never deleted.
39+
40+
\b
41+
Age format: a positive integer followed by a unit:
42+
d days (e.g. 90d)
43+
w weeks (e.g. 4w)
44+
m months (approx 30 days each, e.g. 6m)
45+
y years (approx 365 days each, e.g. 1y)
46+
"""
47+
import lnt.server.instance
48+
49+
cutoff = _parse_age(older_than)
50+
51+
instance = lnt.server.instance.Instance.frompath(instance_path)
52+
with contextlib.closing(instance.get_database(database)) as db:
53+
session = db.make_session()
54+
ts = db.testsuite[testsuite]
55+
56+
to_delete = (
57+
session.query(ts.ABExperiment)
58+
.filter(ts.ABExperiment.created_time < cutoff,
59+
ts.ABExperiment.pinned == False) # noqa: E712
60+
.all())
61+
62+
if not to_delete:
63+
click.echo("No experiments to delete.")
64+
return
65+
66+
for exp in to_delete:
67+
click.echo("%s experiment #%d: %s" % (
68+
"Would delete" if dry_run else "Deleting",
69+
exp.id, exp.name or "(unnamed)"))
70+
71+
if dry_run:
72+
return
73+
74+
# Delete ABSample and ABRun children before the ABExperiment rows to
75+
# respect foreign-key constraints.
76+
run_ids = [rid for exp in to_delete
77+
for rid in (exp.control_run_id, exp.variant_run_id)
78+
if rid is not None]
79+
80+
session.query(ts.ABSample) \
81+
.filter(ts.ABSample.run_id.in_(run_ids)) \
82+
.delete(synchronize_session=False)
83+
84+
session.query(ts.ABRun) \
85+
.filter(ts.ABRun.id.in_(run_ids)) \
86+
.delete(synchronize_session=False)
87+
88+
for exp in to_delete:
89+
session.delete(exp)
90+
91+
session.commit()
92+
click.echo("Deleted %d experiment%s." %
93+
(len(to_delete), "s" if len(to_delete) != 1 else ""))

β€Ž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)

0 commit comments

Comments
Β (0)