Skip to content

Commit cb7c46f

Browse files
authored
locust read load testing (#1055)
* starting locust load test * locust is working * clean up kubernetes config files for testing locust * add locust dev dep, and update locust readme * add a better locust simulator, trying to get the api key working * fix typo in docs * add test_locust.py because I am getting auth errors * update header * Update the ReadingUser, need to figure out how to get the WritingUser working * Add draft of writing user * found out that tiled-dev doesn't support writing * only load test reading in this PR * locust reading test are working well now * add locust tasks for the remaining GET endpoints * locust remove redendant debug logs * undo pyarrow version updates because it is being addressed in another pr * locust touchup * touch up locust README * default to localhost if --host is not given * locust reader touch up * add a task for each search type * get the search tasks working * add --container-name arg, and create the container if it doesn't exist * touch up
1 parent c78fb2d commit cb7c46f

File tree

5 files changed

+292
-3
lines changed

5 files changed

+292
-3
lines changed

docs/source/how-to/api-keys.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ This text is the API key. **It should be handled as a secret.**
7272
We can use it in the Python client:
7373

7474
```py
75-
>>> from tiled.client import from_url
75+
>>> from tiled.client import from_uri
7676
>>> API_KEY = "YOUR_KEY_HERE"
7777
>>> c = from_uri("http://localhost:8000", api_key=API_KEY)
7878
```
@@ -88,7 +88,7 @@ and then start Python (or IPython, or Jupyter, or...). The Python client will
8888
use that, unless it is explicitly passed different credentials.
8989

9090
```py
91-
>>> from tiled.client import from_url
91+
>>> from tiled.client import from_uri
9292
>>> c = from_uri("http://localhost:8000") # uses TILED_API_KEY, if set
9393
```
9494

docs/source/reference/http-api-overview.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ nested under `/api/v1/auth/`.
4444

4545
To view and try the *interactive* docs, visit
4646

47-
[http://tiled-demoblueskyproject.io/docs](http://tiled-demoblueskyproject.io/docs)
47+
[http://tiled-demo.blueskyproject.io/docs](http://tiled-demo.blueskyproject.io/docs)
4848

4949
or, to work fully locally, start the Tiled server with the demo
5050
Tree from a Terminal

locust/README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Tiled Load Testing with Locust
2+
3+
Simple load testing for Tiled using the `reader.py` file.
4+
5+
## Quick Start
6+
7+
```bash
8+
# Install dependencies (dev environment includes locust)
9+
pixi install -e dev
10+
```
11+
12+
### Examples
13+
Run with default localhost server (uses default API key 'secret'):
14+
```bash
15+
pixi run -e dev locust -f reader.py --host http://localhost:8000
16+
```
17+
18+
Run with custom API key:
19+
```bash
20+
pixi run -e dev locust -f reader.py --host http://localhost:8000 --api-key your-api-key
21+
```
22+
23+
Run with custom container name (defaults to locust_testing):
24+
```bash
25+
pixi run -e dev locust -f reader.py --host http://localhost:8000 --container-name my_test_container
26+
```
27+
28+
## Headless Mode
29+
Run without the web interface:
30+
```bash
31+
pixi run -e dev locust -f reader.py --headless -u 100 -r 10 -t 60s
32+
```
33+
- `-u 100`: 100 concurrent users
34+
- `-r 10`: Spawn 10 users per second
35+
- `-t 60s`: Run for 60 seconds

locust/reader.py

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import json
2+
import logging
3+
4+
import numpy as np
5+
import pyarrow
6+
7+
from locust import HttpUser, between, events, task
8+
from tiled.client import from_uri
9+
10+
11+
@events.init_command_line_parser.add_listener
12+
def _(parser):
13+
parser.add_argument(
14+
"--api-key",
15+
type=str,
16+
default="secret",
17+
help="API key for Tiled authentication (default: secret)",
18+
)
19+
parser.add_argument(
20+
"--container-name",
21+
type=str,
22+
default="locust_testing",
23+
help="Container name for test data (default: locust_testing)",
24+
)
25+
26+
27+
@events.init.add_listener
28+
def on_locust_init(environment, **kwargs):
29+
if environment.host is None:
30+
raise ValueError(
31+
"Host must be specified with --host argument, or through the web-ui."
32+
)
33+
34+
environment.container_name = environment.parsed_options.container_name
35+
environment.known_dataset_key = create_test_dataset(
36+
environment.host, environment.parsed_options.api_key, environment.container_name
37+
)
38+
39+
40+
def create_test_dataset(host, api_key, container_name):
41+
"""Create a test dataset using Tiled client for reading tasks"""
42+
43+
# Connect to Tiled server using client
44+
root_client = from_uri(host, api_key=api_key)
45+
46+
# Create container if it doesn't exist
47+
if container_name not in root_client:
48+
root_client.create_container(container_name)
49+
client = root_client[container_name]
50+
51+
rng = np.random.default_rng(seed=42)
52+
rng.integers(10, size=100, dtype=np.dtype("uint8"))
53+
54+
# Write and read tabular data to the SQL storage
55+
table = pyarrow.Table.from_pydict({"a": rng.random(100), "b": rng.random(100)})
56+
57+
table_client = client.create_appendable_table(table.schema)
58+
table_client.append_partition(table, 0)
59+
60+
# Verify we can read it back
61+
result = table_client.read()
62+
logging.debug(f"Created and verified dataset: {result}")
63+
64+
dataset_id = table_client.item["id"]
65+
client.logout()
66+
return dataset_id
67+
68+
69+
class ReadingUser(HttpUser):
70+
"""User that reads data from Tiled using HTTP API"""
71+
72+
wait_time = between(0.5, 2)
73+
74+
def on_start(self):
75+
self.client.headers = {
76+
"Authorization": f"Apikey {self.environment.parsed_options.api_key}"
77+
}
78+
79+
@task(1)
80+
def read_table_data(self):
81+
"""Read table data from our known dataset"""
82+
# Read the table data we created
83+
self.client.get(
84+
f"/api/v1/table/full/{self.environment.container_name}/{self.environment.known_dataset_key}",
85+
)
86+
87+
@task(1)
88+
def read_metadata(self):
89+
"""Read metadata from our known dataset"""
90+
self.client.get(
91+
f"/api/v1/metadata/{self.environment.container_name}/{self.environment.known_dataset_key}",
92+
)
93+
94+
@task(1)
95+
def root_endpoint(self):
96+
"""Test root endpoint performance"""
97+
self.client.get("/")
98+
99+
@task(1)
100+
def metadata_root(self):
101+
"""Test metadata root endpoint"""
102+
self.client.get("/api/v1/metadata/")
103+
104+
@task(1)
105+
def read_table_partition(self):
106+
"""Read specific partition from our known dataset"""
107+
url = (
108+
f"/api/v1/table/partition/{self.environment.container_name}/"
109+
f"{self.environment.known_dataset_key}?partition=0"
110+
)
111+
self.client.get(url)
112+
113+
@task(1)
114+
def healthz_endpoint(self):
115+
"""Test health check endpoint"""
116+
self.client.get("/healthz")
117+
118+
@task(1)
119+
def metrics_endpoint(self):
120+
"""Test Prometheus metrics endpoint"""
121+
self.client.get("/api/v1/metrics")
122+
123+
@task(1)
124+
def about_endpoint(self):
125+
"""Test API information endpoint"""
126+
self.client.get("/api/v1/")
127+
128+
@task(1)
129+
def search_root(self):
130+
"""Test search at root level"""
131+
self.client.get("/api/v1/search/")
132+
133+
@task(1)
134+
def container_full_endpoint(self):
135+
"""Test container full data endpoint"""
136+
self.client.get(f"/api/v1/container/full/{self.environment.container_name}")
137+
138+
@task(1)
139+
def whoami_endpoint(self):
140+
"""Test user identity endpoint"""
141+
self.client.get("/api/v1/auth/whoami")
142+
143+
@task(1)
144+
def search_fulltext(self):
145+
"""Test fulltext search queries"""
146+
params = {"filter[fulltext][condition][text]": "test"}
147+
self.client.get("/api/v1/search/", params=params)
148+
149+
@task(1)
150+
def search_with_limit(self):
151+
"""Test search with limit parameter"""
152+
params = {"page[limit]": 5}
153+
self.client.get("/api/v1/search/", params=params)
154+
155+
@task(1)
156+
def search_with_pagination(self):
157+
"""Test search with offset and limit parameters"""
158+
params = {"page[offset]": 10, "page[limit]": 5}
159+
self.client.get("/api/v1/search/", params=params)
160+
161+
@task(1)
162+
def search_with_sort(self):
163+
"""Test search with sort parameter"""
164+
params = {"sort": "key"}
165+
self.client.get("/api/v1/search/", params=params)
166+
167+
@task(1)
168+
def search_with_max_depth(self):
169+
"""Test search with max_depth parameter"""
170+
params = {"max_depth": 1}
171+
self.client.get("/api/v1/search/", params=params)
172+
173+
@task(1)
174+
def search_structure_family(self):
175+
"""Test structure family search queries"""
176+
params = {"filter[structure_family][condition][value]": ["table"]}
177+
self.client.get("/api/v1/search/", params=params)
178+
179+
@task(1)
180+
def search_with_omit_links(self):
181+
"""Test search with omit_links parameter"""
182+
params = {"omit_links": "true"}
183+
self.client.get("/api/v1/search/", params=params)
184+
185+
@task(1)
186+
def search_with_data_sources(self):
187+
"""Test search with include_data_sources parameter"""
188+
params = {"include_data_sources": "true"}
189+
self.client.get("/api/v1/search/", params=params)
190+
191+
@task(1)
192+
def search_eq(self):
193+
"""Test equality search queries"""
194+
params = {
195+
"filter[eq][condition][key]": "structure_family",
196+
"filter[eq][condition][value]": json.dumps("table"),
197+
}
198+
self.client.get("/api/v1/search/", params=params)
199+
200+
@task(1)
201+
def search_noteq(self):
202+
"""Test not equal search queries"""
203+
params = {
204+
"filter[noteq][condition][key]": "structure_family",
205+
"filter[noteq][condition][value]": json.dumps("array"),
206+
}
207+
self.client.get("/api/v1/search/", params=params)
208+
209+
@task(1)
210+
def search_comparison(self):
211+
"""Test comparison search queries"""
212+
params = {
213+
"filter[comparison][condition][operator]": "gt",
214+
"filter[comparison][condition][key]": "id",
215+
"filter[comparison][condition][value]": json.dumps(0),
216+
}
217+
self.client.get("/api/v1/search/", params=params)
218+
219+
@task(1)
220+
def search_like(self):
221+
"""Test like search queries"""
222+
params = {
223+
"filter[like][condition][key]": "key",
224+
"filter[like][condition][pattern]": json.dumps("%"),
225+
}
226+
self.client.get("/api/v1/search/", params=params)
227+
228+
@task(1)
229+
def search_contains(self):
230+
"""Test contains search queries"""
231+
params = {
232+
"filter[contains][condition][key]": "key",
233+
"filter[contains][condition][value]": json.dumps("locust"),
234+
}
235+
self.client.get("/api/v1/search/", params=params)
236+
237+
@task(1)
238+
def search_in(self):
239+
"""Test in search queries"""
240+
params = {
241+
"filter[in][condition][key]": "structure_family",
242+
"filter[in][condition][value]": '["table", "array"]',
243+
}
244+
self.client.get("/api/v1/search/", params=params)
245+
246+
@task(1)
247+
def search_notin(self):
248+
"""Test not in search queries"""
249+
params = {
250+
"filter[notin][condition][key]": "structure_family",
251+
"filter[notin][condition][value]": '["sparse"]',
252+
}
253+
self.client.get("/api/v1/search/", params=params)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ dev = [
150150
"importlib_resources;python_version < \"3.9\"",
151151
"ipython",
152152
"ldap3",
153+
"locust",
153154
"matplotlib",
154155
"mistune",
155156
"myst-parser",

0 commit comments

Comments
 (0)