locust 2.37.15.dev5__tar.gz → 2.39.1.dev1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/PKG-INFO +5 -1
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/__init__.py +4 -0
- locust-2.39.1.dev1/locust/_version.py +34 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/argument_parser.py +1 -1
- locust-2.39.1.dev1/locust/contrib/__init__.py +5 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/contrib/fasthttp.py +1 -1
- locust-2.39.1.dev1/locust/contrib/milvus.py +407 -0
- locust-2.39.1.dev1/locust/contrib/socketio.py +95 -0
- locust-2.39.1.dev1/locust/user/markov_taskset.py +322 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/user/task.py +13 -2
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/web.py +6 -0
- locust-2.37.15.dev5/locust/webui/dist/assets/index-Csul44Gr.js → locust-2.39.1.dev1/locust/webui/dist/assets/index-BjqxSg7R.js +26 -26
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/webui/dist/auth.html +1 -1
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/webui/dist/index.html +1 -1
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/webui/dist/report.html +1 -1
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/pyproject.toml +14 -0
- locust-2.37.15.dev5/locust/_version.py +0 -21
- locust-2.37.15.dev5/locust/util/__init__.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/.gitignore +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/LICENSE +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/README.md +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/hatch_build.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/__main__.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/clients.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/contrib/mongodb.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/contrib/oai.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/contrib/postgres.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/debug.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/dispatch.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/env.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/event.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/exception.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/html.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/input_events.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/log.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/main.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/py.typed +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/rpc/__init__.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/rpc/protocol.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/rpc/zmqrpc.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/runners.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/shape.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/stats.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/user/__init__.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/user/inspectuser.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/user/sequential_taskset.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/user/users.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/user/wait_time.py +0 -0
- {locust-2.37.15.dev5/locust/contrib → locust-2.39.1.dev1/locust/util}/__init__.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/util/cache.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/util/date.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/util/deprecation.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/util/directory.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/util/exception_handler.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/util/load_locustfile.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/util/rounding.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/util/timespan.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/util/url.py +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/webui/dist/assets/favicon-dark.png +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/webui/dist/assets/favicon-light.png +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/webui/dist/assets/graphs-dark.png +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/webui/dist/assets/graphs-light.png +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/webui/dist/assets/terminal.gif +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/webui/dist/assets/testruns-dark.png +0 -0
- {locust-2.37.15.dev5 → locust-2.39.1.dev1}/locust/webui/dist/assets/testruns-light.png +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: locust
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.39.1.dev1
|
|
4
4
|
Summary: Developer-friendly load testing framework
|
|
5
5
|
Project-URL: homepage, https://locust.io/
|
|
6
6
|
Project-URL: repository, https://github.com/locustio/locust
|
|
@@ -33,6 +33,8 @@ Requires-Dist: geventhttpclient>=2.3.1
|
|
|
33
33
|
Requires-Dist: locust-cloud>=1.26.3
|
|
34
34
|
Requires-Dist: msgpack>=1.0.0
|
|
35
35
|
Requires-Dist: psutil>=5.9.1
|
|
36
|
+
Requires-Dist: python-engineio>=4.12.2
|
|
37
|
+
Requires-Dist: python-socketio[client]>=5.13.0
|
|
36
38
|
Requires-Dist: pywin32; sys_platform == 'win32'
|
|
37
39
|
Requires-Dist: pyzmq>=25.0.0
|
|
38
40
|
Requires-Dist: requests>=2.26.0; python_version <= '3.11'
|
|
@@ -41,6 +43,8 @@ Requires-Dist: setuptools>=70.0.0
|
|
|
41
43
|
Requires-Dist: tomli>=1.1.0; python_version < '3.11'
|
|
42
44
|
Requires-Dist: typing-extensions>=4.6.0; python_version < '3.11'
|
|
43
45
|
Requires-Dist: werkzeug>=2.0.0
|
|
46
|
+
Provides-Extra: milvus
|
|
47
|
+
Requires-Dist: pymilvus>=2.5.0; extra == 'milvus'
|
|
44
48
|
Description-Content-Type: text/markdown
|
|
45
49
|
|
|
46
50
|
# Locust
|
|
@@ -27,6 +27,7 @@ from .debug import run_single_user
|
|
|
27
27
|
from .event import Events
|
|
28
28
|
from .shape import LoadTestShape
|
|
29
29
|
from .user import wait_time
|
|
30
|
+
from .user.markov_taskset import MarkovTaskSet, transition, transitions
|
|
30
31
|
from .user.sequential_taskset import SequentialTaskSet
|
|
31
32
|
from .user.task import TaskSet, tag, task
|
|
32
33
|
from .user.users import HttpUser, User
|
|
@@ -36,6 +37,9 @@ events = Events()
|
|
|
36
37
|
|
|
37
38
|
__all__ = (
|
|
38
39
|
"SequentialTaskSet",
|
|
40
|
+
"MarkovTaskSet",
|
|
41
|
+
"transition",
|
|
42
|
+
"transitions",
|
|
39
43
|
"wait_time",
|
|
40
44
|
"task",
|
|
41
45
|
"tag",
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '2.39.1.dev1'
|
|
32
|
+
__version_tuple__ = version_tuple = (2, 39, 1, 'dev1')
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|
|
@@ -216,7 +216,7 @@ Usage: locust [options] [UserClass ...]
|
|
|
216
216
|
|
|
217
217
|
locust --headless -u 100 -t 20m --processes 4 MyHttpUser AnotherUser
|
|
218
218
|
|
|
219
|
-
locust --headless -u 100 -r 10 -t 50 --print-stats --html "test_report_{u}_{r}_{t}.html"
|
|
219
|
+
locust --headless -u 100 -r 10 -t 50 --print-stats --html "test_report_{u}_{r}_{t}.html"
|
|
220
220
|
(The above run would generate an html file with the name "test_report_100_10_50.html")
|
|
221
221
|
|
|
222
222
|
See documentation for more details, including how to set options using a file or environment variables: https://docs.locust.io/en/stable/configuration.html""",
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# This directory contains modules that are experimental.
|
|
2
|
+
|
|
3
|
+
# Generally they should not have any significant issues, but they may change without a major version bump for locust.
|
|
4
|
+
|
|
5
|
+
# Some modules may require optional dependencies, typically installed with `pip install locust[name-of-module]`.
|
|
@@ -231,7 +231,7 @@ class FastHttpSession:
|
|
|
231
231
|
elif self.auth_header:
|
|
232
232
|
headers["Authorization"] = self.auth_header
|
|
233
233
|
if "Accept-Encoding" not in headers and "accept-encoding" not in headers:
|
|
234
|
-
headers["Accept-Encoding"] = "gzip, deflate, br
|
|
234
|
+
headers["Accept-Encoding"] = "gzip, deflate, br"
|
|
235
235
|
|
|
236
236
|
if not data and json is not None:
|
|
237
237
|
data = unshadowed_json.dumps(json)
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import gevent.monkey
|
|
2
|
+
|
|
3
|
+
gevent.monkey.patch_all()
|
|
4
|
+
import grpc.experimental.gevent as grpc_gevent
|
|
5
|
+
|
|
6
|
+
grpc_gevent.init_gevent()
|
|
7
|
+
|
|
8
|
+
from locust import User, events
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pymilvus import CollectionSchema, MilvusClient
|
|
15
|
+
from pymilvus.milvus_client import IndexParams
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BaseClient(ABC):
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def close(self) -> None:
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def create_collection(self, schema, index_params) -> None:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def insert(self, data) -> dict[str, Any]:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def upsert(self, data) -> dict[str, Any]:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def search(
|
|
37
|
+
self,
|
|
38
|
+
data,
|
|
39
|
+
anns_field,
|
|
40
|
+
limit,
|
|
41
|
+
filter="",
|
|
42
|
+
search_params=None,
|
|
43
|
+
output_fields=None,
|
|
44
|
+
calculate_recall=False,
|
|
45
|
+
ground_truth=None,
|
|
46
|
+
) -> dict[str, Any]:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def hybrid_search(self, reqs, ranker, limit, output_fields=None) -> dict[str, Any]:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def query(self, filter, output_fields=None) -> dict[str, Any]:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def delete(self, filter) -> dict[str, Any]:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class MilvusV2Client(BaseClient):
|
|
63
|
+
"""Milvus v2 Python SDK Client Wrapper"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, uri, collection_name, token="root:Milvus", db_name="default", timeout=60):
|
|
66
|
+
self.uri = uri
|
|
67
|
+
self.collection_name = collection_name
|
|
68
|
+
self.token = token
|
|
69
|
+
self.db_name = db_name
|
|
70
|
+
self.timeout = timeout
|
|
71
|
+
|
|
72
|
+
# Initialize MilvusClient v2
|
|
73
|
+
self.client = MilvusClient(
|
|
74
|
+
uri=self.uri,
|
|
75
|
+
token=self.token,
|
|
76
|
+
db_name=self.db_name,
|
|
77
|
+
timeout=self.timeout,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def close(self):
|
|
81
|
+
self.client.close()
|
|
82
|
+
|
|
83
|
+
def create_collection(self, schema, index_params):
|
|
84
|
+
self.client.create_collection(
|
|
85
|
+
collection_name=self.collection_name,
|
|
86
|
+
schema=schema,
|
|
87
|
+
index_params=index_params,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def insert(self, data):
|
|
91
|
+
start = time.time()
|
|
92
|
+
try:
|
|
93
|
+
result = self.client.insert(collection_name=self.collection_name, data=data)
|
|
94
|
+
total_time = (time.time() - start) * 1000
|
|
95
|
+
return {"success": True, "response_time": total_time, "result": result}
|
|
96
|
+
except Exception as e:
|
|
97
|
+
return {
|
|
98
|
+
"success": False,
|
|
99
|
+
"response_time": (time.time() - start) * 1000,
|
|
100
|
+
"exception": e,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
def upsert(self, data):
|
|
104
|
+
start = time.time()
|
|
105
|
+
try:
|
|
106
|
+
result = self.client.upsert(collection_name=self.collection_name, data=data)
|
|
107
|
+
total_time = (time.time() - start) * 1000
|
|
108
|
+
return {"success": True, "response_time": total_time, "result": result}
|
|
109
|
+
except Exception as e:
|
|
110
|
+
return {
|
|
111
|
+
"success": False,
|
|
112
|
+
"response_time": (time.time() - start) * 1000,
|
|
113
|
+
"exception": e,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
def search(
|
|
117
|
+
self,
|
|
118
|
+
data,
|
|
119
|
+
anns_field,
|
|
120
|
+
limit,
|
|
121
|
+
filter="",
|
|
122
|
+
search_params=None,
|
|
123
|
+
output_fields=None,
|
|
124
|
+
calculate_recall=False,
|
|
125
|
+
ground_truth=None,
|
|
126
|
+
):
|
|
127
|
+
if output_fields is None:
|
|
128
|
+
output_fields = ["id"]
|
|
129
|
+
|
|
130
|
+
start = time.time()
|
|
131
|
+
try:
|
|
132
|
+
result = self.client.search(
|
|
133
|
+
collection_name=self.collection_name,
|
|
134
|
+
data=data,
|
|
135
|
+
anns_field=anns_field,
|
|
136
|
+
filter=filter,
|
|
137
|
+
limit=limit,
|
|
138
|
+
search_params=search_params,
|
|
139
|
+
output_fields=output_fields,
|
|
140
|
+
)
|
|
141
|
+
total_time = (time.time() - start) * 1000
|
|
142
|
+
empty = len(result) == 0 or all(len(r) == 0 for r in result)
|
|
143
|
+
|
|
144
|
+
# Prepare base result
|
|
145
|
+
search_result = {
|
|
146
|
+
"success": not empty,
|
|
147
|
+
"response_time": total_time,
|
|
148
|
+
"empty": empty,
|
|
149
|
+
"result": result,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
# Calculate recall if requested
|
|
153
|
+
if calculate_recall and ground_truth is not None and not empty:
|
|
154
|
+
recall_value = self.get_recall(result, ground_truth, limit)
|
|
155
|
+
search_result["recall"] = recall_value
|
|
156
|
+
|
|
157
|
+
return search_result
|
|
158
|
+
except Exception as e:
|
|
159
|
+
return {
|
|
160
|
+
"success": False,
|
|
161
|
+
"response_time": (time.time() - start) * 1000,
|
|
162
|
+
"exception": e,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
def hybrid_search(self, reqs, ranker, limit, output_fields=None):
|
|
166
|
+
if output_fields is None:
|
|
167
|
+
output_fields = ["id"]
|
|
168
|
+
|
|
169
|
+
start = time.time()
|
|
170
|
+
try:
|
|
171
|
+
result = self.client.hybrid_search(
|
|
172
|
+
collection_name=self.collection_name,
|
|
173
|
+
reqs=reqs,
|
|
174
|
+
ranker=ranker,
|
|
175
|
+
limit=limit,
|
|
176
|
+
output_fields=output_fields,
|
|
177
|
+
timeout=self.timeout,
|
|
178
|
+
)
|
|
179
|
+
total_time = (time.time() - start) * 1000
|
|
180
|
+
empty = len(result) == 0 or all(len(r) == 0 for r in result)
|
|
181
|
+
|
|
182
|
+
# Prepare base result
|
|
183
|
+
search_result = {
|
|
184
|
+
"success": not empty,
|
|
185
|
+
"response_time": total_time,
|
|
186
|
+
"empty": empty,
|
|
187
|
+
"result": result,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return search_result
|
|
191
|
+
except Exception as e:
|
|
192
|
+
return {
|
|
193
|
+
"success": False,
|
|
194
|
+
"response_time": (time.time() - start) * 1000,
|
|
195
|
+
"exception": e,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def get_recall(search_results, ground_truth, limit=None):
|
|
200
|
+
"""Calculate recall for V2 client search results."""
|
|
201
|
+
try:
|
|
202
|
+
# Extract IDs from V2 search results
|
|
203
|
+
retrieved_ids = []
|
|
204
|
+
if isinstance(search_results, list) and len(search_results) > 0:
|
|
205
|
+
# search_results[0] contains the search results for the first query
|
|
206
|
+
for hit in search_results[0] if isinstance(search_results[0], list) else search_results:
|
|
207
|
+
if isinstance(hit, dict) and "id" in hit:
|
|
208
|
+
retrieved_ids.append(hit["id"])
|
|
209
|
+
elif hasattr(hit, "get"):
|
|
210
|
+
retrieved_ids.append(hit.get("id"))
|
|
211
|
+
|
|
212
|
+
# Apply limit if specified
|
|
213
|
+
if limit is None:
|
|
214
|
+
limit = len(retrieved_ids)
|
|
215
|
+
|
|
216
|
+
if len(ground_truth) < limit:
|
|
217
|
+
raise ValueError(f"Ground truth length is less than limit: {len(ground_truth)} < {limit}")
|
|
218
|
+
|
|
219
|
+
# Calculate recall
|
|
220
|
+
ground_truth_set = set(ground_truth[:limit])
|
|
221
|
+
retrieved_set = set(retrieved_ids)
|
|
222
|
+
intersect = len(ground_truth_set.intersection(retrieved_set))
|
|
223
|
+
return intersect / len(ground_truth_set)
|
|
224
|
+
|
|
225
|
+
except Exception:
|
|
226
|
+
return 0.0
|
|
227
|
+
|
|
228
|
+
def query(self, filter, output_fields=None):
|
|
229
|
+
if output_fields is None:
|
|
230
|
+
output_fields = ["id"]
|
|
231
|
+
|
|
232
|
+
start = time.time()
|
|
233
|
+
try:
|
|
234
|
+
result = self.client.query(
|
|
235
|
+
collection_name=self.collection_name,
|
|
236
|
+
filter=filter,
|
|
237
|
+
output_fields=output_fields,
|
|
238
|
+
)
|
|
239
|
+
total_time = (time.time() - start) * 1000
|
|
240
|
+
empty = len(result) == 0
|
|
241
|
+
return {
|
|
242
|
+
"success": not empty,
|
|
243
|
+
"response_time": total_time,
|
|
244
|
+
"empty": empty,
|
|
245
|
+
"result": result,
|
|
246
|
+
}
|
|
247
|
+
except Exception as e:
|
|
248
|
+
return {
|
|
249
|
+
"success": False,
|
|
250
|
+
"response_time": (time.time() - start) * 1000,
|
|
251
|
+
"exception": e,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
def delete(self, filter):
|
|
255
|
+
start = time.time()
|
|
256
|
+
try:
|
|
257
|
+
result = self.client.delete(collection_name=self.collection_name, filter=filter)
|
|
258
|
+
total_time = (time.time() - start) * 1000
|
|
259
|
+
return {"success": True, "response_time": total_time, "result": result}
|
|
260
|
+
except Exception as e:
|
|
261
|
+
return {
|
|
262
|
+
"success": False,
|
|
263
|
+
"response_time": (time.time() - start) * 1000,
|
|
264
|
+
"exception": e,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ----------------------------------
|
|
269
|
+
# Locust User wrapper
|
|
270
|
+
# ----------------------------------
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class MilvusUser(User):
|
|
274
|
+
"""Locust User implementation for Milvus operations.
|
|
275
|
+
|
|
276
|
+
This class wraps the MilvusV2Client implementation and translates
|
|
277
|
+
client method results into Locust request events so that performance
|
|
278
|
+
statistics are collected properly.
|
|
279
|
+
|
|
280
|
+
Parameters
|
|
281
|
+
----------
|
|
282
|
+
host : str
|
|
283
|
+
Milvus server URI, e.g. ``"http://localhost:19530"``.
|
|
284
|
+
collection_name : str
|
|
285
|
+
The name of the collection to operate on.
|
|
286
|
+
**client_kwargs
|
|
287
|
+
Additional keyword arguments forwarded to the client.
|
|
288
|
+
"""
|
|
289
|
+
|
|
290
|
+
abstract = True
|
|
291
|
+
|
|
292
|
+
def __init__(
|
|
293
|
+
self,
|
|
294
|
+
environment,
|
|
295
|
+
uri: str = "http://localhost:19530",
|
|
296
|
+
token: str = "root:Milvus",
|
|
297
|
+
collection_name: str = "test_collection",
|
|
298
|
+
db_name: str = "default",
|
|
299
|
+
timeout: int = 60,
|
|
300
|
+
schema: CollectionSchema | None = None,
|
|
301
|
+
index_params: IndexParams | None = None,
|
|
302
|
+
**client_kwargs,
|
|
303
|
+
):
|
|
304
|
+
super().__init__(environment)
|
|
305
|
+
|
|
306
|
+
if uri is None:
|
|
307
|
+
raise ValueError("'uri' must be provided for MilvusUser")
|
|
308
|
+
if collection_name is None:
|
|
309
|
+
raise ValueError("'collection_name' must be provided for MilvusUser")
|
|
310
|
+
|
|
311
|
+
self.client_type = "milvus"
|
|
312
|
+
self.client = MilvusV2Client(
|
|
313
|
+
uri=uri,
|
|
314
|
+
token=token,
|
|
315
|
+
collection_name=collection_name,
|
|
316
|
+
db_name=db_name,
|
|
317
|
+
timeout=timeout,
|
|
318
|
+
)
|
|
319
|
+
if schema is not None:
|
|
320
|
+
self.client.create_collection(schema=schema, index_params=index_params)
|
|
321
|
+
|
|
322
|
+
@staticmethod
|
|
323
|
+
def _fire_event(request_type: str, name: str, result: dict[str, Any]):
|
|
324
|
+
"""Emit a Locust request event from a Milvus client result dict."""
|
|
325
|
+
response_time = int(result.get("response_time", 0))
|
|
326
|
+
events.request.fire(
|
|
327
|
+
request_type=f"{request_type}",
|
|
328
|
+
name=name,
|
|
329
|
+
response_time=response_time,
|
|
330
|
+
response_length=0,
|
|
331
|
+
exception=result.get("exception"),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
@staticmethod
|
|
335
|
+
def _fire_recall_event(request_type: str, name: str, result: dict[str, Any]):
|
|
336
|
+
"""Emit a Locust request event for recall metric using recall value instead of response time."""
|
|
337
|
+
recall_value = result.get("recall", 0.0)
|
|
338
|
+
# Use recall value as response_time for metric display (scaled by 100 for better visualization) percentage
|
|
339
|
+
response_time_as_recall = int(recall_value * 100)
|
|
340
|
+
events.request.fire(
|
|
341
|
+
request_type=f"{request_type}",
|
|
342
|
+
name=name,
|
|
343
|
+
response_time=response_time_as_recall,
|
|
344
|
+
response_length=result.get("retrieved_count", 0),
|
|
345
|
+
exception=result.get("exception"),
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
def insert(self, data):
|
|
349
|
+
result = self.client.insert(data)
|
|
350
|
+
self._fire_event(self.client_type, "insert", result)
|
|
351
|
+
return result
|
|
352
|
+
|
|
353
|
+
def upsert(self, data):
|
|
354
|
+
result = self.client.upsert(data)
|
|
355
|
+
self._fire_event(self.client_type, "upsert", result)
|
|
356
|
+
return result
|
|
357
|
+
|
|
358
|
+
def search(
|
|
359
|
+
self,
|
|
360
|
+
data,
|
|
361
|
+
anns_field,
|
|
362
|
+
limit,
|
|
363
|
+
filter="",
|
|
364
|
+
search_params=None,
|
|
365
|
+
output_fields=None,
|
|
366
|
+
calculate_recall=False,
|
|
367
|
+
ground_truth=None,
|
|
368
|
+
):
|
|
369
|
+
result = self.client.search(
|
|
370
|
+
data,
|
|
371
|
+
anns_field,
|
|
372
|
+
limit,
|
|
373
|
+
filter=filter,
|
|
374
|
+
search_params=search_params,
|
|
375
|
+
output_fields=output_fields,
|
|
376
|
+
calculate_recall=calculate_recall,
|
|
377
|
+
ground_truth=ground_truth,
|
|
378
|
+
)
|
|
379
|
+
# Fire search event
|
|
380
|
+
self._fire_event(self.client_type, "search", result)
|
|
381
|
+
|
|
382
|
+
# Fire recall event if recall was calculated
|
|
383
|
+
if calculate_recall and "recall" in result:
|
|
384
|
+
self._fire_recall_event(self.client_type, "recall", result)
|
|
385
|
+
|
|
386
|
+
return result
|
|
387
|
+
|
|
388
|
+
def hybrid_search(self, reqs, ranker, limit, output_fields=None):
|
|
389
|
+
result = self.client.hybrid_search(reqs, ranker, limit, output_fields)
|
|
390
|
+
self._fire_event(self.client_type, "hybrid_search", result)
|
|
391
|
+
return result
|
|
392
|
+
|
|
393
|
+
def query(self, filter, output_fields=None):
|
|
394
|
+
result = self.client.query(
|
|
395
|
+
filter=filter,
|
|
396
|
+
output_fields=output_fields,
|
|
397
|
+
)
|
|
398
|
+
self._fire_event(self.client_type, "query", result)
|
|
399
|
+
return result
|
|
400
|
+
|
|
401
|
+
def delete(self, filter):
|
|
402
|
+
result = self.client.delete(filter)
|
|
403
|
+
self._fire_event(self.client_type, "delete", result)
|
|
404
|
+
return result
|
|
405
|
+
|
|
406
|
+
def on_stop(self):
|
|
407
|
+
self.client.close()
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from locust import User
|
|
2
|
+
|
|
3
|
+
import gevent
|
|
4
|
+
import socketio
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SocketIOUser(User):
|
|
8
|
+
"""
|
|
9
|
+
SocketIOUser wraps an instance of :class:`socketio.Client` to log requests.
|
|
10
|
+
See example in :gh:`examples/socketio/socketio_ex.py`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
abstract = True
|
|
14
|
+
options: dict = {}
|
|
15
|
+
"""socketio.Client options, e.g. `{"reconnection_attempts": 1, "reconnection_delay": 2}`"""
|
|
16
|
+
client: socketio.Client
|
|
17
|
+
"""The underlying :class:`socketio.Client` instance. Can be useful to call directly if you want to skip logging a requests."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, *args, **kwargs):
|
|
20
|
+
super().__init__(*args, **kwargs)
|
|
21
|
+
self.client = socketio.Client(**self.options)
|
|
22
|
+
self.ws_greenlet = gevent.spawn(self.client.wait)
|
|
23
|
+
self.client.on("*", self.on_message)
|
|
24
|
+
|
|
25
|
+
#
|
|
26
|
+
def on_message(self, event: str, data: str) -> None:
|
|
27
|
+
"""
|
|
28
|
+
This is the default handler for events. You can override it for custom behavior,
|
|
29
|
+
or even register separate handlers using self.client.on(event, handler)
|
|
30
|
+
|
|
31
|
+
Measuring response_time isn't obvious for for WebSockets. Sometimes a response time
|
|
32
|
+
can be inferred from the event data (if it contains a timestamp) or related to
|
|
33
|
+
a message that you sent. Override this method in your User class to do that.
|
|
34
|
+
"""
|
|
35
|
+
self.environment.events.request.fire(
|
|
36
|
+
request_type="WSR",
|
|
37
|
+
name=event,
|
|
38
|
+
response_time=0,
|
|
39
|
+
response_length=len(data or []),
|
|
40
|
+
exception=None,
|
|
41
|
+
context={},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def connect(self, *args, **kwargs):
|
|
45
|
+
"""
|
|
46
|
+
Wraps :meth:`socketio.Client.connect`.
|
|
47
|
+
"""
|
|
48
|
+
with self.environment.events.request.measure("WS", "connect") as _:
|
|
49
|
+
self.client.connect(*args, **kwargs)
|
|
50
|
+
|
|
51
|
+
def send(self, name, data=None, namespace=None) -> None:
|
|
52
|
+
"""
|
|
53
|
+
Wraps :meth:`socketio.Client.send`.
|
|
54
|
+
"""
|
|
55
|
+
exception = None
|
|
56
|
+
try:
|
|
57
|
+
self.client.send(data, namespace)
|
|
58
|
+
except Exception as e:
|
|
59
|
+
exception = e
|
|
60
|
+
self.environment.events.request.fire(
|
|
61
|
+
request_type="WSS",
|
|
62
|
+
name=name,
|
|
63
|
+
response_time=0,
|
|
64
|
+
response_length=len(data or []),
|
|
65
|
+
exception=exception,
|
|
66
|
+
context={},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def emit(self, name, data=None, namespace=None, callback=None) -> None:
|
|
70
|
+
"""
|
|
71
|
+
Wraps :meth:`socketio.Client.emit`.
|
|
72
|
+
"""
|
|
73
|
+
exception = None
|
|
74
|
+
try:
|
|
75
|
+
self.client.emit(name, data, namespace, callback)
|
|
76
|
+
except Exception as e:
|
|
77
|
+
exception = e
|
|
78
|
+
self.environment.events.request.fire(
|
|
79
|
+
request_type="WSE",
|
|
80
|
+
name=name,
|
|
81
|
+
response_time=0,
|
|
82
|
+
response_length=len(data or []),
|
|
83
|
+
exception=exception,
|
|
84
|
+
context={},
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def call(self, event, data=None, *args, **kwargs):
|
|
88
|
+
"""
|
|
89
|
+
Wraps :meth:`socketio.Client.call`.
|
|
90
|
+
"""
|
|
91
|
+
with self.environment.events.request.measure("WSC", event) as _:
|
|
92
|
+
return self.client.call(event, data, *args, **kwargs)
|
|
93
|
+
|
|
94
|
+
def on_stop(self):
|
|
95
|
+
self.client.disconnect()
|