Skip to content

Commit 2031915

Browse files
Bug 1066272 - Add the possibility to display unscheduled tasks (#9080)
* Bug 1066272 - part 1: Add Pulse binding for task-defined exchange * Bug 1066272 - part 2: Map task-defined exchange to unscheduled state * Bug 1066272 - part 3: Implement handle_task_defined for unscheduled tasks * Bug 1066272 - part 4: Route task-defined messages to handler * Bug 1066272 - part 5: Allow unscheduled to pending/running/completed transitions * Bug 1066272 - part 6: Add unscheduled to job counter * Bug 1066272 - part 7: Add CSS class for unscheduled jobs Use turquoise as more contrastful color for unscheduled tasks Co-authored-by: Sebastian Hengst <aryx.github@gmx-topmail.de> * Bug 1066272 - part 8: Add unscheduled filter with separate toggle * Bug 1066272 - part 9: Add integration tests for unscheduled task lifecycle * Bug 1066272 - part 10: Hide unscheduled tasks by default * Bug 1066272 - part 11: Add shortcut key 's' to toggle display of unscheduled tasks * Bug 1066272 - part 12: Add unscheduled task to user guide --------- Co-authored-by: Sebastian Hengst <aryx.github@gmx-topmail.de>
1 parent 28de6cc commit 2031915

File tree

19 files changed

+521
-20
lines changed

19 files changed

+521
-20
lines changed

tests/etl/taskcluster_pulse/__init__.py

Whitespace-only changes.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import pytest
2+
3+
from treeherder.etl.taskcluster_pulse.handler import handle_message, handle_task_defined
4+
5+
6+
@pytest.mark.asyncio
7+
async def test_handle_message_routes_task_defined():
8+
task = {
9+
"metadata": {
10+
"name": "test-task",
11+
"description": "Test task",
12+
"owner": "test@example.com",
13+
},
14+
"created": "2025-01-01T00:00:00.000Z",
15+
"workerType": "test-worker",
16+
"tags": {},
17+
"routes": ["tc-treeherder.v2.autoland.abc123"],
18+
"extra": {
19+
"treeherder": {
20+
"symbol": "T",
21+
"tier": 1,
22+
}
23+
},
24+
}
25+
26+
message = {
27+
"exchange": "exchange/taskcluster-queue/v1/task-defined",
28+
"root_url": "https://firefox-ci-tc.services.mozilla.com",
29+
"payload": {
30+
"runId": 0,
31+
"status": {
32+
"taskId": "AJBb7wqZT6K9kz4niYAatg",
33+
"state": "unscheduled",
34+
"runs": [],
35+
},
36+
},
37+
}
38+
39+
result = await handle_message(message, task)
40+
41+
assert len(result) == 1
42+
assert result[0]["state"] == "unscheduled"
43+
assert result[0]["result"] == "unknown"
44+
45+
46+
def test_handle_task_defined():
47+
push_info = {
48+
"project": "autoland",
49+
"revision": "abc123",
50+
"origin": "hg.mozilla.org",
51+
"id": "12345",
52+
}
53+
54+
task = {
55+
"metadata": {
56+
"name": "test-task",
57+
"description": "Test task",
58+
"owner": "test@example.com",
59+
},
60+
"created": "2025-01-01T00:00:00.000Z",
61+
"workerType": "test-worker",
62+
"tags": {},
63+
"extra": {
64+
"treeherder": {
65+
"symbol": "T",
66+
"tier": 1,
67+
}
68+
},
69+
}
70+
71+
message = {
72+
"exchange": "exchange/taskcluster-queue/v1/task-defined",
73+
"payload": {
74+
"status": {
75+
"taskId": "AJBb7wqZT6K9kz4niYAatg",
76+
"state": "unscheduled",
77+
"runs": [],
78+
},
79+
},
80+
}
81+
82+
result = handle_task_defined(push_info, task, message)
83+
84+
assert result is not None
85+
assert isinstance(result, dict)
86+
assert result["buildMachine"]["name"] == "unknown"
87+
assert result["origin"]["project"] == "autoland"

tests/etl/test_job_ingestion.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,3 +307,132 @@ def test_ingest_job_with_updated_job_group(
307307

308308
assert second_job.job_group.name == second_job_datum["job"]["group_name"]
309309
assert first_job.job_group.name == first_job_datum["job"]["group_name"]
310+
311+
312+
def test_unscheduled_task_full_lifecycle(
313+
test_repository, failure_classifications, sample_data, sample_push, mock_log_parser
314+
):
315+
"""Test complete lifecycle: unscheduled -> pending -> running -> completed"""
316+
job_datum = copy.deepcopy(sample_data.job_data[0])
317+
job_guid = job_datum["job"]["job_guid"]
318+
original_logs = job_datum["job"].get("logs", [])
319+
320+
# 1. Ingest as unscheduled
321+
job_datum["job"]["state"] = "unscheduled"
322+
job_datum["job"]["result"] = "unknown"
323+
if "logs" in job_datum["job"]:
324+
del job_datum["job"]["logs"]
325+
test_utils.do_job_ingestion(test_repository, [job_datum], sample_push)
326+
327+
assert Job.objects.count() == 1
328+
job = Job.objects.get(guid=job_guid)
329+
assert job.state == "unscheduled"
330+
assert job.result == "unknown"
331+
332+
# 2. Transition to pending
333+
job_datum["job"]["state"] = "pending"
334+
test_utils.do_job_ingestion(test_repository, [job_datum], sample_push)
335+
336+
job = Job.objects.get(guid=job_guid)
337+
assert job.state == "pending"
338+
assert job.result == "unknown"
339+
340+
# 3. Transition to running
341+
job_datum["job"]["state"] = "running"
342+
test_utils.do_job_ingestion(test_repository, [job_datum], sample_push)
343+
344+
job = Job.objects.get(guid=job_guid)
345+
assert job.state == "running"
346+
assert job.result == "unknown"
347+
348+
# 4. Transition to completed
349+
job_datum["job"]["state"] = "completed"
350+
job_datum["job"]["result"] = "success"
351+
if original_logs:
352+
job_datum["job"]["logs"] = original_logs
353+
test_utils.do_job_ingestion(test_repository, [job_datum], sample_push)
354+
355+
job = Job.objects.get(guid=job_guid)
356+
assert job.state == "completed"
357+
assert job.result == "success"
358+
359+
360+
def test_unscheduled_task_out_of_order(
361+
test_repository, failure_classifications, sample_data, sample_push, mock_log_parser
362+
):
363+
"""Test receiving pending before task-defined"""
364+
job_datum = copy.deepcopy(sample_data.job_data[0])
365+
job_guid = job_datum["job"]["job_guid"]
366+
367+
# 1. Ingest as pending first
368+
job_datum["job"]["state"] = "pending"
369+
job_datum["job"]["result"] = "unknown"
370+
if "logs" in job_datum["job"]:
371+
del job_datum["job"]["logs"]
372+
test_utils.do_job_ingestion(test_repository, [job_datum], sample_push)
373+
374+
assert Job.objects.count() == 1
375+
job = Job.objects.get(guid=job_guid)
376+
assert job.state == "pending"
377+
378+
# 2. Try to transition back to unscheduled (should be rejected)
379+
job_datum["job"]["state"] = "unscheduled"
380+
test_utils.do_job_ingestion(test_repository, [job_datum], sample_push)
381+
382+
job = Job.objects.get(guid=job_guid)
383+
assert job.state == "pending" # Should stay pending
384+
385+
386+
def test_unscheduled_to_completed_direct(
387+
test_repository, failure_classifications, sample_data, sample_push, mock_log_parser
388+
):
389+
"""Test task that completes without pending/running states"""
390+
job_datum = copy.deepcopy(sample_data.job_data[0])
391+
job_guid = job_datum["job"]["job_guid"]
392+
393+
# 1. Ingest as unscheduled
394+
job_datum["job"]["state"] = "unscheduled"
395+
job_datum["job"]["result"] = "unknown"
396+
if "logs" in job_datum["job"]:
397+
del job_datum["job"]["logs"]
398+
test_utils.do_job_ingestion(test_repository, [job_datum], sample_push)
399+
400+
assert Job.objects.count() == 1
401+
job = Job.objects.get(guid=job_guid)
402+
assert job.state == "unscheduled"
403+
404+
# 2. Jump directly to completed (valid for tasks that fail immediately)
405+
job_datum["job"]["state"] = "completed"
406+
job_datum["job"]["result"] = "exception"
407+
job_datum["job"]["logs"] = []
408+
test_utils.do_job_ingestion(test_repository, [job_datum], sample_push)
409+
410+
job = Job.objects.get(guid=job_guid)
411+
assert job.state == "completed"
412+
assert job.result == "exception"
413+
414+
415+
def test_unscheduled_ingestion_idempotent(
416+
test_repository, failure_classifications, sample_data, sample_push, mock_log_parser
417+
):
418+
"""Test that ingesting the same unscheduled job twice doesn't create duplicates"""
419+
job_data = copy.deepcopy(sample_data.job_data[:1])
420+
job_guid = job_data[0]["job"]["job_guid"]
421+
422+
# Create unscheduled job
423+
job_data[0]["job"]["state"] = "unscheduled"
424+
job_data[0]["job"]["result"] = "unknown"
425+
if "logs" in job_data[0]["job"]:
426+
del job_data[0]["job"]["logs"]
427+
test_utils.do_job_ingestion(test_repository, job_data, sample_push)
428+
429+
assert Job.objects.count() == 1
430+
job = Job.objects.get(guid=job_guid)
431+
assert job.state == "unscheduled"
432+
433+
# Ingest same unscheduled job again (should not create duplicate)
434+
test_utils.do_job_ingestion(test_repository, job_data, sample_push)
435+
436+
assert Job.objects.count() == 1
437+
job = Job.objects.get(guid=job_guid)
438+
assert job.state == "unscheduled"

tests/etl/test_job_loader.py

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -360,23 +360,45 @@ def test_transition_pending_retry_fail_stays_retry(
360360
change_state_result(first_job, jl, "completed", "fail", "completed", "retry")
361361

362362

363-
def test_skip_unscheduled(first_job, failure_classifications, mock_log_parser):
363+
def test_transition_unscheduled_pending_running_complete(
364+
first_job, failure_classifications, mock_log_parser
365+
):
366+
jl = JobLoader()
367+
368+
change_state_result(first_job, jl, "unscheduled", "unknown", "unscheduled", "unknown")
369+
change_state_result(first_job, jl, "pending", "unknown", "pending", "unknown")
370+
change_state_result(first_job, jl, "running", "unknown", "running", "unknown")
371+
change_state_result(first_job, jl, "completed", "success", "completed", "success")
372+
373+
374+
def test_transition_pending_unscheduled_stays_pending(
375+
first_job, failure_classifications, mock_log_parser
376+
):
364377
jl = JobLoader()
365-
first_job["state"] = "unscheduled"
366-
jl.process_job(first_job, "https://firefox-ci-tc.services.mozilla.com")
367378

368-
assert not Job.objects.count()
379+
change_state_result(first_job, jl, "pending", "unknown", "pending", "unknown")
380+
change_state_result(first_job, jl, "unscheduled", "unknown", "pending", "unknown")
381+
382+
383+
def test_transition_running_unscheduled_stays_running(
384+
first_job, failure_classifications, mock_log_parser
385+
):
386+
jl = JobLoader()
387+
388+
change_state_result(first_job, jl, "running", "unknown", "running", "unknown")
389+
change_state_result(first_job, jl, "unscheduled", "unknown", "running", "unknown")
369390

370391

371392
def change_state_result(test_job, job_loader, new_state, new_result, exp_state, exp_result):
372393
# make a copy so we can modify it and not affect other tests
373394
job = copy.deepcopy(test_job)
374395
job["state"] = new_state
375396
job["result"] = new_result
376-
if new_state == "pending":
377-
# pending jobs wouldn't have logs and our store_job_data doesn't
397+
if new_state in ("pending", "unscheduled"):
398+
# pending/unscheduled jobs wouldn't have logs and our store_job_data doesn't
378399
# support it.
379-
del job["logs"]
400+
if "logs" in job:
401+
del job["logs"]
380402
errorsummary_indices = [
381403
i
382404
for i, item in enumerate(job["jobInfo"].get("links", []))

tests/ui/helpers/filter.test.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { thAllResultStatuses } from '../../../ui/helpers/constants';
2+
import { thDefaultFilterResultStatuses } from '../../../ui/helpers/filter';
3+
4+
describe('Filter constants', () => {
5+
it('thAllResultStatuses includes unscheduled', () => {
6+
expect(thAllResultStatuses).toContain('unscheduled');
7+
});
8+
9+
it('thDefaultFilterResultStatuses does not include unscheduled', () => {
10+
expect(thDefaultFilterResultStatuses).not.toContain('unscheduled');
11+
});
12+
});

0 commit comments

Comments
 (0)