3
3
4
4
import secrets
5
5
from contextlib import closing
6
- from datetime import datetime , timezone
7
6
from random import randint
7
+ from unittest import mock
8
8
from unittest .mock import ANY , MagicMock
9
9
10
10
import pytest
16
16
from github_runner_manager .platform .github_provider import GitHubRunnerPlatform
17
17
from github_runner_manager .platform .platform_provider import PlatformProvider
18
18
from github_runner_manager .reactive import consumer
19
- from github_runner_manager .reactive .consumer import JobError , Labels , get_queue_size
19
+ from github_runner_manager .reactive .consumer import (
20
+ PROCESS_COUNT_HEADER_NAME ,
21
+ RETRY_LIMIT ,
22
+ WAIT_TIME_IN_SEC ,
23
+ JobError ,
24
+ Labels ,
25
+ get_queue_size ,
26
+ )
20
27
from github_runner_manager .reactive .types_ import QueueConfig
21
- from github_runner_manager .types_ .github import JobConclusion , JobInfo , JobStatus
22
28
23
29
IN_MEMORY_URI = "memory://"
24
30
FAKE_JOB_ID = "8200803099"
@@ -35,9 +41,10 @@ def queue_config_fixture() -> QueueConfig:
35
41
36
42
37
43
@pytest .fixture (name = "mock_sleep" , autouse = True )
38
- def mock_sleep_fixture (monkeypatch : pytest .MonkeyPatch ) -> None :
44
+ def mock_sleep_fixture (monkeypatch : pytest .MonkeyPatch ) -> MagicMock :
39
45
"""Mock the sleep function."""
40
- monkeypatch .setattr (consumer , "sleep" , lambda _ : None )
46
+ monkeypatch .setattr (consumer , "sleep" , mock_sleep := MagicMock ())
47
+ return mock_sleep
41
48
42
49
43
50
@pytest .mark .parametrize (
@@ -49,11 +56,11 @@ def mock_sleep_fixture(monkeypatch: pytest.MonkeyPatch) -> None:
49
56
pytest .param ({"LaBeL1" , "label2" }, {"label1" , "laBeL2" }, id = "case insensitive labels" ),
50
57
],
51
58
)
52
- def test_consume (labels : Labels , supported_labels : Labels , queue_config : QueueConfig ):
59
+ def test_consume (labels : Labels , supported_labels : Labels , queue_config : QueueConfig , mock_sleep ):
53
60
"""
54
61
arrange: A job with valid labels placed in the message queue which has not yet been picked up.
55
62
act: Call consume.
56
- assert: A runner is created and the message is removed from the queue.
63
+ assert: A runner is created, the message is removed from the queue, sleep is called once .
57
64
"""
58
65
job_details = consumer .JobDetails (
59
66
labels = labels ,
@@ -76,6 +83,8 @@ def test_consume(labels: Labels, supported_labels: Labels, queue_config: QueueCo
76
83
77
84
_assert_queue_is_empty (queue_config .queue_name )
78
85
86
+ mock_sleep .assert_called_once_with (WAIT_TIME_IN_SEC )
87
+
79
88
80
89
def test_consume_job_manager (queue_config : QueueConfig ):
81
90
"""
@@ -187,7 +196,9 @@ def test_consume_reject_if_job_gets_not_picked_up(queue_config: QueueConfig):
187
196
supported_labels = labels ,
188
197
)
189
198
190
- _assert_msg_has_been_requeued (queue_config .queue_name , job_details .json ())
199
+ _assert_msg_has_been_requeued (
200
+ queue_config .queue_name , job_details .json (), headers = {PROCESS_COUNT_HEADER_NAME : 1 }
201
+ )
191
202
192
203
193
204
def test_consume_reject_if_spawning_failed (queue_config : QueueConfig ):
@@ -216,7 +227,9 @@ def test_consume_reject_if_spawning_failed(queue_config: QueueConfig):
216
227
supported_labels = labels ,
217
228
)
218
229
219
- _assert_msg_has_been_requeued (queue_config .queue_name , job_details .json ())
230
+ _assert_msg_has_been_requeued (
231
+ queue_config .queue_name , job_details .json (), headers = {PROCESS_COUNT_HEADER_NAME : 1 }
232
+ )
220
233
221
234
222
235
def test_consume_raises_queue_error (monkeypatch : pytest .MonkeyPatch , queue_config : QueueConfig ):
@@ -351,34 +364,119 @@ def test_consume_reject_if_labels_not_supported(
351
364
_assert_queue_is_empty (queue_config .queue_name )
352
365
353
366
354
- def _create_job_info (status : JobStatus ) -> JobInfo :
355
- """Create a JobInfo object with the given status.
367
+ def test_consume_retried_job_success (queue_config : QueueConfig , mock_sleep : MagicMock ):
368
+ """
369
+ arrange: A job placed in the message queue which is processed before.
370
+ act: Call consume.
371
+ assert: A runner is spawned, the message is removed from the queue, and sleep is called two
372
+ times.
373
+ """
374
+ labels = {secrets .token_hex (16 ), secrets .token_hex (16 )}
375
+ job_details = consumer .JobDetails (
376
+ labels = labels ,
377
+ url = FAKE_JOB_URL ,
378
+ )
379
+ _put_in_queue (
380
+ job_details .json (), queue_config .queue_name , headers = {PROCESS_COUNT_HEADER_NAME : 1 }
381
+ )
356
382
357
- Args:
358
- status: The status of the job.
383
+ runner_manager_mock = MagicMock (spec = consumer .RunnerManager )
384
+ platform_mock = MagicMock (spec = PlatformProvider )
385
+ platform_mock .check_job_been_picked_up .side_effect = [False , True ]
359
386
360
- Returns:
361
- The JobInfo object.
387
+ consumer .consume (
388
+ queue_config = queue_config ,
389
+ runner_manager = runner_manager_mock ,
390
+ platform_provider = platform_mock ,
391
+ supported_labels = labels ,
392
+ )
393
+
394
+ runner_manager_mock .create_runners .assert_called_once_with (1 , metadata = ANY , reactive = True )
395
+
396
+ _assert_queue_is_empty (queue_config .queue_name )
397
+
398
+ mock_sleep .assert_has_calls ([mock .call (WAIT_TIME_IN_SEC ), mock .call (WAIT_TIME_IN_SEC )])
399
+
400
+
401
+ def test_consume_retried_job_failure (queue_config : QueueConfig , mock_sleep : MagicMock ):
402
+ """
403
+ arrange: A job placed in the message queue which is processed before. Mock runner spawn fail.
404
+ act: Call consume.
405
+ assert: The message requeued. Sleep called once.
406
+ """
407
+ labels = {secrets .token_hex (16 ), secrets .token_hex (16 )}
408
+ job_details = consumer .JobDetails (
409
+ labels = labels ,
410
+ url = FAKE_JOB_URL ,
411
+ )
412
+ _put_in_queue (
413
+ job_details .json (), queue_config .queue_name , headers = {PROCESS_COUNT_HEADER_NAME : 1 }
414
+ )
415
+
416
+ runner_manager_mock = MagicMock (spec = consumer .RunnerManager )
417
+ runner_manager_mock .create_runners .return_value = tuple ()
418
+
419
+ platform_mock = MagicMock (spec = GitHubRunnerPlatform )
420
+ platform_mock .check_job_been_picked_up .side_effect = [False ]
421
+
422
+ consumer .consume (
423
+ queue_config = queue_config ,
424
+ runner_manager = runner_manager_mock ,
425
+ platform_provider = platform_mock ,
426
+ supported_labels = labels ,
427
+ )
428
+
429
+ _assert_msg_has_been_requeued (
430
+ queue_config .queue_name , job_details .json (), headers = {PROCESS_COUNT_HEADER_NAME : 2 }
431
+ )
432
+
433
+ mock_sleep .assert_called_once_with (WAIT_TIME_IN_SEC )
434
+
435
+
436
+ def test_consume_retried_job_failure_past_limit (queue_config : QueueConfig , mock_sleep : MagicMock ):
437
+ """
438
+ arrange: A job placed in the message queue which is at the retry limit.
439
+ act: Call consume.
440
+ assert: Message not requeue, and not processed.
362
441
"""
363
- return JobInfo (
364
- created_at = datetime (2021 , 10 , 1 , 0 , 0 , 0 , tzinfo = timezone .utc ),
365
- started_at = datetime (2021 , 10 , 1 , 1 , 0 , 0 , tzinfo = timezone .utc ),
366
- conclusion = JobConclusion .SUCCESS ,
367
- status = status ,
368
- job_id = randint (1 , 1000 ),
442
+ labels = {secrets .token_hex (16 ), secrets .token_hex (16 )}
443
+ job_details = consumer .JobDetails (
444
+ labels = labels ,
445
+ url = FAKE_JOB_URL ,
369
446
)
447
+ _put_in_queue (
448
+ job_details .json (),
449
+ queue_config .queue_name ,
450
+ headers = {PROCESS_COUNT_HEADER_NAME : RETRY_LIMIT },
451
+ )
452
+ _put_in_queue (consumer .END_PROCESSING_PAYLOAD , queue_config .queue_name )
453
+
454
+ runner_manager_mock = MagicMock (spec = consumer .RunnerManager )
455
+ platform_mock = MagicMock (spec = GitHubRunnerPlatform )
456
+
457
+ consumer .consume (
458
+ queue_config = queue_config ,
459
+ runner_manager = runner_manager_mock ,
460
+ platform_provider = platform_mock ,
461
+ supported_labels = labels ,
462
+ )
463
+
464
+ runner_manager_mock .create_runners .assert_not_called ()
465
+ platform_mock .check_job_been_picked_up .assert_not_called ()
466
+ _assert_queue_is_empty (queue_config .queue_name )
370
467
371
468
372
- def _put_in_queue (msg : str , queue_name : str ) -> None :
469
+ def _put_in_queue (msg : str , queue_name : str , headers : dict [ str , str | int ] | None = None ) -> None :
373
470
"""Put a job in the message queue.
374
471
375
472
Args:
376
473
msg: The job details.
377
474
queue_name: The name of the queue
475
+ headers: The message headers. Not set if None.
378
476
"""
379
477
with Connection (IN_MEMORY_URI ) as conn :
380
478
with closing (conn .SimpleQueue (queue_name )) as simple_queue :
381
- simple_queue .put (msg , retry = True )
479
+ simple_queue .put (msg , headers = headers , retry = True )
382
480
383
481
384
482
def _consume_from_queue (queue_name : str ) -> Message :
@@ -406,15 +504,21 @@ def _assert_queue_is_empty(queue_name: str) -> None:
406
504
assert simple_queue .qsize () == 0
407
505
408
506
409
- def _assert_msg_has_been_requeued (queue_name : str , payload : str ) -> None :
507
+ def _assert_msg_has_been_requeued (
508
+ queue_name : str , payload : str , headers : dict [str , str | int ] | None
509
+ ) -> None :
410
510
"""Assert that the message is requeued.
411
511
412
512
This will consume message from the queue and assert that the payload is the same as the given.
413
513
414
514
Args:
415
515
queue_name: The name of the queue.
416
516
payload: The payload of the message to assert.
517
+ headers: The headers to assert for if present.
417
518
"""
418
519
with Connection (IN_MEMORY_URI ) as conn :
419
520
with closing (conn .SimpleQueue (queue_name )) as simple_queue :
420
- assert simple_queue .get (block = False ).payload == payload
521
+ msg = simple_queue .get (block = False )
522
+ assert msg .payload == payload
523
+ if headers is not None :
524
+ assert msg .headers == headers
0 commit comments