Skip to content

Commit 088755b

Browse files
ldionneclaude
andcommitted
[API] Add multi-value test to POST /query and machine/metric filters to /tests
Change the /query endpoint from GET to POST with a JSON body to eliminate URL length limits when querying many tests with long names. The test field accepts a list for disjunction queries. Unknown test names are silently skipped. The schema uses marshmallow's unknown=RAISE to reject unknown fields (returning 422 instead of the previous 400). Add optional machine= and metric= query parameters to GET /tests so clients can discover which tests have actual data for a given machine and/or metric combination, joining through Sample -> Run with DISTINCT. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 7a00d63 commit 088755b

9 files changed

Lines changed: 696 additions & 313 deletions

File tree

docs/design/v5-api.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ Tests
5050
GET /tests — List (cursor-paginated, filterable)
5151
GET /tests/{test_name} — Detail
5252
Read-only. Tests are created implicitly via run submission.
53+
Filters: name_contains=, name_prefix=, machine= (only tests with data for this machine), metric= (only tests with non-NULL values for this metric).
5354

5455
Samples
5556

@@ -106,12 +107,12 @@ Creating a field change requires: machine (name), test (name), metric (name), ol
106107

107108
Time Series
108109

109-
GET /query
110-
Query params: machine={name}&test={name}&metric={name}&order={order}
111-
&after_order={order}&before_order={order}
112-
&after_time={iso8601}&before_time={iso8601}&sort={fields}&limit={n}&cursor={c}
113-
The metric parameter is required; all other query parameters are optional.
114-
The order parameter filters for an exact order match and cannot be combined with after_order/before_order.
110+
POST /query
111+
Body (JSON): {metric, machine, test, order, after_order, before_order,
112+
after_time, before_time, sort, limit, cursor}
113+
The metric field is required; all other fields are optional.
114+
The test field accepts a list of names for disjunction queries.
115+
The order field filters for an exact order match and cannot be combined with after_order/before_order.
115116
Returns cursor-paginated time-series data for graphing. Uses field names (not indices) to be self-documenting.
116117

117118
Schema and Fields

