Skip to content

Commit 9f7d0f4

Browse files
committed
testcases/loading: add a new class of tests
These tests load the SMB server and check the results. Signed-off-by: Sachin Prabhu <[email protected]>
1 parent 4ff90f2 commit 9f7d0f4

File tree

1 file changed

+384
-0
lines changed

1 file changed

+384
-0
lines changed

testcases/loading/test_loading.py

Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
#
2+
# A simple load test
3+
#
4+
# We use python process and threads to open up several consecutive connections
5+
# on the SMB server and perform either open/write, open/read and delete
6+
# operations with an interval of 0.5 seconds between each operation.
7+
# The tests are run for fixed duration of time before we stop and
8+
# print out the stats for the number of operations performed
9+
#
10+
# 10 processes each with 100 thread to simulate a total of 1000 consecutive
11+
# connections are created
12+
13+
14+
import testhelper
15+
import random
16+
import time
17+
import threading
18+
import typing
19+
import pytest
20+
import os
21+
from multiprocessing import Process, Queue
22+
23+
test_info_file = os.getenv("TEST_INFO_FILE")
24+
test_info: dict = testhelper.read_yaml(test_info_file)
25+
26+
# total number of processes
27+
total_processes: int = 10
28+
# each with this number of threads
29+
per_process_threads: int = 100
30+
# running the connection test for this many seconds
31+
test_runtime: int = 30
32+
33+
34+
class SimpleLoadTest:
35+
"""A helper class to generate a simple load on a SMB server"""
36+
37+
instance_num = 0
38+
max_files = 10
39+
test_string = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
40+
41+
def __init__(
42+
self,
43+
hostname: str,
44+
share: str,
45+
username: str,
46+
passwd: str,
47+
testdir: str,
48+
):
49+
self.idnum: int = type(self).instance_num
50+
type(self).instance_num += 1
51+
52+
self.rootpath: str = f"{testdir}/test{self.idnum}"
53+
self.files: typing.List[str] = []
54+
self.thread = None
55+
self.test_running: bool = False
56+
self.stats: dict["str", int] = {
57+
"read": 0,
58+
"write": 0,
59+
"delete": 0,
60+
"error": 0,
61+
"client_error": 0,
62+
}
63+
64+
# Operations in the frequency of which they are called
65+
self.actions: dict["str", int] = {"write": 1, "read": 3, "delete": 1}
66+
# Use the dict above to generate weights for random.choice()
67+
self.ops: list[str] = list(self.actions.keys())
68+
self.ops_count: list[int] = [self.actions[str] for str in self.ops]
69+
70+
try:
71+
self.smbclient: testhelper.SMBClient = testhelper.SMBClient(
72+
hostname, share, username, passwd
73+
)
74+
except (IOError, TimeoutError, ConnectionError) as error:
75+
self.stats["error"] += 1
76+
raise RuntimeError(f"failed to setup connection: {error}")
77+
78+
def disconnect(self) -> None:
79+
self.smbclient.disconnect()
80+
81+
def _new_file(self) -> str:
82+
"""return a new filename which doesn't exist"""
83+
# Don't go above max_files
84+
if len(self.files) >= self.max_files:
85+
return ""
86+
file: str = "file" + str(random.randint(0, 1000))
87+
# if we don't already have this filename open, return filename
88+
if file not in self.files:
89+
self.files.append(file)
90+
return f"{self.rootpath}/{file}"
91+
# else recursive call until we have a filename to return
92+
return self._new_file()
93+
94+
def _get_file(self) -> str:
95+
"""Get a filename which has already been created"""
96+
if not self.files:
97+
return ""
98+
file = random.choice(self.files)
99+
return f"{self.rootpath}/{file}"
100+
101+
def _del_file(self) -> str:
102+
"""Delete filename which has been created"""
103+
if not self.files:
104+
return ""
105+
file = random.choice(self.files)
106+
self.files.remove(file)
107+
return f"{self.rootpath}/{file}"
108+
109+
def _simple_run(self, op=""):
110+
"""Run random operations on the share
111+
This is based on the ops and weight set in self.actions
112+
in the intialiser.
113+
"""
114+
# if op hasn't been set, randomly select an op
115+
if not op:
116+
op = random.sample(self.ops, k=1, counts=self.ops_count)[0]
117+
try:
118+
if op == "read":
119+
file = self._get_file()
120+
if not file:
121+
# If no files exist, then run an write op first
122+
self._simple_run(op="write")
123+
return
124+
self.stats["read"] += 1
125+
self.smbclient.read_text(file)
126+
elif op == "write":
127+
file = self._new_file()
128+
if not file:
129+
return
130+
self.stats["write"] += 1
131+
self.smbclient.write_text(file, type(self).test_string)
132+
elif op == "delete":
133+
file = self._del_file()
134+
if not file:
135+
return
136+
self.stats["delete"] += 1
137+
self.smbclient.unlink(file)
138+
# Catch operational errors
139+
except (IOError, TimeoutError, ConnectionError) as error:
140+
print(error)
141+
self.stats["error"] += 1
142+
143+
def _clean_up(self):
144+
# Go through open file list and delete any existing files
145+
for file in self.files:
146+
self.smbclient.unlink(f"{self.rootpath}/{file}")
147+
self.files = []
148+
149+
def simple_load(self, test_start: float, test_stop: float) -> None:
150+
"""Run a simple load tests between test_start and test_stop times"""
151+
# Do not proceed if we hit an error here
152+
try:
153+
self.smbclient.mkdir(self.rootpath)
154+
except Exception:
155+
print("Error creating test subdirectory")
156+
self.stats["error"] += 1
157+
return
158+
while time.time() < test_start:
159+
time.sleep(test_start - time.time())
160+
self.test_running = True
161+
while time.time() < test_stop:
162+
self._simple_run()
163+
# Sleep for half a second between each operation
164+
time.sleep(0.5)
165+
# Record these errors but proceed with other operations
166+
try:
167+
self._clean_up()
168+
self.smbclient.rmdir(self.rootpath)
169+
except (IOError, TimeoutError, ConnectionError) as error:
170+
print(error)
171+
self.stats["error"] += 1
172+
self.test_running = False
173+
174+
def start(self, test_start, test_stop):
175+
self.thread = threading.Thread(
176+
target=self.simple_load, args=(test_start, test_stop)
177+
)
178+
try:
179+
self.thread.start()
180+
except RuntimeError:
181+
print("Could not start thread")
182+
self.thread = None
183+
self.stats["client_error"] += 1
184+
185+
def cleanup(self):
186+
while self.test_running:
187+
time.sleep(1)
188+
if self.thread:
189+
self.thread.join()
190+
# Just report errors during cleanup
191+
try:
192+
self.disconnect()
193+
except Exception as error:
194+
print(error)
195+
196+
197+
class LoadTest:
198+
def __init__(
199+
self,
200+
hostname: str,
201+
share: str,
202+
username: str,
203+
passwd: str,
204+
testdir: str,
205+
):
206+
self.server: str = hostname
207+
self.share: str = share
208+
self.username: str = username
209+
self.password: str = passwd
210+
self.testdir: str = testdir
211+
self.connections: typing.List[SimpleLoadTest] = []
212+
self.start_time: float = 0
213+
self.stop_time: float = 0
214+
215+
def get_connection_num(self) -> int:
216+
return len(self.connections)
217+
218+
def set_connection_num(self, num: int) -> None:
219+
cnum: int = self.get_connection_num()
220+
if cnum < num:
221+
for _ in range(0, num - cnum):
222+
smbclient = SimpleLoadTest(
223+
self.server,
224+
self.share,
225+
self.username,
226+
self.password,
227+
self.testdir,
228+
)
229+
self.connections.append(smbclient)
230+
elif cnum > num:
231+
for testclient in self.connections[num:]:
232+
testclient.cleanup()
233+
del self.connections[num:]
234+
235+
def total_stats(self) -> typing.Dict[str, int]:
236+
total_stats: dict[str, int] = {
237+
"write": 0,
238+
"read": 0,
239+
"delete": 0,
240+
"error": 0,
241+
"client_error": 0,
242+
}
243+
for smbcon in self.connections:
244+
stats = smbcon.stats
245+
total_stats["read"] += stats.get("read", 0)
246+
total_stats["write"] += stats.get("write", 0)
247+
total_stats["delete"] += stats.get("delete", 0)
248+
total_stats["error"] += stats.get("error", 0)
249+
total_stats["client_error"] += stats.get("client_error", 0)
250+
return total_stats
251+
252+
def start_tests(self, runtime: int) -> None:
253+
# delay start by 10 seconds to give sufficient time to
254+
# setup threads/processes.
255+
self.start_time = time.time() + 10
256+
self.stop_time = self.start_time + runtime
257+
for testclient in self.connections:
258+
testclient.start(self.start_time, self.stop_time)
259+
260+
def stop_tests(self):
261+
while time.time() < self.stop_time:
262+
time.sleep(self.stop_time - time.time())
263+
for testclient in self.connections:
264+
testclient.cleanup()
265+
266+
267+
def print_stats(header: str, stats: typing.Dict[str, int]) -> None:
268+
"""Helper function to print out process stats"""
269+
ret = header + " "
270+
ret += f'read: {stats.get("read", 0)} '
271+
ret += f'write: {stats.get("write", 0)} '
272+
ret += f'delete: {stats.get("delete", 0)} '
273+
ret += f'error: {stats.get("error", 0)} '
274+
ret += f'client_error: {stats.get("client_error", 0)} '
275+
print(ret)
276+
277+
278+
def start_process(
279+
process_number: int,
280+
numcons: int,
281+
ret_queue: Queue,
282+
mount_params: typing.Dict[str, str],
283+
testdir: str,
284+
) -> None:
285+
"""Start function for test processes"""
286+
loadtest: LoadTest = LoadTest(
287+
mount_params["host"],
288+
mount_params["share"],
289+
mount_params["username"],
290+
mount_params["password"],
291+
testdir,
292+
)
293+
loadtest.set_connection_num(numcons)
294+
loadtest.start_tests(test_runtime)
295+
loadtest.stop_tests()
296+
total_stats: dict[str, int] = loadtest.total_stats()
297+
total_stats["process_number"] = process_number
298+
total_stats["number_connections"] = numcons
299+
# Push process stats to the main process
300+
ret_queue.put(total_stats)
301+
302+
303+
def generate_loading_check() -> typing.List[tuple[str, str]]:
304+
"""return a list of tuples containig hostname and sharename to test"""
305+
arr = []
306+
for sharename in testhelper.get_exported_shares(test_info):
307+
share = testhelper.get_share(test_info, sharename)
308+
arr.append((share["server"], share["name"]))
309+
return arr
310+
311+
312+
@pytest.mark.parametrize("hostname,sharename", generate_loading_check())
313+
def test_loading(hostname: str, sharename: str) -> None:
314+
mount_params: dict[str, str] = testhelper.get_mount_parameters(
315+
test_info, sharename
316+
)
317+
testdir: str = "/loadtest"
318+
# Open a connection to create and finally remove the testdir
319+
smbclient: testhelper.SMBClient = testhelper.SMBClient(
320+
hostname,
321+
mount_params["share"],
322+
mount_params["username"],
323+
mount_params["password"],
324+
)
325+
smbclient.mkdir(testdir)
326+
327+
# Start load test
328+
329+
# return queue for stats
330+
ret_queue: Queue = Queue()
331+
processes: list[Process] = []
332+
for process_number in range(total_processes):
333+
process_testdir: str = f"{testdir}/p{process_number}"
334+
smbclient.mkdir(process_testdir)
335+
process = Process(
336+
target=start_process,
337+
args=(
338+
process_number,
339+
per_process_threads,
340+
ret_queue,
341+
mount_params,
342+
process_testdir,
343+
),
344+
)
345+
processes.append(process)
346+
347+
for process in processes:
348+
process.start()
349+
350+
for process in processes:
351+
process.join()
352+
353+
total_stats: dict[str, int] = {
354+
"write": 0,
355+
"read": 0,
356+
"delete": 0,
357+
"error": 0,
358+
"client_error": 0,
359+
}
360+
while not ret_queue.empty():
361+
stats = ret_queue.get()
362+
print_stats(
363+
f'Process #{stats["process_number"]} '
364+
+ f'{stats.get("number_connections", 0)} Connections:',
365+
stats,
366+
)
367+
total_stats["read"] += stats.get("read", 0)
368+
total_stats["write"] += stats.get("write", 0)
369+
total_stats["delete"] += stats.get("delete", 0)
370+
total_stats["error"] += stats.get("error", 0)
371+
total_stats["client_error"] += stats.get("client_error", 0)
372+
373+
for process_number in range(total_processes):
374+
process_testdir = f"{testdir}/p{process_number}"
375+
smbclient.rmdir(process_testdir)
376+
# End load test
377+
378+
smbclient.rmdir(testdir)
379+
smbclient.disconnect()
380+
381+
print_stats("Total:", total_stats)
382+
assert (
383+
total_stats["error"] == 0
384+
), "Server side errors seen when running load tests"

0 commit comments

Comments
 (0)