Skip to content

Commit e82029b

Browse files
committed
Add benchmark tests
Adopt new error handling patterns done elsewhere Propoerly parameterize the worker number Move event trigger to drain_queue method Fix changed event meanings Add artifacting of benchmark data Add benchmark test for control task Add some control message benchmarks Combine with existing test methods module Update unit test Update to new config problems Avoid retyping no longer necessary Do some modernization combine test_pool files Update test to new pattern Use better start_working call
1 parent b40708e commit e82029b

File tree

11 files changed

+345
-6
lines changed

11 files changed

+345
-6
lines changed

.github/workflows/ci.yml

+30-1
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,12 @@ jobs:
2727
run: pip install -e .[pg_notify]
2828
- run: make postgres
2929
- run: pip install pytest pytest-asyncio
30-
- run: pytest tests/unit tests/integration -vv -s
30+
- run: pytest tests/unit tests/integration -vv -s -m "not integration"
3131

3232
black:
3333
name: Run black
3434
runs-on: ubuntu-latest
35+
timeout-minutes: 3
3536
steps:
3637
- uses: actions/checkout@v4
3738
with:
@@ -43,6 +44,7 @@ jobs:
4344
isort:
4445
name: Run isort
4546
runs-on: ubuntu-latest
47+
timeout-minutes: 3
4648
steps:
4749
- uses: actions/checkout@v4
4850
with:
@@ -54,6 +56,7 @@ jobs:
5456
mypy:
5557
name: Run mypy
5658
runs-on: ubuntu-latest
59+
timeout-minutes: 3
5760
steps:
5861
- uses: actions/checkout@v4
5962
with:
@@ -67,10 +70,36 @@ jobs:
6770
flake8:
6871
name: Run flake8
6972
runs-on: ubuntu-latest
73+
timeout-minutes: 3
7074
steps:
7175
- uses: actions/checkout@v4
7276
with:
7377
show-progress: false
7478

7579
- run: pip install flake8
7680
- run: flake8 dispatcher
81+
82+
benchmark:
83+
name: Run benchmark tests
84+
runs-on: ubuntu-latest
85+
timeout-minutes: 15
86+
steps:
87+
- uses: actions/checkout@v4
88+
with:
89+
show-progress: false
90+
91+
- uses: actions/setup-python@v5
92+
with:
93+
python-version: '3.13'
94+
95+
- name: Install dispatcher
96+
run: pip install -e .[pg_notify]
97+
- run: make postgres
98+
- run: pip install pytest pytest-benchmark pytest-asyncio
99+
- run: make benchmark
100+
101+
- name: Save benchmark results as artifact
102+
uses: actions/upload-artifact@v4
103+
with:
104+
name: benchmark-results
105+
path: benchmark_data.json