docs/design/v5-ui.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,11 +181,11 @@ The primary performance-over-time visualization. Replaces v4's graph page. This
181181
- **Zoom preservation during progressive loading**: If the user zooms into the chart while data is still loading, the zoom is preserved across incremental updates. The x-axis range is always preserved (it was established by the scaffold or by user zoom). The y-axis range is preserved only when the user has explicitly zoomed; otherwise, it auto-ranges to accommodate new data as it arrives. Double-clicking the chart resets the zoom to the full range as usual.
182182
- **Legend table**: Below the chart, a table lists traces sorted alphabetically by name (not in a scrollable container — the table is part of the page flow, like the Compare page's table). A message line above the table rows always shows a matching count (e.g., "42 of 150 tests matching"); when the 20-cap is active, the cap warning replaces it. Each row represents one trace (`{test name} - {machine name}`), with a colored marker symbol character (●, ▲, ■, etc. in the trace's color) identifying both the test (by color) and the machine (by shape). The test filter matches on test name only — matching test names show all their machine variants; non-matching names are hidden entirely. Tests that are inactive (manually hidden or beyond the 20-cap) are grayed out in place. Clicking a row toggles the test's visibility on the chart. Double-clicking a row is a shortcut for hiding all other visible tests (equivalent to single-clicking every other test one by one) — it populates `manuallyHidden` with all visible tests except the double-clicked one. Double-clicking the same test again when it is the only visible test restores all tests. Subsequent single-clicks work naturally against the `manuallyHidden` set. Plotly's built-in legend is disabled; the table replaces it. Bidirectional hover highlighting: hovering a table row highlights the corresponding chart trace by emphasizing the entire trace line (thicker line, full opacity) while dimming all other traces via `Plotly.restyle()`; hovering a chart trace highlights the table row.
183183
- **Active state and 20-cap**: A trace is active (plotted) when its test name matches the text filter and it has not been manually hidden by clicking its row. A default 20-test cap auto-activates the first 20 alphabetical trace names when there is no text filter and no manual toggles. As soon as the user types a filter or manually toggles any row, the cap is permanently disabled for the rest of the page session. Colors are assigned by alphabetical index of all **test names** (not trace names) using a fixed palette, ensuring the same test on different machines shares the same color.
184-
- **Baselines**: Users can overlay one or more baselines as horizontal dashed lines on the chart. Each baseline is a (suite, machine, order) tuple, allowing cross-suite comparisons. The selector is an expandable panel with cascading dropdowns: Suite (populated from `data-testsuites`) → Machine (populated from the selected suite's machines endpoint) → Order (populated from the selected machine's orders). Added baselines appear as removable chips labeled `{suite}/{machine}/{order} ({tag})`. Baseline data is fetched from the baseline's suite via `GET /api/v5/{suite}/query?machine=...&metric=...&order=...`. Each baseline renders as a horizontal dashed line per test trace, spanning the full chart width, in a distinct color per baseline. The baseline's Y value for each test is computed using the same run aggregation function as the main trace (e.g., median of all runs at that order), so the dashed line aligns exactly with the trace point at that order. Hovering a dashed line shows a tooltip with: the baseline suite, machine, order value, tag (if set), test name, and metric value. Baselines are encoded in the URL query string for shareability (e.g., `&baseline=nts::machine1::abc123&baseline=other_suite::machine2::def456`). Baseline data is fetched asynchronously after the first render, so it does not block initial chart display.
184+
- **Baselines**: Users can overlay one or more baselines as horizontal dashed lines on the chart. Each baseline is a (suite, machine, order) tuple, allowing cross-suite comparisons. The selector is an expandable panel with cascading dropdowns: Suite (populated from `data-testsuites`) → Machine (populated from the selected suite's machines endpoint) → Order (populated from the selected machine's orders). Added baselines appear as removable chips labeled `{suite}/{machine}/{order} ({tag})`. Baseline data is fetched from the baseline's suite via `POST /api/v5/{suite}/query` with `{machine, metric, order, test}` in the JSON body. Each baseline renders as a horizontal dashed line per test trace, spanning the full chart width, in a distinct color per baseline. The baseline's Y value for each test is computed using the same run aggregation function as the main trace (e.g., median of all runs at that order), so the dashed line aligns exactly with the trace point at that order. Hovering a dashed line shows a tooltip with: the baseline suite, machine, order value, tag (if set), test name, and metric value. Baselines are encoded in the URL query string for shareability (e.g., `&baseline=nts::machine1::abc123&baseline=other_suite::machine2::def456`). Baseline data is fetched asynchronously after the first render, so it does not block initial chart display.
185185
- **Concurrent background fetches**: Each machine×metric fetch uses its own AbortController, so navigating away or removing a machine cancels its in-flight requests cleanly without affecting other machines' fetches.
186186
- **Hover** a data point: tooltip showing test name, machine name, order value, aggregated metric value, run count. Hover distance is reduced (`hoverdistance: 5`, less sticky tooltips) so the tooltip only appears when the cursor is close to a data point. When hovering over an aggregated point that represents multiple runs, the individual pre-aggregation values are shown as a scatter of markers at the same x-position, in the same trace color but faded (opacity 0.3). This scatter is computed lazily via a callback and displayed as a temporary Plotly trace that is added on hover and removed on unhover.
187187
- **"No data to plot" annotation**: When no traces match the current filter/settings, the chart displays a Plotly annotation overlay ("No data to plot") centered on the chart area, preserving the x-axis scaffold so the user can see the order range.
188-
- API: `GET query?machine=...&metric=...&sort=-order&limit=10000` (one fetch pipeline per machine, newest-first with cursor pagination — returns data for all tests, filtered client-side), `GET machines/{name}/runs?sort=order` (x-axis scaffold, per machine), `GET orders` (tags for baseline suggestions), `GET machines` (machine combobox), `GET test-suites/{ts}` (fields/metrics)
188+
- API: `POST query` with JSON body `{machine, metric, test, sort, limit, cursor}` (one fetch pipeline per machine, targeted to discovered tests via multi-value `test`), `GET tests?machine=...&metric=...&name_contains=...` (test name discovery), `GET machines/{name}/runs?sort=order` (x-axis scaffold, per machine), `GET orders` (tags for baseline suggestions), `GET machines` (machine combobox), `GET test-suites/{ts}` (fields/metrics)
189189
- **URL state**: `?suite={ts}&machine={name}&machine={name2}&metric={name}&test_filter={text}&run_agg={fn}&sample_agg={fn}&baseline={suite}::{machine}::{order}&baseline={suite2}::{machine2}::{order2}` — the `machine` parameter is repeated for each selected machine; the `baseline` parameter is repeated for each baseline.
190190
- **Links out**: Compare
191191

