From 26b1ecc7722d010f04b46741840c3d095fcae37f Mon Sep 17 00:00:00 2001 From: Alan Peixinho Date: Tue, 14 Jul 2026 15:38:52 -0300 Subject: [PATCH 1/4] fix(hardware): list boards in time window and load test origins from hardware_status * Non longer limiting hardware listing to latest checkout. * Hardcoded TEST_ORIGINS also omitted origins from the Hardware dropdown. * Expand hardcoded test origins list. Closes #1983 Signed-off-by: Alan Peixinho --- backend/kernelCI_app/queries/hardware.py | 10 ++-------- backend/kernelCI_app/views/originsView.py | 3 +++ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/backend/kernelCI_app/queries/hardware.py b/backend/kernelCI_app/queries/hardware.py index 9bf249b1b..374536638 100644 --- a/backend/kernelCI_app/queries/hardware.py +++ b/backend/kernelCI_app/queries/hardware.py @@ -370,16 +370,10 @@ def get_hardware_listing_data_from_status_table( SUM(test_inc) AS test_null FROM hardware_status - INNER JOIN - latest_checkout - ON - hardware_status.checkout_id = latest_checkout.checkout_id - AND - latest_checkout.start_time >= %(start_date)s - AND - latest_checkout.start_time <= %(end_date)s WHERE hardware_status.test_origin = %(origin)s + AND hardware_status.start_time >= %(start_date)s + AND hardware_status.start_time <= %(end_date)s GROUP BY platform, compatibles diff --git a/backend/kernelCI_app/views/originsView.py b/backend/kernelCI_app/views/originsView.py index e5ed1821d..913e402c3 100644 --- a/backend/kernelCI_app/views/originsView.py +++ b/backend/kernelCI_app/views/originsView.py @@ -22,8 +22,11 @@ "arm", "broonie", "linaro", + "linaro_pull_labs", "maestro", "microsoft", + "pullab_cloud_aws_arm64", + "pull_labs_aws_ec2", "redhat", "riscv", "syzbot", From 013376e0598041878b2f5fec4f64f89de2783b97 Mon Sep 17 00:00:00 2001 From: Alan Peixinho Date: Wed, 15 Jul 2026 18:56:28 -0300 Subject: [PATCH 2/4] fix(hardware): resolve details trees from hardware_status in window Stop selecting absolute tree tips for hardware details. Use hardware_status to pick the latest checkout per tree that actually ran the board, so older checkouts with tests still appear. Closes #1264 Signed-off-by: Alan Peixinho --- backend/kernelCI_app/queries/hardware.py | 219 +++++++----------- .../factories/mocks/fixtures/build_data.py | 14 ++ .../factories/mocks/fixtures/tree_data.py | 19 ++ .../hardwareDetailsSummary_test.py | 24 ++ .../tests/integrationTests/metrics_test.py | 4 +- .../queries/hardware_queries_test.py | 7 +- 6 files changed, 147 insertions(+), 140 deletions(-) diff --git a/backend/kernelCI_app/queries/hardware.py b/backend/kernelCI_app/queries/hardware.py index 374536638..051a294bd 100644 --- a/backend/kernelCI_app/queries/hardware.py +++ b/backend/kernelCI_app/queries/hardware.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Optional, TypedDict -from django.db import connection +from django.db import connection, connections from kernelCI_app.cache import get_query_cache, set_query_cache from kernelCI_app.helpers.database import dict_fetchall @@ -12,44 +12,38 @@ from kernelCI_app.typeModels.hardwareDetails import CommitHead, Tree -def _get_hardware_tree_heads_clause(*, id_only: bool) -> str: - """Returns the tree_heads for the hardware queries, - where the checkout is not filtered by origin. +def _get_hardware_trees_from_status_query(*, fields: str) -> str: + """Latest checkout per tree in the window for this hardware via hardware_status. - This is done because tests from a origin can be - related to checkouts from another origin.""" - if id_only is True: - fields = "C.id" - else: - fields = """C.id, - C.origin, - C.tree_name, - C.start_time, - C.git_repository_branch, - C.git_repository_url, - C.git_commit_name, - C.git_commit_hash, - C.git_commit_tags""" - - return f"""SELECT DISTINCT - ON ( - C.tree_name, - C.git_repository_branch, - C.git_repository_url, - C.origin - ) - {fields} - FROM - checkouts C - WHERE - C.start_time >= %(start_date)s - AND C.start_time <= %(end_date)s - ORDER BY - C.tree_name ASC, - C.git_repository_branch ASC, - C.git_repository_url ASC, - C.origin ASC, - C.start_time DESC""" + Checkout origin is not filtered: tests from one origin can be related to + checkouts from another origin. + """ + return f""" + SELECT DISTINCT ON ( + C.tree_name, + C.git_repository_branch, + C.git_repository_url, + C.origin + ) + {fields} + FROM + hardware_status HS + INNER JOIN checkouts C ON C.id = HS.checkout_id + WHERE + HS.test_origin = %(origin)s + AND ( + HS.platform = %(hardware)s + OR HS.compatibles @> ARRAY[%(hardware)s]::TEXT[] + ) + AND HS.start_time >= %(start_date)s + AND HS.start_time <= %(end_date)s + ORDER BY + C.tree_name ASC, + C.git_repository_branch ASC, + C.git_repository_url ASC, + C.origin ASC, + C.start_time DESC + """ # TODO: unify with get_tree_listing_count_clause @@ -356,6 +350,36 @@ def get_hardware_listing_data_from_status_table( """ else: query = """ + WITH latest_per_tree AS ( + SELECT DISTINCT ON ( + HS.platform, + HS.compatibles, + C.tree_name, + C.git_repository_branch, + C.git_repository_url, + C.origin + ) + HS.platform, + HS.compatibles, + HS.build_pass, HS.build_failed, HS.build_inc, + HS.boot_pass, HS.boot_failed, HS.boot_inc, + HS.test_pass, HS.test_failed, HS.test_inc + FROM + hardware_status HS + INNER JOIN checkouts C ON C.id = HS.checkout_id + WHERE + HS.test_origin = %(origin)s + AND HS.start_time >= %(start_date)s + AND HS.start_time <= %(end_date)s + ORDER BY + HS.platform ASC, + HS.compatibles ASC, + C.tree_name ASC, + C.git_repository_branch ASC, + C.git_repository_url ASC, + C.origin ASC, + C.start_time DESC + ) SELECT platform, compatibles AS hardware, @@ -369,11 +393,7 @@ def get_hardware_listing_data_from_status_table( SUM(test_failed) AS test_fail, SUM(test_inc) AS test_null FROM - hardware_status - WHERE - hardware_status.test_origin = %(origin)s - AND hardware_status.start_time >= %(start_date)s - AND hardware_status.start_time <= %(end_date)s + latest_per_tree GROUP BY platform, compatibles @@ -756,11 +776,7 @@ def get_hardware_trees_head_commits( start_datetime: datetime, end_datetime: datetime, ) -> list[tuple[str, str]]: - - # similar to the get_hardware_trees_data, except we limit the information - # being brought to the commit hash - - cache_key = "hardwareTreesHeadCommits" + cache_key = "hardwareTreesHeadCommitsFromStatus" cache_params = { "hardware": hardware_id, @@ -774,45 +790,12 @@ def get_hardware_trees_head_commits( if trees: return trees - tree_head_clause = _get_hardware_tree_heads_clause(id_only=False) - - # We need a subquery because if we filter by any hardware, it will get the - # last head that has that hardware, but not the real head of the trees - query = f""" - WITH - -- Selects the data of the latest checkout of all trees in the given period - tree_heads AS ( - {tree_head_clause} - ) - SELECT DISTINCT - ON ( - TH.tree_name, - TH.git_repository_branch, - TH.git_repository_url, - TH.git_commit_hash - ) TH.tree_name, - TH.git_commit_hash - FROM - tests - INNER JOIN builds ON tests.build_id = builds.id - INNER JOIN tree_heads TH ON builds.checkout_id = TH.id - WHERE - ( - ( - tests.environment_compatible @> ARRAY[%(hardware)s]::TEXT[] - OR tests.environment_misc ->> 'platform' = %(hardware)s - ) - AND tests.origin = %(origin)s - AND TH.start_time >= %(start_date)s - AND TH.start_time <= %(end_date)s - ) - ORDER BY - TH.tree_name ASC, - TH.git_repository_branch ASC, - TH.git_repository_url ASC, - TH.git_commit_hash ASC, - TH.start_time DESC - """ + query = _get_hardware_trees_from_status_query( + fields=""" + C.tree_name, + C.git_commit_hash + """ + ) params = { "hardware": hardware_id, @@ -820,8 +803,7 @@ def get_hardware_trees_head_commits( "start_date": start_datetime, "end_date": end_datetime, } - trees = [] - with connection.cursor() as cursor: + with connections["default"].cursor() as cursor: cursor.execute(query, params) tree_records = dict_fetchall(cursor) trees = [ @@ -840,7 +822,7 @@ def get_hardware_trees_data( start_datetime: datetime, end_datetime: datetime, ) -> list[Tree]: - cache_key = "hardwareDetailsTreeData" + cache_key = "hardwareDetailsTreeDataFromStatus" params = { "hardware": hardware_id, @@ -851,52 +833,19 @@ def get_hardware_trees_data( trees: list[Tree] = get_query_cache(cache_key, params) - tree_head_clause = _get_hardware_tree_heads_clause(id_only=False) - if not trees: - # We need a subquery because if we filter by any hardware, it will get the - # last head that has that hardware, but not the real head of the trees - query = f""" - WITH - -- Selects the data of the latest checkout of all trees in the given period - tree_heads AS ( - {tree_head_clause} - ) - SELECT DISTINCT - ON ( - TH.tree_name, - TH.git_repository_branch, - TH.git_repository_url, - TH.git_commit_hash - ) TH.tree_name, - TH.origin, - TH.git_repository_branch, - TH.git_repository_url, - TH.git_commit_name, - TH.git_commit_hash, - TH.git_commit_tags - FROM - tests - INNER JOIN builds ON tests.build_id = builds.id - INNER JOIN tree_heads TH ON builds.checkout_id = TH.id - WHERE - ( - ( - tests.environment_compatible @> ARRAY[%(hardware)s]::TEXT[] - OR tests.environment_misc ->> 'platform' = %(hardware)s - ) - AND tests.origin = %(origin)s - AND TH.start_time >= %(start_date)s - AND TH.start_time <= %(end_date)s - ) - ORDER BY - TH.tree_name ASC, - TH.git_repository_branch ASC, - TH.git_repository_url ASC, - TH.git_commit_hash ASC, - TH.start_time DESC - """ - with connection.cursor() as cursor: + query = _get_hardware_trees_from_status_query( + fields=""" + C.tree_name, + C.origin, + C.git_repository_branch, + C.git_repository_url, + C.git_commit_name, + C.git_commit_hash, + C.git_commit_tags + """ + ) + with connections["default"].cursor() as cursor: cursor.execute(query, params) tree_records = dict_fetchall(cursor) diff --git a/backend/kernelCI_app/tests/factories/mocks/fixtures/build_data.py b/backend/kernelCI_app/tests/factories/mocks/fixtures/build_data.py index 25d1a470d..67dc0009b 100644 --- a/backend/kernelCI_app/tests/factories/mocks/fixtures/build_data.py +++ b/backend/kernelCI_app/tests/factories/mocks/fixtures/build_data.py @@ -144,6 +144,20 @@ "status": "FAIL", "config_name": "defconfig", }, + "older_checkout_build": { + "checkout_id": "older_checkout_with_tests", + "origin": "maestro", + "architecture": "arm64", + "status": "PASS", + "config_name": "defconfig", + }, + "latest_checkout_build": { + "checkout_id": "latest_checkout_without_tests", + "origin": "maestro", + "architecture": "arm64", + "status": "PASS", + "config_name": "defconfig", + }, # TODO: Add builds to test STATUS NULL + add integration tests for this case } diff --git a/backend/kernelCI_app/tests/factories/mocks/fixtures/tree_data.py b/backend/kernelCI_app/tests/factories/mocks/fixtures/tree_data.py index 015276e44..931112920 100644 --- a/backend/kernelCI_app/tests/factories/mocks/fixtures/tree_data.py +++ b/backend/kernelCI_app/tests/factories/mocks/fixtures/tree_data.py @@ -189,4 +189,23 @@ "start_time": _SEED_NOW - timedelta(hours=1), "hardware_platform": None, }, + # Older-checkout scenario (#1264): the older checkout ran "older-checkout-board", + # but the latest checkout (latest_checkout_without_tests) did not. Details must + # still resolve the older checkout as the tree head. + "older_checkout_with_tests": { + "origin": "maestro", + "git_url": "https://git.kernel.org/pub/scm/linux/kernel/git/older-checkout/linux.git", + "git_branch": "master", + "tree_name": "older_checkout_mainline", + "start_time": datetime.fromtimestamp(1741356000, timezone.utc), + "hardware_platform": "older-checkout-board", + }, + "latest_checkout_without_tests": { + "origin": "maestro", + "git_url": "https://git.kernel.org/pub/scm/linux/kernel/git/older-checkout/linux.git", + "git_branch": "master", + "tree_name": "older_checkout_mainline", + "start_time": datetime.fromtimestamp(1741399200, timezone.utc), + "hardware_platform": None, + }, } diff --git a/backend/kernelCI_app/tests/integrationTests/hardwareDetailsSummary_test.py b/backend/kernelCI_app/tests/integrationTests/hardwareDetailsSummary_test.py index bbe226498..577442fc5 100644 --- a/backend/kernelCI_app/tests/integrationTests/hardwareDetailsSummary_test.py +++ b/backend/kernelCI_app/tests/integrationTests/hardwareDetailsSummary_test.py @@ -63,6 +63,18 @@ ), } +# Older-checkout board (#1264): tested only on an older checkout, not on the latest one. +OLDER_CHECKOUT_HARDWARE = { + "id": "older-checkout-board", + "body": HardwareDetailsPostBody( + origin="maestro", + startTimestampInSeconds=1741300000, + endTimestampInSeconds=1741420000, + selectedCommits={}, + filter={}, + ), +} + client = HardwareClient() @@ -202,6 +214,18 @@ def test_no_filters(base_hardware, status_code, has_error_body): ) +def test_older_checkout_board_resolves_older_checkout(): + """#1264 regression: a board tested only on an older checkout (not the latest + one) must still resolve that older checkout as the tree head.""" + response, content = request_data(OLDER_CHECKOUT_HARDWARE) + assert_status_code(response=response, status_code=HTTPStatus.OK) + assert "error" not in content + + head_hashes = {tree["head_git_commit_hash"] for tree in content["common"]["trees"]} + assert "older_checkout_with_tests" in head_hashes + assert "latest_checkout_without_tests" not in head_hashes + + def test_filter_test_status(test_status_input): """ Tests for the status filter for both boots and tests diff --git a/backend/kernelCI_app/tests/integrationTests/metrics_test.py b/backend/kernelCI_app/tests/integrationTests/metrics_test.py index ec9defb28..ea39c6201 100644 --- a/backend/kernelCI_app/tests/integrationTests/metrics_test.py +++ b/backend/kernelCI_app/tests/integrationTests/metrics_test.py @@ -56,14 +56,14 @@ def _ok_content(query: dict[str, str] | None = None) -> dict: metrics_expected_counts = { "n_trees": 5, "n_checkouts": 21, - "n_builds": 12, + "n_builds": 14, "n_tests": 14, "n_issues": 7, "n_incidents": 7, "prev_n_trees": 6, "prev_n_checkouts": 20, "prev_n_builds": 7, - "prev_n_tests": 7, + "prev_n_tests": 9, } diff --git a/backend/kernelCI_app/tests/unitTests/queries/hardware_queries_test.py b/backend/kernelCI_app/tests/unitTests/queries/hardware_queries_test.py index 6330bf223..f5640eedf 100644 --- a/backend/kernelCI_app/tests/unitTests/queries/hardware_queries_test.py +++ b/backend/kernelCI_app/tests/unitTests/queries/hardware_queries_test.py @@ -75,9 +75,9 @@ def test_get_hardware_trees_data_from_cache(self, mock_get_cache): @patch("kernelCI_app.queries.hardware.get_query_cache") @patch("kernelCI_app.queries.hardware.set_query_cache") @patch("kernelCI_app.queries.hardware.dict_fetchall") - @patch("kernelCI_app.queries.hardware.connection") + @patch("kernelCI_app.queries.hardware.connections") def test_get_hardware_trees_data_from_database( - self, mock_connection, mock_dict_fetchall, mock_set_cache, mock_get_cache + self, mock_connections, mock_dict_fetchall, mock_set_cache, mock_get_cache ): tree_records = [ { @@ -92,7 +92,7 @@ def test_get_hardware_trees_data_from_database( ] mock_get_cache.return_value = None mock_dict_fetchall.return_value = tree_records - setup_mock_cursor(mock_connection) + setup_mock_cursor(mock_connections.__getitem__.return_value) result = get_hardware_trees_data( hardware_id="hardware", @@ -104,6 +104,7 @@ def test_get_hardware_trees_data_from_database( assert len(result) == 1 assert result[0].tree_name == "mainline" mock_set_cache.assert_called_once() + mock_connections.__getitem__.assert_called_with("default") class TestGenerateQueryParams: From bba74aa0df9884ae04e01fb752db0d48266fa1bd Mon Sep 17 00:00:00 2001 From: Alan Peixinho Date: Thu, 13 Aug 2026 11:13:33 -0300 Subject: [PATCH 3/4] fix(hardware): prune hardware_status by time window instead of latest_checkout The listing/details now surface boards tested on any checkout within the window, not just the latest tip. But delete_unused_hardware_status still removed every hardware_status row not present in latest_checkout, so the weekly cron would wipe those older-checkout rows and regress #1983. Prune hardware_status (and ProcessedListingItems) by age older than HARDWARE_STATUS_RETENTION_DAYS instead. Delete hardware rows by their composite key and start_time, not by checkout_id alone, so an old platform row cannot wipe an in-window sibling. Both tables use the same cutoff so the dedup ledger never predeceases its status rows. Integration tests pin the non-tip retention guard, the shared cutoff, and the sibling-row delete behavior. Signed-off-by: Alan Peixinho --- backend/kernelCI/settings.py | 5 + .../commands/delete_unused_hardware_status.py | 133 ++++++------ .../delete_unused_hardware_status_test.py | 195 ++++++++++++++++++ 3 files changed, 266 insertions(+), 67 deletions(-) create mode 100644 backend/kernelCI_app/tests/integrationTests/delete_unused_hardware_status_test.py diff --git a/backend/kernelCI/settings.py b/backend/kernelCI/settings.py index 5cbc481ac..b5ca1e01d 100644 --- a/backend/kernelCI/settings.py +++ b/backend/kernelCI/settings.py @@ -428,6 +428,11 @@ def get_json_env_var(name, default): os.environ.get("DEFAULT_ORIGIN_LISTING_INTERVAL_IN_DAYS", 30) ) +# How long hardware_status and processed_listing_items rows are kept. +HARDWARE_STATUS_RETENTION_DAYS = int( + os.environ.get("HARDWARE_STATUS_RETENTION_DAYS", 30) +) + PROMETHEUS_METRICS_ENABLED = is_boolean_or_string_true( os.environ.get("PROMETHEUS_METRICS_ENABLED", False) ) diff --git a/backend/kernelCI_app/management/commands/delete_unused_hardware_status.py b/backend/kernelCI_app/management/commands/delete_unused_hardware_status.py index 33847fa09..3102ac029 100644 --- a/backend/kernelCI_app/management/commands/delete_unused_hardware_status.py +++ b/backend/kernelCI_app/management/commands/delete_unused_hardware_status.py @@ -1,27 +1,28 @@ """ -Management command to delete unused entries from hardware_status table. - -Removes HardwareStatus entries that have no corresponding checkout_id in the LatestCheckout table. +Prune HardwareStatus and ProcessedListingItems older than +HARDWARE_STATUS_RETENTION_DAYS. Both use the same cutoff so already-processed +entries stay in sync with status rows and we avoid over/undercounting on +re-ingest. """ -import logging +from datetime import timedelta +from django.conf import settings from django.core.management.base import BaseCommand from django.db import transaction +from django.utils import timezone from kernelCI_app.management.commands.helpers.healthcheck import ( MONITORING_ID_PARAM_HELP_TEXT, run_with_healthcheck_monitoring, ) -from kernelCI_app.models import HardwareStatus, LatestCheckout, ProcessedListingItems - -logger = logging.getLogger(__name__) +from kernelCI_app.models import Checkouts, HardwareStatus, ProcessedListingItems class Command(BaseCommand): help = ( - "Delete HardwareStatus entries with no corresponding checkout_id " - "in the LatestCheckout table" + "Delete HardwareStatus entries (and their ProcessedListingItems) older " + "than HARDWARE_STATUS_RETENTION_DAYS" ) def add_arguments(self, parser): @@ -54,88 +55,86 @@ def _run_action(self, options): dry_run = options["dry_run"] batch_size = options["batch_size"] - with transaction.atomic(): - valid_checkout_ids = set( - LatestCheckout.objects.values_list("checkout_id", flat=True) - ) - - orphaned_hardware_entries = HardwareStatus.objects.exclude( - checkout_id__in=valid_checkout_ids - ).values_list("checkout_id", flat=True) - orphaned_hardware_count = orphaned_hardware_entries.count() + cutoff = timezone.now() - timedelta( + days=settings.HARDWARE_STATUS_RETENTION_DAYS + ) - orphaned_processed_hardware_entries = ( - ProcessedListingItems.objects.exclude( - checkout_id__in=valid_checkout_ids - ) - ).values_list("listing_item_key", flat=True) + stale_hardware = HardwareStatus.objects.filter(start_time__lt=cutoff) + recent_checkout_ids = Checkouts.objects.filter(start_time__gte=cutoff).values( + "id" + ) + stale_processed = ProcessedListingItems.objects.exclude( + checkout_id__in=recent_checkout_ids + ) - orphaned_processed_hardware_count = ( - orphaned_processed_hardware_entries.count() - ) + stale_hardware_count = stale_hardware.count() + stale_processed_count = stale_processed.count() - if orphaned_hardware_count == 0 and orphaned_processed_hardware_count == 0: - self.stdout.write( - self.style.SUCCESS( - "No orphaned HardwareStatus/ProcessedListingItems entries found." - ) - ) - return - - if dry_run: - self.stdout.write( - self.style.WARNING( - f"[DRY RUN] Would delete {orphaned_hardware_count} HardwareStatus entries and " - f"{orphaned_processed_hardware_count} ProcessedListingItems entries " - "Run without --dry-run to execute deletion." - ) + if stale_hardware_count == 0 and stale_processed_count == 0: + self.stdout.write( + self.style.SUCCESS( + "No orphaned HardwareStatus/ProcessedListingItems entries found." ) - return + ) + return + if dry_run: self.stdout.write( - f"Found {orphaned_hardware_count} HardwareStatus entries " - f"and {orphaned_processed_hardware_count} ProcessedListingItems entries " - "with no corresponding LatestCheckout." + self.style.WARNING( + f"[DRY RUN] Would delete {stale_hardware_count} HardwareStatus entries and " + f"{stale_processed_count} ProcessedListingItems entries " + "Run without --dry-run to execute deletion." + ) ) + return - total_hardware_deleted = 0 - total_processed_hardware_deleted = 0 + self.stdout.write( + f"Found {stale_hardware_count} HardwareStatus entries " + f"and {stale_processed_count} ProcessedListingItems entries " + f"older than {settings.HARDWARE_STATUS_RETENTION_DAYS} days." + ) + + total_hardware_deleted = 0 + total_processed_deleted = 0 + with transaction.atomic(): while True: - hardware_batch_ids = list(orphaned_hardware_entries[:batch_size]) - processed_hardware_batch_ids = list( - orphaned_processed_hardware_entries[:batch_size] + hardware_batch = list( + stale_hardware.values_list( + "test_origin", "platform", "checkout_id" + )[:batch_size] + ) + processed_batch = list( + stale_processed.values_list("listing_item_key", flat=True)[ + :batch_size + ] ) - if not hardware_batch_ids and not processed_hardware_batch_ids: + if not hardware_batch and not processed_batch: break - if hardware_batch_ids: + if hardware_batch: hardware_delete_count = HardwareStatus.objects.filter( - checkout_id__in=hardware_batch_ids + pk__in=hardware_batch ).delete()[0] + total_hardware_deleted += hardware_delete_count self.stdout.write( f"Deleted hardware_status(n={hardware_delete_count}) entries " - f"(total: {total_hardware_deleted}/{orphaned_hardware_count})" - ) - total_hardware_deleted += hardware_delete_count - - if processed_hardware_batch_ids: - processed_hardware_delete_count = ( - ProcessedListingItems.objects.filter( - listing_item_key__in=processed_hardware_batch_ids - ).delete()[0] + f"(total: {total_hardware_deleted}/{stale_hardware_count})" ) - total_processed_hardware_deleted += processed_hardware_delete_count - + if processed_batch: + processed_delete_count = ProcessedListingItems.objects.filter( + listing_item_key__in=processed_batch + ).delete()[0] + total_processed_deleted += processed_delete_count self.stdout.write( - f"Deleted processed_hardware_status(n={processed_hardware_delete_count}) entries " - f"(total: {total_processed_hardware_deleted}/{orphaned_processed_hardware_count})" + f"Deleted processed_listing_items(n={processed_delete_count}) entries " + f"(total: {total_processed_deleted}/{stale_processed_count})" ) self.stdout.write( self.style.SUCCESS( f"Successfully deleted hardware_status(n={total_hardware_deleted}) " - f"and processed_hardware_status(n={total_processed_hardware_deleted})." + f"and processed_listing_items(n={total_processed_deleted})." ) ) diff --git a/backend/kernelCI_app/tests/integrationTests/delete_unused_hardware_status_test.py b/backend/kernelCI_app/tests/integrationTests/delete_unused_hardware_status_test.py new file mode 100644 index 000000000..61997b853 --- /dev/null +++ b/backend/kernelCI_app/tests/integrationTests/delete_unused_hardware_status_test.py @@ -0,0 +1,195 @@ +"""Integration tests for delete_unused_hardware_status retention. + +Pins the #1983 regression: hardware_status rows for non-tip checkouts inside the +retention window must survive the weekly cron. Also pins that HardwareStatus and +ProcessedListingItems share the same age cutoff. +""" + +from io import StringIO + +import pytest +from django.core.management import call_command +from django.test import override_settings +from django.utils import timezone + +from kernelCI_app.models import ( + HardwareStatus, + LatestCheckout, + ProcessedListingItems, +) +from kernelCI_app.tests.factories import CheckoutFactory + +RETENTION_DAYS = 7 + + +def _days_ago(days: int): + return timezone.now() - timezone.timedelta(days=days) + + +def _run_delete(**kwargs) -> str: + out = StringIO() + err = StringIO() + call_command("delete_unused_hardware_status", stdout=out, stderr=err, **kwargs) + return out.getvalue() + err.getvalue() + + +def _make_hardware_status( + *, checkout, platform: str, start_time, test_origin="maestro" +): + return HardwareStatus.objects.create( + checkout_id=checkout.id, + test_origin=test_origin, + platform=platform, + compatibles=None, + start_time=start_time, + test_pass=1, + ) + + +def _make_processed(*, checkout_id: str, key_byte: int): + return ProcessedListingItems.objects.create( + listing_item_key=bytes([key_byte]) * 32, + checkout_id=checkout_id, + status="P", + ) + + +@pytest.mark.django_db +@override_settings(HARDWARE_STATUS_RETENTION_DAYS=RETENTION_DAYS) +def test_keeps_non_tip_hardware_within_window(): + """#1983: board tested on an older (non-tip) checkout must not be wiped. + + Tip checkout is in latest_checkout and has no board. Older checkout is not a + tip but is inside the retention window and has the board. Old tip-based cron + deleted that row; time-window retention must keep it. + """ + tip = CheckoutFactory(start_time=_days_ago(1), id="ret_tip_checkout") + older = CheckoutFactory(start_time=_days_ago(3), id="ret_older_checkout") + + LatestCheckout.objects.create( + checkout_id=tip.id, + start_time=tip.start_time, + origin=tip.origin, + tree_name=tip.tree_name, + git_repository_url=tip.git_repository_url, + git_repository_branch=tip.git_repository_branch, + ) + + board = _make_hardware_status( + checkout=older, platform="exynos", start_time=older.start_time + ) + + _run_delete() + + assert HardwareStatus.objects.filter( + test_origin=board.test_origin, + platform=board.platform, + checkout_id=board.checkout_id, + ).exists() + + +@pytest.mark.django_db +@override_settings(HARDWARE_STATUS_RETENTION_DAYS=RETENTION_DAYS) +def test_deletes_hardware_older_than_retention(): + old = CheckoutFactory(start_time=_days_ago(RETENTION_DAYS + 5), id="ret_old_hw") + board = _make_hardware_status( + checkout=old, platform="old-board", start_time=old.start_time + ) + + _run_delete() + + assert not HardwareStatus.objects.filter( + test_origin=board.test_origin, + platform=board.platform, + checkout_id=board.checkout_id, + ).exists() + + +@pytest.mark.django_db +@override_settings(HARDWARE_STATUS_RETENTION_DAYS=RETENTION_DAYS) +def test_processed_items_match_hardware_cutoff(): + """ProcessedListingItems and hardware_status use the same age cutoff.""" + recent = CheckoutFactory(start_time=_days_ago(2), id="ret_recent_processed") + old = CheckoutFactory( + start_time=_days_ago(RETENTION_DAYS + 5), id="ret_old_processed" + ) + + recent_hw = _make_hardware_status( + checkout=recent, platform="recent-board", start_time=recent.start_time + ) + old_hw = _make_hardware_status( + checkout=old, platform="old-board", start_time=old.start_time + ) + recent_processed = _make_processed(checkout_id=recent.id, key_byte=1) + old_processed = _make_processed(checkout_id=old.id, key_byte=2) + + _run_delete() + + assert HardwareStatus.objects.filter( + test_origin=recent_hw.test_origin, + platform=recent_hw.platform, + checkout_id=recent_hw.checkout_id, + ).exists() + assert not HardwareStatus.objects.filter( + test_origin=old_hw.test_origin, + platform=old_hw.platform, + checkout_id=old_hw.checkout_id, + ).exists() + assert ProcessedListingItems.objects.filter( + listing_item_key=recent_processed.listing_item_key + ).exists() + assert not ProcessedListingItems.objects.filter( + listing_item_key=old_processed.listing_item_key + ).exists() + + +@pytest.mark.django_db +@override_settings(HARDWARE_STATUS_RETENTION_DAYS=RETENTION_DAYS) +def test_does_not_wipe_recent_sibling_platform_rows(): + """Deleting by checkout_id alone would wipe in-window siblings. Must not.""" + checkout = CheckoutFactory(start_time=_days_ago(2), id="ret_sibling_checkout") + stale = _make_hardware_status( + checkout=checkout, + platform="stale-board", + start_time=_days_ago(RETENTION_DAYS + 5), + ) + fresh = _make_hardware_status( + checkout=checkout, + platform="fresh-board", + start_time=checkout.start_time, + ) + + _run_delete() + + assert not HardwareStatus.objects.filter( + test_origin=stale.test_origin, + platform=stale.platform, + checkout_id=stale.checkout_id, + ).exists() + assert HardwareStatus.objects.filter( + test_origin=fresh.test_origin, + platform=fresh.platform, + checkout_id=fresh.checkout_id, + ).exists() + + +@pytest.mark.django_db +@override_settings(HARDWARE_STATUS_RETENTION_DAYS=RETENTION_DAYS) +def test_dry_run_deletes_nothing(): + old = CheckoutFactory(start_time=_days_ago(RETENTION_DAYS + 5), id="ret_dry_run") + board = _make_hardware_status( + checkout=old, platform="dry-board", start_time=old.start_time + ) + processed = _make_processed(checkout_id=old.id, key_byte=3) + + output = _run_delete(dry_run=True) + + assert "DRY RUN" in output + assert HardwareStatus.objects.filter( + test_origin=board.test_origin, + platform=board.platform, + checkout_id=board.checkout_id, + ).exists() + assert ProcessedListingItems.objects.filter( + listing_item_key=processed.listing_item_key + ).exists() From dbf93c2abbdd326a84ca7118e2fafb38686da240 Mon Sep 17 00:00:00 2001 From: Alan Peixinho Date: Tue, 18 Aug 2026 16:32:22 -0300 Subject: [PATCH 4/4] fixup! fix(hardware): prune hardware_status by time window instead of latest_checkout --- .../commands/delete_unused_hardware_status.py | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/backend/kernelCI_app/management/commands/delete_unused_hardware_status.py b/backend/kernelCI_app/management/commands/delete_unused_hardware_status.py index 3102ac029..cde3a9ce9 100644 --- a/backend/kernelCI_app/management/commands/delete_unused_hardware_status.py +++ b/backend/kernelCI_app/management/commands/delete_unused_hardware_status.py @@ -96,22 +96,20 @@ def _run_action(self, options): total_hardware_deleted = 0 total_processed_deleted = 0 - with transaction.atomic(): - while True: - hardware_batch = list( - stale_hardware.values_list( - "test_origin", "platform", "checkout_id" - )[:batch_size] - ) - processed_batch = list( - stale_processed.values_list("listing_item_key", flat=True)[ - :batch_size - ] - ) + while True: + hardware_batch = list( + stale_hardware.values_list("test_origin", "platform", "checkout_id")[ + :batch_size + ] + ) + processed_batch = list( + stale_processed.values_list("listing_item_key", flat=True)[:batch_size] + ) - if not hardware_batch and not processed_batch: - break + if not hardware_batch and not processed_batch: + break + with transaction.atomic(): if hardware_batch: hardware_delete_count = HardwareStatus.objects.filter( pk__in=hardware_batch