Skip to content

Commit 07eb315

Browse files
authored
fix: Improve performance on TestStatusHistory endpoint (#1863)
* Moving to a raw SQL query, instead of ORM. * Force the query to use a hash based comparison, instead of a slower nested loop Closes #1131
1 parent 726e327 commit 07eb315

6 files changed

Lines changed: 211 additions & 62 deletions

File tree

backend/kernelCI_app/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ class Meta:
5454

5555

5656
class Checkouts(models.Model):
57+
# time this entry was ingested
5758
field_timestamp = models.DateTimeField(
5859
db_column="_timestamp", blank=True, null=True
5960
)
@@ -68,6 +69,7 @@ class Checkouts(models.Model):
6869
patchset_hash = models.TextField(blank=True, null=True)
6970
message_id = models.TextField(blank=True, null=True)
7071
comment = models.TextField(blank=True, null=True)
72+
# time reported by the submission
7173
start_time = models.DateTimeField(blank=True, null=True)
7274
log_url = models.TextField(blank=True, null=True)
7375
log_excerpt = models.CharField(max_length=16384, blank=True, null=True)

backend/kernelCI_app/queries/test.py

Lines changed: 57 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from typing import Optional
22

3-
from django.db import connection
3+
from django.db import connection, transaction
44

5+
from kernelCI_app.cache import get_query_cache, set_query_cache
56
from kernelCI_app.helpers.database import dict_fetchall
6-
from kernelCI_app.models import Tests
77
from kernelCI_app.typeModels.databases import (
88
Origin,
99
Test__StartTime,
@@ -64,33 +64,66 @@ def get_test_status_history(
6464
field_timestamp: Timestamp,
6565
group_size: int,
6666
):
67-
query = Tests.objects.filter(
68-
path=path,
69-
build__checkout__origin=origin,
70-
build__checkout__git_repository_url=git_repository_url,
71-
build__checkout__git_repository_branch=git_repository_branch,
72-
build__config_name=config_name,
73-
).values(
74-
"start_time",
75-
"id",
76-
"status",
77-
"build__checkout__git_commit_hash",
78-
)
67+
cache_key = "TestStatusHistory"
68+
params = {
69+
"path": path,
70+
"origin": origin,
71+
"git_repository_url": git_repository_url,
72+
"git_repository_branch": git_repository_branch,
73+
"config_name": config_name,
74+
"group_size": group_size,
75+
"field_timestamp": field_timestamp,
76+
"platform": platform,
77+
"test_start_time": test_start_time,
78+
}
79+
80+
if rows := get_query_cache(key=cache_key, params=params):
81+
return rows
7982

8083
if platform is None:
81-
query = query.filter(environment_misc__platform__isnull=True)
84+
platform_clause = "AND T.ENVIRONMENT_MISC ->> 'platform' IS NULL"
8285
else:
83-
query = query.filter(environment_misc__platform=platform)
86+
platform_clause = "AND T.ENVIRONMENT_MISC ->> 'platform' = %(platform)s"
8487

8588
if test_start_time is None:
8689
if field_timestamp is None:
87-
query = query.filter(
88-
start_time__isnull=True,
89-
field_timestamp__isnull=True,
90-
)
90+
time_clause = "AND T.START_TIME IS NULL AND T._TIMESTAMP IS NULL"
91+
order_clause = "ORDER BY T._TIMESTAMP DESC"
9192
else:
92-
query = query.filter(field_timestamp__lte=field_timestamp)
93-
return query.order_by("-field_timestamp")[:group_size]
93+
time_clause = "AND T._TIMESTAMP <= %(field_timestamp)s"
94+
order_clause = "ORDER BY T._TIMESTAMP DESC"
9495
else:
95-
query = query.filter(start_time__lte=test_start_time)
96-
return query.order_by("-start_time")[:group_size]
96+
time_clause = "AND T.START_TIME <= %(test_start_time)s"
97+
order_clause = "ORDER BY T.START_TIME DESC"
98+
99+
query = f"""
100+
SELECT
101+
T.START_TIME,
102+
T.ID,
103+
COALESCE(T.STATUS, 'NULL') AS status,
104+
C.GIT_COMMIT_HASH AS build__checkout__git_commit_hash
105+
FROM
106+
TESTS T
107+
INNER JOIN BUILDS B ON T.BUILD_ID = B.ID
108+
INNER JOIN CHECKOUTS C ON B.CHECKOUT_ID = C.ID
109+
WHERE
110+
T.PATH = %(path)s
111+
AND C.ORIGIN = %(origin)s
112+
AND C.GIT_REPOSITORY_URL = %(git_repository_url)s
113+
AND C.GIT_REPOSITORY_BRANCH = %(git_repository_branch)s
114+
AND B.CONFIG_NAME = %(config_name)s
115+
{platform_clause}
116+
{time_clause}
117+
{order_clause}
118+
LIMIT %(group_size)s;
119+
"""
120+
121+
with transaction.atomic():
122+
with connection.cursor() as cursor:
123+
# do not let postgres planner use a slow nested loop in the query
124+
# a hashed approach should always be faster here
125+
cursor.execute("SET LOCAL enable_nestloop = off")
126+
cursor.execute(query, params)
127+
rows = dict_fetchall(cursor)
128+
set_query_cache(key=cache_key, params=params, rows=rows)
129+
return rows

backend/kernelCI_app/tests/integrationTests/testStatusHistory_test.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from datetime import datetime, timedelta, timezone
12
from http import HTTPStatus
23

34
import pytest
@@ -47,6 +48,22 @@
4748
HTTPStatus.BAD_REQUEST,
4849
True,
4950
),
51+
(
52+
TestStatusHistoryRequest(
53+
path="fluster.debian.v4l2.gstreamer_av1.validate-fluster-results",
54+
origin="maestro",
55+
git_repository_url="https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git",
56+
git_repository_branch="master",
57+
platform="mt8195-cherry-tomato-r2",
58+
field_timestamp=datetime.now(timezone.utc)
59+
+ timedelta(days=365), # 1 year ahead
60+
current_test_start_time=None,
61+
config_name="defconfig+lab-setup+arm64-chromebook"
62+
"+CONFIG_MODULE_COMPRESS=n+CONFIG_MODULE_COMPRESS_NONE=y",
63+
),
64+
HTTPStatus.OK,
65+
False,
66+
),
5067
],
5168
)
5269
def test_get(

backend/kernelCI_app/tests/unitTests/queries/conftest.py

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,6 @@ def setup_mock_queryset(mock_model, return_value):
1919
return mock_queryset
2020

2121

22-
def setup_mock_test_queryset(mock_tests_model):
23-
mock_queryset = Mock()
24-
mock_queryset.values.return_value = mock_queryset
25-
mock_queryset.filter.return_value = mock_queryset
26-
mock_queryset.order_by.return_value = mock_queryset
27-
mock_sliced = Mock()
28-
mock_sliced.__iter__ = Mock(return_value=iter([]))
29-
mock_queryset.__getitem__ = Mock(return_value=mock_sliced)
30-
mock_tests_model.objects.filter.return_value = mock_queryset
31-
return mock_queryset
32-
33-
3422
TEST_TREE = Tree(
3523
index="0",
3624
tree_name="mainline",

backend/kernelCI_app/tests/unitTests/queries/test_queries_test.py

Lines changed: 134 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
from unittest.mock import patch
1+
from unittest.mock import Mock, patch
22

33
from kernelCI_app.queries.test import get_test_details_data, get_test_status_history
44
from kernelCI_app.tests.unitTests.queries.conftest import (
55
setup_mock_cursor,
6-
setup_mock_test_queryset,
76
)
87

98

@@ -34,9 +33,25 @@ def test_get_test_details_data_empty_result(
3433

3534

3635
class TestGetTestStatusHistory:
37-
@patch("kernelCI_app.queries.test.Tests")
38-
def test_get_test_status_history_with_platform(self, mock_tests_model):
39-
mock_queryset = setup_mock_test_queryset(mock_tests_model)
36+
@patch("kernelCI_app.queries.test.transaction.atomic")
37+
@patch("kernelCI_app.queries.test.set_query_cache")
38+
@patch("kernelCI_app.queries.test.get_query_cache")
39+
@patch("kernelCI_app.queries.test.dict_fetchall")
40+
@patch("kernelCI_app.queries.test.connection")
41+
def test_get_test_status_history_with_platform(
42+
self,
43+
mock_connection,
44+
mock_dict_fetchall,
45+
mock_get_cache,
46+
mock_set_cache,
47+
mock_transaction,
48+
):
49+
mock_transaction.return_value.__enter__ = Mock(return_value=None)
50+
mock_transaction.return_value.__exit__ = Mock(return_value=None)
51+
expected_result = [{"id": "test", "status": "PASS"}]
52+
mock_dict_fetchall.return_value = expected_result
53+
mock_get_cache.return_value = None
54+
mock_cursor = setup_mock_cursor(mock_connection)
4055

4156
result = get_test_status_history(
4257
path="boot",
@@ -50,12 +65,34 @@ def test_get_test_status_history_with_platform(self, mock_tests_model):
5065
group_size=10,
5166
)
5267

53-
assert list(result) == []
54-
mock_queryset.filter.assert_called()
55-
56-
@patch("kernelCI_app.queries.test.Tests")
57-
def test_get_test_status_history_without_platform(self, mock_tests_model):
58-
setup_mock_test_queryset(mock_tests_model)
68+
assert result == expected_result
69+
assert mock_cursor.execute.call_count > 1
70+
query_call = mock_cursor.execute.call_args_list[1]
71+
assert "platform" in query_call[0][1]
72+
assert query_call[0][1]["platform"] == "x86_64"
73+
mock_get_cache.assert_called_once()
74+
mock_set_cache.assert_called_once()
75+
mock_transaction.assert_called_once()
76+
77+
@patch("kernelCI_app.queries.test.transaction.atomic")
78+
@patch("kernelCI_app.queries.test.set_query_cache")
79+
@patch("kernelCI_app.queries.test.get_query_cache")
80+
@patch("kernelCI_app.queries.test.dict_fetchall")
81+
@patch("kernelCI_app.queries.test.connection")
82+
def test_get_test_status_history_without_platform(
83+
self,
84+
mock_connection,
85+
mock_dict_fetchall,
86+
mock_get_cache,
87+
mock_set_cache,
88+
mock_transaction,
89+
):
90+
mock_transaction.return_value.__enter__ = Mock(return_value=None)
91+
mock_transaction.return_value.__exit__ = Mock(return_value=None)
92+
expected_result = []
93+
mock_dict_fetchall.return_value = expected_result
94+
mock_get_cache.return_value = None
95+
mock_cursor = setup_mock_cursor(mock_connection)
5996

6097
result = get_test_status_history(
6198
path="boot",
@@ -69,13 +106,32 @@ def test_get_test_status_history_without_platform(self, mock_tests_model):
69106
group_size=10,
70107
)
71108

72-
assert list(result) == []
73-
74-
@patch("kernelCI_app.queries.test.Tests")
109+
assert result == expected_result
110+
query_call = mock_cursor.execute.call_args_list[1]
111+
assert "field_timestamp" in query_call[0][1]
112+
assert query_call[0][1]["field_timestamp"] == "2025-11-11T10:00:00Z"
113+
mock_get_cache.assert_called_once()
114+
mock_set_cache.assert_called_once()
115+
mock_transaction.assert_called_once()
116+
117+
@patch("kernelCI_app.queries.test.transaction.atomic")
118+
@patch("kernelCI_app.queries.test.set_query_cache")
119+
@patch("kernelCI_app.queries.test.get_query_cache")
120+
@patch("kernelCI_app.queries.test.dict_fetchall")
121+
@patch("kernelCI_app.queries.test.connection")
75122
def test_get_test_status_history_uses_start_time_when_provided(
76-
self, mock_tests_model
123+
self,
124+
mock_connection,
125+
mock_dict_fetchall,
126+
mock_get_cache,
127+
mock_set_cache,
128+
mock_transaction,
77129
):
78-
mock_queryset = setup_mock_test_queryset(mock_tests_model)
130+
mock_transaction.return_value.__enter__ = Mock(return_value=None)
131+
mock_transaction.return_value.__exit__ = Mock(return_value=None)
132+
mock_dict_fetchall.return_value = []
133+
mock_get_cache.return_value = None
134+
mock_cursor = setup_mock_cursor(mock_connection)
79135

80136
get_test_status_history(
81137
path="boot",
@@ -89,11 +145,32 @@ def test_get_test_status_history_uses_start_time_when_provided(
89145
group_size=10,
90146
)
91147

92-
mock_queryset.order_by.assert_called_with("-start_time")
148+
query_call = mock_cursor.execute.call_args_list[1]
149+
assert "test_start_time" in query_call[0][1]
150+
assert query_call[0][1]["test_start_time"] == "2025-11-10T10:00:00Z"
151+
mock_get_cache.assert_called_once()
152+
mock_set_cache.assert_called_once()
153+
mock_transaction.assert_called_once()
93154

94-
@patch("kernelCI_app.queries.test.Tests")
95-
def test_get_test_status_history_with_null_timestamps(self, mock_tests_model):
96-
mock_queryset = setup_mock_test_queryset(mock_tests_model)
155+
@patch("kernelCI_app.queries.test.transaction.atomic")
156+
@patch("kernelCI_app.queries.test.set_query_cache")
157+
@patch("kernelCI_app.queries.test.get_query_cache")
158+
@patch("kernelCI_app.queries.test.dict_fetchall")
159+
@patch("kernelCI_app.queries.test.connection")
160+
def test_get_test_status_history_with_null_timestamps(
161+
self,
162+
mock_connection,
163+
mock_dict_fetchall,
164+
mock_get_cache,
165+
mock_set_cache,
166+
mock_transaction,
167+
):
168+
mock_transaction.return_value.__enter__ = Mock(return_value=None)
169+
mock_transaction.return_value.__exit__ = Mock(return_value=None)
170+
expected_result = []
171+
mock_dict_fetchall.return_value = expected_result
172+
mock_get_cache.return_value = None
173+
mock_cursor = setup_mock_cursor(mock_connection)
97174

98175
result = get_test_status_history(
99176
path="boot",
@@ -107,7 +184,40 @@ def test_get_test_status_history_with_null_timestamps(self, mock_tests_model):
107184
group_size=10,
108185
)
109186

110-
assert list(result) == []
111-
filter_calls = [str(call) for call in mock_queryset.filter.call_args_list]
112-
assert any("start_time__isnull" in call for call in filter_calls)
113-
assert any("field_timestamp__isnull" in call for call in filter_calls)
187+
assert result == expected_result
188+
query_call = mock_cursor.execute.call_args_list[1]
189+
sql = query_call[0][0]
190+
assert "T.START_TIME IS NULL" in sql
191+
assert "T._TIMESTAMP IS NULL" in sql
192+
mock_get_cache.assert_called_once()
193+
mock_set_cache.assert_called_once()
194+
mock_transaction.assert_called_once()
195+
196+
@patch("kernelCI_app.queries.test.set_query_cache")
197+
@patch("kernelCI_app.queries.test.get_query_cache")
198+
@patch("kernelCI_app.queries.test.dict_fetchall")
199+
def test_get_test_status_history_returns_cached_result(
200+
self,
201+
mock_dict_fetchall,
202+
mock_get_cache,
203+
mock_set_cache,
204+
):
205+
cached_result = [{"id": "cached", "status": "PASS"}]
206+
mock_get_cache.return_value = cached_result
207+
208+
result = get_test_status_history(
209+
path="boot",
210+
origin="maestro",
211+
git_repository_url="https://my_url.com",
212+
git_repository_branch="master",
213+
platform="x86_64",
214+
test_start_time="2025-11-11T10:00:00Z",
215+
config_name="defconfig",
216+
field_timestamp=None,
217+
group_size=10,
218+
)
219+
220+
assert result == cached_result
221+
mock_dict_fetchall.assert_not_called()
222+
mock_set_cache.assert_not_called()
223+
mock_get_cache.assert_called_once()

backend/kernelCI_app/typeModels/testDetails.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
Test__OutputFiles,
3030
Test__Path,
3131
Test__StartTime,
32-
Test__Status,
3332
Timestamp,
3433
)
3534
from kernelCI_app.utils import validate_str_to_dict
@@ -72,7 +71,7 @@ class TestDetailsResponse(BaseModel):
7271
class TestStatusHistoryItem(BaseModel):
7372
start_time: Test__StartTime
7473
id: Test__Id
75-
status: Test__Status
74+
status: StatusValues
7675
git_commit_hash: Checkout__GitCommitHash = Field(
7776
validation_alias="build__checkout__git_commit_hash"
7877
)

0 commit comments

Comments
 (0)