docs/v5-api-implementation-plan.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,10 @@ GET /api/v5/{ts}/tests/{test_name} — Detail
436436
**Key design decisions:**
437437
- Read-only. Tests created implicitly via run submission.
438438
- Use `<path:test_name>` converter for test names with slashes
439-
- Filter: `name_contains=`, `name_prefix=`
439+
- Filter: `name_contains=`, `name_prefix=`, `machine=`, `metric=`
440+
- `machine=` and `metric=` join through Sample → Run to return only tests
441+
that have actual data for the given machine and/or metric. The query uses
442+
`DISTINCT` to deduplicate.
440443
- Escape `%` and `_` in user-supplied LIKE patterns to prevent pattern injection
441444
- Auth: read only.
442445

@@ -567,14 +570,20 @@ DELETE /api/v5/{ts}/field-changes/{uuid}/ignore — Un-ignore
567570

568571
**Endpoint:**
569572
```
570-
GET /api/v5/{ts}/series?machine={name}&test={name}&field={name}&after={order}&before={order}&limit={n}
573+
POST /api/v5/{ts}/query
574+
Body (JSON): {metric, machine, test, order, after_order, before_order,
575+
after_time, before_time, sort, limit, cursor}
571576
```
572577

573578
**Key design decisions:**
574-
- `machine`, `test`, `field` are all REQUIRED (by name, not ID)
579+
- Uses POST with a JSON body (not GET with query params) to avoid URL length
580+
limits when querying many tests with long names.
581+
- `metric` is REQUIRED (by name, not ID). All other fields are optional.
582+
- `test` is a list of test names for disjunction queries.
583+
Unknown test names are silently skipped (no 404).
575584
- Field name → Sample column resolution via `ts.sample_fields` name→column mapping
576585
- Core query: `SELECT field.column, order.*, run.* FROM Sample JOIN Run JOIN Order
577-
WHERE machine_id=X AND test_id=Y AND field IS NOT NULL`
586+
WHERE machine_id=X AND test_id IN (...) AND field IS NOT NULL`
578587
- Filter out failing tests if the field has a status_field
579588
- Order filtering: `order` for exact match (=), `after_order`/`before_order` for
580589
exclusive range (>/< on Order.id). `order` cannot be combined with range params.

lnt/server/api/v5/endpoints/query.py

Lines changed: 36 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
"""Query endpoint for the v5 API.
22
3-
GET /api/v5/{ts}/query?metric={name}&machine={name}&test={name}
4-
&order={order}
5-
&after_order={order}&before_order={order}
6-
&after_time={iso8601}&before_time={iso8601}
7-
&sort={fields}&limit={n}&cursor={c}
8-
9-
Returns cursor-paginated data points. The metric parameter is required;
10-
all other filter parameters are optional.
3+
POST /api/v5/{ts}/query
4+
Body (JSON): {metric, machine, test, order, after_order, before_order,
5+
after_time, before_time, sort, limit, cursor}
6+
7+
Returns cursor-paginated data points. The metric field is required;
8+
all other fields are optional. The test field accepts a list of names
9+
for disjunction queries.
1110
"""
1211

1312
import base64
@@ -20,7 +19,7 @@
2019
from lnt.testing import PASS
2120

2221
from ..auth import require_scope
23-
from ..errors import abort_with_error, reject_unknown_params
22+
from ..errors import abort_with_error
2423
from ..helpers import parse_datetime, resolve_metric
2524
from ..pagination import make_paginated_response
2625
from ..schemas.query import QueryEndpointQuerySchema, QueryResponseSchema
@@ -36,13 +35,6 @@
3635
_DEFAULT_LIMIT = 100
3736
_MAX_LIMIT = 10000
3837

39-
# Valid query parameter names for the /query endpoint.
40-
_VALID_QUERY_PARAMS = {
41-
'machine', 'test', 'metric', 'order',
42-
'after_order', 'before_order', 'after_time', 'before_time',
43-
'sort', 'limit', 'cursor',
44-
}
45-
4638
# Allowed sort field names and the columns they map to.
4739
# The actual column references are resolved at query time since the
4840
# model classes are dynamic per test suite.
@@ -248,11 +240,13 @@ def _resolve_order(session, ts, order_value):
248240
return order, None
249241

250242

251-
def _query_for_field(session, ts, sample_field, machine, test,
243+
def _query_for_field(session, ts, sample_field, machine, test_ids,
252244
sort_spec, cursor_values, order, after_order,
253245
before_order, after_time, before_time, limit):
254246
"""Build and execute a query for a single sample field.
255247
248+
*test_ids* is a list of test IDs to filter by, or None for no filter.
249+
256250
Returns a list of dicts ready for serialization, plus a boolean
257251
indicating whether there are more results.
258252
"""
@@ -279,8 +273,11 @@ def _query_for_field(session, ts, sample_field, machine, test,
279273
# Apply optional filters.
280274
if machine is not None:
281275
q = q.filter(ts.Run.machine_id == machine.id)
282-
if test is not None:
283-
q = q.filter(ts.Sample.test_id == test.id)
276+
if test_ids is not None:
277+
if len(test_ids) == 1:
278+
q = q.filter(ts.Sample.test_id == test_ids[0])
279+
else:
280+
q = q.filter(ts.Sample.test_id.in_(test_ids))
284281

285282
# Apply exact order filter.
286283
if order is not None:
@@ -347,28 +344,23 @@ class QueryView(MethodView):
347344
"""Query data points."""
348345

349346
@require_scope('read')
350-
@blp.arguments(QueryEndpointQuerySchema, location="query")
347+
@blp.arguments(QueryEndpointQuerySchema, location="json")
351348
@blp.response(200, QueryResponseSchema)
352-
def get(self, query_args, testsuite):
349+
def post(self, query_args, testsuite):
353350
"""Query data points.
354351
355-
Returns cursor-paginated data points. The metric parameter is
356-
required; all other filter parameters are optional -- omit any
357-
to get data across all values of that dimension.
352+
Returns cursor-paginated data points. The metric field is
353+
required; all other fields are optional -- omit any to get
354+
data across all values of that dimension.
358355
"""
359-
# Reject unknown query parameters early so that typos like
360-
# ``machine_name=`` or ``metric_name=`` don't silently return
361-
# unfiltered data.
362-
reject_unknown_params(_VALID_QUERY_PARAMS)
363-
364356
ts = g.ts
365357
session = g.db_session
366358

367359
# ------------------------------------------------------------------
368360
# Parse filter parameters
369361
# ------------------------------------------------------------------
370362
machine_name = query_args.get('machine')
371-
test_name = query_args.get('test')
363+
test_names = query_args.get('test')
372364
field_name = query_args['metric']
373365

374366
# Resolve entities when provided.
@@ -378,11 +370,19 @@ def get(self, query_args, testsuite):
378370
if err:
379371
abort_with_error(status, err)
380372

381-
test = None
382-
if test_name:
383-
test, err = _resolve_test(session, ts, test_name)
384-
if err:
385-
abort_with_error(404, err)
373+
test_ids = None
374+
if test_names:
375+
test_ids = []
376+
for tn in test_names:
377+
test, err = _resolve_test(session, ts, tn)
378+
if err:
379+
# Silently skip unknown test names — return no data
380+
# for them rather than 404-ing the entire request.
381+
continue
382+
test_ids.append(test.id)
383+
if not test_ids:
384+
# All requested tests are unknown — return empty response.
385+
return jsonify(make_paginated_response([], None))
386386

387387
field = resolve_metric(ts, field_name)
388388

@@ -461,7 +461,7 @@ def get(self, query_args, testsuite):
461461
# Execute query
462462
# ------------------------------------------------------------------
463463
items, has_next = _query_for_field(
464-
session, ts, field, machine, test,
464+
session, ts, field, machine, test_ids,
465465
sort_spec, cursor_values, order, after_order, before_order,
466466
after_time, before_time, limit)
467467

lnt/server/api/v5/endpoints/tests.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from ..auth import require_scope
1212
from ..errors import reject_unknown_params
1313
from ..etag import add_etag_to_response
14-
from ..helpers import escape_like, lookup_test
14+
from ..helpers import escape_like, lookup_machine, lookup_test, resolve_metric
1515
from ..pagination import (
1616
cursor_paginate,
1717
make_paginated_response,
@@ -44,12 +44,31 @@ class TestList(MethodView):
4444
@blp.response(200, PaginatedTestResponseSchema)
4545
def get(self, query_args, testsuite):
4646
"""List tests (cursor-paginated, filterable)."""
47-
reject_unknown_params({'name_contains', 'name_prefix', 'cursor', 'limit'})
47+
reject_unknown_params({
48+
'name_contains', 'name_prefix', 'machine', 'metric',
49+
'cursor', 'limit',
50+
})
4851
ts = g.ts
4952
session = g.db_session
5053

5154
query = session.query(ts.Test)
5255

56+
# Machine / metric filters require joining through Sample
57+
# (and additionally through Run when machine= is specified).
58+
machine_name = query_args.get('machine')
59+
metric_name = query_args.get('metric')
60+
if machine_name or metric_name:
61+
query = query.join(ts.Sample, ts.Sample.test_id == ts.Test.id)
62+
if machine_name:
63+
machine = lookup_machine(session, ts, machine_name)
64+
query = query.join(ts.Run).filter(
65+
ts.Run.machine_id == machine.id)
66+
if metric_name:
67+
field = resolve_metric(ts, metric_name)
68+
query = query.filter(field.column.isnot(None))
69+
if machine_name or metric_name:
70+
query = query.distinct()
71+
5372
# Apply filters
5473
name_contains = query_args.get('name_contains')
5574
if name_contains:

lnt/server/api/v5/schemas/query.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import marshmallow as ma
44

55
from . import BaseSchema
6-
from .common import BaseQuerySchema, CursorSchema
6+
from .common import CursorSchema
77

88

99
class QueryDataPointSchema(BaseSchema):
@@ -44,7 +44,7 @@ class QueryDataPointSchema(BaseSchema):
4444

4545

4646
class QueryResponseSchema(BaseSchema):
47-
"""Response schema for GET /api/v5/{ts}/query."""
47+
"""Response schema for POST /api/v5/{ts}/query."""
4848
items = ma.fields.List(
4949
ma.fields.Nested(QueryDataPointSchema),
5050
required=True,
@@ -54,18 +54,26 @@ class QueryResponseSchema(BaseSchema):
5454

5555

5656
# ---------------------------------------------------------------------------
57-
# Query parameter schemas
57+
# Request body schema
5858
# ---------------------------------------------------------------------------
5959

60-
class QueryEndpointQuerySchema(BaseQuerySchema):
61-
"""Query parameters for GET /query."""
60+
class QueryEndpointQuerySchema(BaseSchema):
61+
"""JSON body for POST /query."""
62+
63+
class Meta:
64+
ordered = True
65+
unknown = ma.RAISE
66+
6267
machine = ma.fields.String(
6368
load_default=None,
6469
metadata={'description': 'Filter by machine name'},
6570
)
66-
test = ma.fields.String(
71+
test = ma.fields.List(
72+
ma.fields.String(),
6773
load_default=None,
68-
metadata={'description': 'Filter by test name'},
74+
metadata={
75+
'description': 'Filter by test name(s) (disjunction).',
76+
},
6977
)
7078
metric = ma.fields.String(
7179
required=True,

0 commit comments

Comments
 (0)