.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,6 @@ tools/generated/*
4848

4949
# Gets created when testing sonar-scanner locally
5050
.scannerwork
51+
52+
# benchmark tests output
53+
benchmark_data.json

Makefile

+3
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,6 @@ linters:
2020
isort dispatcher/
2121
flake8 dispatcher/
2222
mypy dispatcher
23+
24+
benchmark:
25+
py.test tests/ --benchmark-columns=mean,min,max,stddev,rounds --benchmark-json=benchmark_data.json --benchmark-only

dispatcher/worker/task.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,8 @@ def work_loop(worker_id: int, settings: dict, finished_queue: multiprocessing.Qu
250250
result = worker.perform_work(message)
251251

252252
# Indicate that the task is finished by putting a message in the finished_queue
253-
finished_queue.put(worker.get_finished_message(result, message, time_started))
253+
to_send = worker.get_finished_message(result, message, time_started)
254+
finished_queue.put(to_send)
254255

255256
finished_queue.put(worker.get_shutdown_message())
256257
logger.debug(f'Worker {worker_id} informed the pool manager that we have exited')

tests/benchmark/conftest.py

+181
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import asyncio
2+
import contextlib
3+
import multiprocessing
4+
import time
5+
from copy import deepcopy
6+
7+
import pytest
8+
9+
from dispatcher.brokers.pg_notify import create_connection
10+
from dispatcher.config import DispatcherSettings
11+
from dispatcher.factories import from_settings
12+
13+
14+
class PoolServer:
15+
"""Before you read more, know there are 3 contexts involved.
16+
17+
This produces a method to be passed to pytest-benchmark.
18+
That method has to be ran inside a context manager,
19+
which will run (and stop) the relevant dispatcher code in a background process.
20+
"""
21+
22+
def __init__(self, config):
23+
self.config = config
24+
25+
def run_benchmark_test(self, queue_in, queue_out, times):
26+
print(f'submitting message to pool server {times}')
27+
queue_in.put(str(times))
28+
print('waiting for reply message from pool server')
29+
message_in = queue_out.get()
30+
print(f'finished running round with {times} messages, got: {message_in}')
31+
if message_in == 'error':
32+
raise Exception('Test subprocess runner exception, look back in logs')
33+
34+
@classmethod
35+
async def run_pool(cls, config, queue_in, queue_out, workers, function='lambda: __import__("time").sleep(0.01)'):
36+
this_config = config.copy()
37+
this_config['service']['pool_kwargs']['max_workers'] = workers
38+
dispatcher = from_settings(DispatcherSettings(this_config))
39+
pool = dispatcher.pool
40+
await pool.start_working(dispatcher)
41+
queue_out.put('ready')
42+
43+
print('waiting for message to start test')
44+
loop = asyncio.get_event_loop()
45+
while True:
46+
print('pool server listening on queue_in')
47+
message = await loop.run_in_executor(None, queue_in.get)
48+
print(f'pool server got message {message}')
49+
if message == 'stop':
50+
print('shutting down pool server')
51+
pool.shutdown()
52+
break
53+
else:
54+
times = int(message.strip())
55+
print('creating cleared event task')
56+
cleared_event = asyncio.create_task(pool.events.work_cleared.wait())
57+
print('creating tasks for submissions')
58+
submissions = [pool.dispatch_task({'task': function, 'uuid': str(i)}) for i in range(times)]
59+
print('awaiting submission task')
60+
await asyncio.gather(*submissions)
61+
print('waiting for cleared event')
62+
await cleared_event
63+
pool.events.work_cleared.clear()
64+
await loop.run_in_executor(None, queue_out.put, 'done')
65+
print('exited forever loop of pool server')
66+
67+
@classmethod
68+
def run_pool_loop(cls, config, queue_in, queue_out, workers, **kwargs):
69+
loop = asyncio.get_event_loop()
70+
try:
71+
loop.run_until_complete(cls.run_pool(config, queue_in, queue_out, workers, **kwargs))
72+
except Exception:
73+
import traceback
74+
75+
traceback.print_exc()
76+
# We are in a subprocess here, so even if we handle the exception
77+
# the main process will not know and still wait forever
78+
# so give them a kick on our way out
79+
print('sending error message after error')
80+
queue_out.put('error')
81+
finally:
82+
print('closing asyncio loop')
83+
loop.close()
84+
print('finished closing async loop')
85+
86+
def start_server(self, workers, **kwargs):
87+
self.queue_in = multiprocessing.Queue()
88+
self.queue_out = multiprocessing.Queue()
89+
process = multiprocessing.Process(target=self.run_pool_loop, args=(self.config, self.queue_in, self.queue_out, workers), kwargs=kwargs)
90+
process.start()
91+
return process
92+
93+
@contextlib.contextmanager
94+
def with_server(self, *args, **kwargs):
95+
process = self.start_server(*args, **kwargs)
96+
msg = self.queue_out.get()
97+
if msg != 'ready':
98+
raise RuntimeError('never got ready message from subprocess')
99+
try:
100+
yield self
101+
finally:
102+
self.queue_in.put('stop')
103+
process.terminate() # SIGTERM
104+
# Poll to close process resources, due to race condition where it is not still running
105+
for i in range(3):
106+
time.sleep(0.1)
107+
try:
108+
process.close()
109+
break
110+
except Exception:
111+
if i == 2:
112+
raise
113+
114+
115+
class FullServer(PoolServer):
116+
def run_benchmark_test(self, queue_in, queue_out, times):
117+
print('sending wakeup message to set new clear event')
118+
queue_in.put('wake')
119+
print('sending pg_notify messages')
120+
function = 'lambda: __import__("time").sleep(0.01)'
121+
conn = create_connection(**self.config['brokers']['pg_notify']['config'])
122+
with conn.cursor() as cur:
123+
for i in range(times):
124+
cur.execute(f"SELECT pg_notify('test_channel', '{function}');")
125+
print('waiting for reply message from pool server')
126+
message_in = queue_out.get()
127+
print(f'finished running round with {times} messages, got: {message_in}')
128+
129+
@classmethod
130+
async def run_pool(cls, config, queue_in, queue_out, workers):
131+
this_config = config.copy()
132+
this_config['service']['pool_kwargs']['max_workers'] = workers
133+
this_config['service']['pool_kwargs']['min_workers'] = workers
134+
dispatcher = from_settings(DispatcherSettings(this_config))
135+
await dispatcher.start_working()
136+
# Make sure the dispatcher is listening before starting the tests which will submit messages
137+
for producer in dispatcher.producers:
138+
await producer.events.ready_event.wait()
139+
queue_out.put('ready')
140+
141+
print('waiting for message to start test')
142+
loop = asyncio.get_event_loop()
143+
while True:
144+
print('pool server listening on queue_in')
145+
message = await loop.run_in_executor(None, queue_in.get)
146+
print(f'pool server got message {message}')
147+
if message == 'stop':
148+
print('shutting down server')
149+
dispatcher.shutdown()
150+
break
151+
print('creating cleared event task')
152+
cleared_event = asyncio.create_task(dispatcher.pool.events.queue_cleared.wait())
153+
print('waiting for cleared event')
154+
await cleared_event
155+
dispatcher.pool.events.queue_cleared.clear()
156+
await loop.run_in_executor(None, queue_out.put, 'done')
157+
print('exited forever loop of pool server')
158+
159+
160+
@pytest.fixture
161+
def benchmark_config(test_config):
162+
config = deepcopy(test_config)
163+
config['service']['main_kwargs']['node_id'] = 'benchmark-server'
164+
return config
165+
166+
167+
@pytest.fixture
168+
def benchmark_settings(benchmark_config):
169+
return DispatcherSettings(benchmark_config)
170+
171+
172+
@pytest.fixture
173+
def with_pool_server(benchmark_config):
174+
server_thing = PoolServer(benchmark_config)
175+
return server_thing.with_server
176+
177+
178+
@pytest.fixture
179+
def with_full_server(benchmark_config):
180+
server_thing = FullServer(benchmark_config)
181+
return server_thing.with_server
+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import os
2+
import sys
3+
4+
import pytest
5+
6+
7+
@pytest.mark.benchmark(group="by_task")
8+
@pytest.mark.parametrize('times', [1, 10, 100, 1000])
9+
def test_clear_sleep_by_task_number(benchmark, times, with_pool_server):
10+
with with_pool_server(4, function='lambda: __import__("time").sleep(0.01)') as pool_server:
11+
benchmark(pool_server.run_benchmark_test, pool_server.queue_in, pool_server.queue_out, times)
12+
13+
14+
@pytest.mark.benchmark(group="by_task")
15+
@pytest.mark.parametrize('times', [1, 10, 100, 1000])
16+
def test_clear_no_op_by_task_number(benchmark, times, with_pool_server):
17+
with with_pool_server(4, function='lambda: None') as pool_server:
18+
benchmark(pool_server.run_benchmark_test, pool_server.queue_in, pool_server.queue_out, times)
19+
20+
21+
@pytest.mark.benchmark(group="by_worker_sleep")
22+
@pytest.mark.parametrize('workers', [1, 4, 12, 24, 50, 75])
23+
def test_clear_sleep_by_worker_count(benchmark, workers, with_pool_server):
24+
with with_pool_server(workers, function='lambda: __import__("time").sleep(0.01)') as pool_server:
25+
benchmark(pool_server.run_benchmark_test, pool_server.queue_in, pool_server.queue_out, 100)
26+
27+
28+
@pytest.mark.benchmark(group="by_worker_math")
29+
@pytest.mark.parametrize('workers', [1, 4, 12, 24, 50, 75])
30+
def test_clear_math_by_worker_count(benchmark, workers, with_pool_server):
31+
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))))
32+
sys.path.append(root_dir)
33+
34+
with with_pool_server(workers, function='lambda: __import__("tests.data.methods").fibonacci(26)') as pool_server:
35+
benchmark(pool_server.run_benchmark_test, pool_server.queue_in, pool_server.queue_out, 100)

tests/benchmark/test_control.py

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import asyncio
2+
3+
import pytest
4+
5+
from dispatcher.factories import get_control_from_settings, get_publisher_from_settings
6+
7+
8+
@pytest.mark.benchmark(group="control")
9+
def test_alive_benchmark(benchmark, with_full_server, test_settings):
10+
control = get_control_from_settings(settings=test_settings)
11+
12+
def alive_check():
13+
r = control.control_with_reply('alive')
14+
assert r == [{'node_id': 'benchmark-server'}]
15+
16+
with with_full_server(4):
17+
benchmark(alive_check)
18+
19+
20+
@pytest.mark.benchmark(group="control")
21+
@pytest.mark.parametrize('messages', [0, 3, 4, 5, 10, 100])
22+
def test_alive_benchmark_while_busy(benchmark, with_full_server, benchmark_settings, messages):
23+
control = get_control_from_settings(settings=benchmark_settings)
24+
broker = get_publisher_from_settings('pg_notify', settings=benchmark_settings)
25+
broker.get_connection() # warm connection saver
26+
27+
def alive_check():
28+
function = 'lambda: __import__("time").sleep(0.01)'
29+
for i in range(messages):
30+
broker.publish_message(channel='test_channel', message=function)
31+
r = control.control_with_reply('alive', timeout=2)
32+
assert r == [{'node_id': 'benchmark-server'}]
33+
34+
with with_full_server(4):
35+
benchmark(alive_check)

tests/benchmark/test_full_server.py

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import pytest
2+
3+
4+
@pytest.mark.benchmark(group="by_system")
5+
def test_clear_time_with_full_server(benchmark, with_full_server):
6+
with with_full_server(4) as server:
7+
benchmark(server.run_benchmark_test, server.queue_in, server.queue_out, 100)
8+
9+
10+
@pytest.mark.benchmark(group="by_system")
11+
def test_clear_time_with_only_pool(benchmark, with_pool_server):
12+
with with_pool_server(4) as pool_server:
13+
benchmark(pool_server.run_benchmark_test, pool_server.queue_in, pool_server.queue_out, 100)

0 commit comments

Comments
 (0)