stac-api-load-testing 0.3.0__tar.gz → 0.4.0__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.
Files changed (21) hide show
  1. {stac_api_load_testing-0.3.0/stac_api_load_testing.egg-info → stac_api_load_testing-0.4.0}/PKG-INFO +55 -1
  2. stac_api_load_testing-0.4.0/README.md +149 -0
  3. {stac_api_load_testing-0.3.0 → stac_api_load_testing-0.4.0}/pyproject.toml +1 -1
  4. {stac_api_load_testing-0.3.0 → stac_api_load_testing-0.4.0}/stac_api_load_testing/cli.py +156 -27
  5. {stac_api_load_testing-0.3.0 → stac_api_load_testing-0.4.0}/stac_api_load_testing/config_files/locustfile.py +297 -30
  6. stac_api_load_testing-0.4.0/stac_api_load_testing/data_loader/data_loader.py +118 -0
  7. {stac_api_load_testing-0.3.0 → stac_api_load_testing-0.4.0}/stac_api_load_testing/data_loader/setup_data/collection.json +1 -0
  8. {stac_api_load_testing-0.3.0 → stac_api_load_testing-0.4.0/stac_api_load_testing.egg-info}/PKG-INFO +55 -1
  9. stac_api_load_testing-0.3.0/README.md +0 -95
  10. stac_api_load_testing-0.3.0/stac_api_load_testing/data_loader/data_loader.py +0 -60
  11. {stac_api_load_testing-0.3.0 → stac_api_load_testing-0.4.0}/LICENSE +0 -0
  12. {stac_api_load_testing-0.3.0 → stac_api_load_testing-0.4.0}/setup.cfg +0 -0
  13. {stac_api_load_testing-0.3.0 → stac_api_load_testing-0.4.0}/stac_api_load_testing/__init__.py +0 -0
  14. {stac_api_load_testing-0.3.0 → stac_api_load_testing-0.4.0}/stac_api_load_testing/config_files/taurus_locust.yml +0 -0
  15. {stac_api_load_testing-0.3.0 → stac_api_load_testing-0.4.0}/stac_api_load_testing/data_loader/__init__.py +0 -0
  16. {stac_api_load_testing-0.3.0 → stac_api_load_testing-0.4.0}/stac_api_load_testing/data_loader/setup_data/sentinel-s2-l2a-cogs_0_100.json +0 -0
  17. {stac_api_load_testing-0.3.0 → stac_api_load_testing-0.4.0}/stac_api_load_testing.egg-info/SOURCES.txt +0 -0
  18. {stac_api_load_testing-0.3.0 → stac_api_load_testing-0.4.0}/stac_api_load_testing.egg-info/dependency_links.txt +0 -0
  19. {stac_api_load_testing-0.3.0 → stac_api_load_testing-0.4.0}/stac_api_load_testing.egg-info/entry_points.txt +0 -0
  20. {stac_api_load_testing-0.3.0 → stac_api_load_testing-0.4.0}/stac_api_load_testing.egg-info/requires.txt +0 -0
  21. {stac_api_load_testing-0.3.0 → stac_api_load_testing-0.4.0}/stac_api_load_testing.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stac-api-load-testing
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Load testing for stac compliant apis
5
5
  Author-email: Jonathan Healy <jonathan.d.healy@gmail.com>
6
6
  License: MIT
@@ -68,6 +68,11 @@ Usage: stac-api-load-testing [OPTIONS]
68
68
 
69
69
  Options:
70
70
  -i, --ingest Ingest sample data into the STAC API.
71
+ --collections INTEGER Number of collections to create for --ingest.
72
+ --items-per-collection INTEGER
73
+ Items per collection for --ingest; beyond the
74
+ 100 bundled items, duplicates are synthesized
75
+ with new ids and shifted geometry.
71
76
  -l, --locust Run Locust load tests against the STAC API.
72
77
  -t, --taurus Run the Taurus wrapper for performance testing
73
78
  against the STAC API.
@@ -83,6 +88,8 @@ Options:
83
88
  --spawn-rate INTEGER Users spawned per second for --benchmark.
84
89
  --run-time TEXT Duration of each --benchmark run, in locust
85
90
  notation (e.g. '30s', '1m').
91
+ -w, --workers INTEGER Locust worker processes for --benchmark; >1
92
+ distributes load across cores on this machine.
86
93
  -o, --output PATH Write aggregated --benchmark results to a CSV
87
94
  file.
88
95
  --version Show the version and exit.
@@ -92,6 +99,10 @@ Options:
92
99
  ## Ingest test data - http://localhost:8084 is just an example url
93
100
  ```$ stac-api-load-testing --ingest --api-address http://localhost:8084```
94
101
 
102
+ Larger synthetic datasets: `--collections` creates `test-collection`, `test-collection-2`, ..., and `--items-per-collection` controls items per collection. Above the 100 bundled items, duplicates are derived from the sample features with new ids and randomly relocated geometry:
103
+
104
+ ```$ stac-api-load-testing --ingest --api-address http://localhost:8084 --collections 2 --items-per-collection 500```
105
+
95
106
  ## Run Locust Load Testing Ouside of Taurus Wrapper
96
107
  ```$ stac-api-load-testing --locust --api-address http://localhost:8084```
97
108
  - go to ```http://localhost:8089``` and start with desired settings
@@ -110,5 +121,48 @@ Compare multiple backends in one command (comma-separated addresses):
110
121
 
111
122
  ```$ stac-api-load-testing --benchmark --api-address http://localhost:8085,http://localhost:8087 --users 10,50,100 --run-time 1m -o results.csv```
112
123
 
124
+ ## Example benchmark: pgstac vs opensearch vs stac-os-rs
125
+
126
+ Methodology used for the comparison below:
127
+
128
+ 1. Start all three backends, each with its own datastore on a ~2GB memory budget (OpenSearch 3.8.0 `-Xms2g -Xmx2g`, pgstac `shared_buffers=2GB`):
129
+
130
+ ```$ docker compose up -d opensearch opensearch-rs app-opensearch app-stac-os-rs pgstac app-pgstac```
131
+
132
+ 2. Ingest the same dataset into each backend — 4 collections with 500 Sentinel-2 L2A items each (2,000 items total):
133
+
134
+ ```$ stac-api-load-testing --ingest --api-address http://localhost:8083 --collections 4 --items-per-collection 500```
135
+ ```$ stac-api-load-testing --ingest --api-address http://localhost:8085 --collections 4 --items-per-collection 500```
136
+ ```$ stac-api-load-testing --ingest --api-address http://localhost:8087 --collections 4 --items-per-collection 500```
137
+
138
+ 3. Run the benchmark sweep against all three in one command (10/50/100/150 users, spawn rate 10 users/s, 1 minute per run, 8 locust worker processes):
139
+
140
+ ```$ stac-api-load-testing --benchmark --api-address stac-fastapi-pgstac=http://localhost:8083,stac-fastapi-os=http://localhost:8085,stac-os-rs=http://localhost:8087 --users 10,50,100,150 --run-time 1m -w 8 -o results.csv```
141
+
142
+ Environment: AMD Ryzen 7 8845HS (16 threads), 28GB RAM, Docker 29.8.0, locust 2.47.0 (`-w 8`), stac-fastapi.pgstac 7.2.0, stac-fastapi-os v8.1.1, stac-os-rs 0.5.0. The locustfile drives a read/search workload — landing page, collections, collection items, single items across all collections, GET/POST `/search` with bbox, intersects, sortby, datetime, CQL2, and ids queries, one level of `next`-link pagination, and filtered item lists; every search uses `limit=10`; write tasks are disabled.
143
+
144
+ Note on parity: stac-fastapi-os and stac-os-rs compute `numberMatched` (total-hit counting) on every search, while this stac-fastapi-pgstac version ships no context extension and skips count work entirely — so pgstac's reported numbers do not include a cost the OpenSearch-backed APIs pay per query.
145
+
146
+ ### Results
147
+
148
+ | api | collections | items | users | requests | failures | median (ms) | p95 (ms) | p99 (ms) | req/s |
149
+ | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
150
+ | stac-fastapi-pgstac (:8083) | 4 | 2,000 | 10 | 11,252 | 0 | 27 | 140 | 170 | 187 |
151
+ | stac-fastapi-pgstac (:8083) | 4 | 2,000 | 50 | 13,817 | 0 | 54 | 780 | 2,500 | 230 |
152
+ | stac-fastapi-pgstac (:8083) | 4 | 2,000 | 100 | 13,629 | 0 | 120 | 1,300 | 5,900 | 227 |
153
+ | stac-fastapi-pgstac (:8083) | 4 | 2,000 | 150 | 13,619 | 0 | 250 | 1,600 | 7,300 | 227 |
154
+ | stac-fastapi-os (:8085) | 4 | 2,000 | 10 | 49,492 | 0 | 9 | 25 | 32 | 824 |
155
+ | stac-fastapi-os (:8085) | 4 | 2,000 | 50 | 59,998 | 0 | 43 | 98 | 140 | 999 |
156
+ | stac-fastapi-os (:8085) | 4 | 2,000 | 100 | 58,755 | 0 | 90 | 170 | 220 | 978 |
157
+ | stac-fastapi-os (:8085) | 4 | 2,000 | 150 | 51,455 | 0 | 150 | 270 | 330 | 857 |
158
+ | stac-os-rs (:8087) | 4 | 2,000 | 10 | 100,517 | 0 | 5 | 10 | 12 | 1,674 |
159
+ | stac-os-rs (:8087) | 4 | 2,000 | 50 | 131,865 | 0 | 20 | 38 | 47 | 2,196 |
160
+ | stac-os-rs (:8087) | 4 | 2,000 | 100 | 130,389 | 0 | 40 | 68 | 82 | 2,170 |
161
+ | stac-os-rs (:8087) | 4 | 2,000 | 150 | 105,080 | 0 | 72 | 120 | 150 | 1,748 |
162
+
163
+ ### Summary
164
+
165
+ stac-os-rs sustained ~2.2x the throughput of stac-fastapi-os and ~8.8x stac-fastapi-pgstac, peaking at ~2,200 req/s with zero failures across all twelve runs. All three backends saturate by ~100 users on this box — throughput knees at ~250 (pgstac), ~1,000 (fastapi-os), and ~2,200 (stac-os-rs) req/s; at 150 users each shows the classic overload curve with declining throughput and rising latency. The OpenSearch-backed APIs degrade without errors, as does pgstac — its earlier failures (CQL2 500s, dropped connections) traced to Docker's 64MB `/dev/shm` default starving Postgres parallel-query workers, fixed by `shm_size: 1gb` on the pgstac container; with that resolved its ~230 req/s ceiling is clean but its p99 still reaches 7.3s at 150 users. Running enough locust workers matters: in single-process runs the same setup measured only ~1,000 req/s because the load generator itself was CPU-bound — with `-w 8` no CPU warnings appeared and reported numbers reflect the backends. These numbers reflect a 2,000-item dataset on a single machine and should be treated as a relative comparison, not absolute capacity.
166
+
113
167
  ## Debugging Locust tasks using a single simulated user
114
168
  ```$ python stac_api_load_testing/config_files/locustfile.py```
@@ -0,0 +1,149 @@
1
+ # stac-api-load-testing
2
+ Taurus and Locust load testing tools for stac-api backends.
3
+
4
+ ![Alt text](readme_files/taurus-pgstac.png?raw=true "stac-fastapi-pgstac")
5
+
6
+ Requires Python >= 3.11.
7
+
8
+ ## Run stac-fastapi backends locally (elasticsearch|opensearch|pgstac|mongo|stac-os-rs)
9
+ ```$ docker compose up elasticsearch```
10
+ ```$ docker compose up app-elasticsearch```
11
+
12
+ Backend ports: pgstac `:8083`, elasticsearch `:8084`, opensearch `:8085`, mongo `:8086`, stac-os-rs `:8087`
13
+
14
+ | backend | app image | database image |
15
+ | --- | --- | --- |
16
+ | pgstac | stac-fastapi.pgstac 7.2.0 (built locally) | ghcr.io/stac-utils/pgstac:v0.10.0 |
17
+ | elasticsearch | ghcr.io/stac-utils/stac-fastapi-es:v8.1.1 | elasticsearch 9.3.2 |
18
+ | opensearch | ghcr.io/stac-utils/stac-fastapi-os:v8.1.1 | opensearch 3.8.0 |
19
+ | mongo | stac-fastapi.mongo 3.2.0 (built locally) | mongo 7.0 |
20
+ | stac-os-rs | ghcr.io/jonhealy1/stac-os-rs:0.2.0 | opensearch 3.8.0 |
21
+
22
+ All datastores are given a ~2GB memory budget for comparable benchmarks: ES/OpenSearch heaps `-Xms2g -Xmx2g` with `bootstrap.memory_lock`, pgstac `shared_buffers=2GB`, mongo `--wiredTigerCacheSizeGB 2`. Ingest the same data into each backend before comparing (`stac-api-load-testing --ingest --api-address <url>`).
23
+
24
+ ## Install locally for development
25
+ ```$ pip install -e .```
26
+
27
+ ## Install from PyPI
28
+ ```$ pip install stac-api-load-testing```
29
+
30
+ ```
31
+ Usage: stac-api-load-testing [OPTIONS]
32
+
33
+ Entry point for the stac-api-load-testing CLI tool.
34
+
35
+ This tool facilitates data ingestion, Locust load testing, and Taurus
36
+ performance testing against a specified STAC API endpoint.
37
+
38
+ Args:
39
+ ingest (bool): If True, ingest sample data into the specified STAC API.
40
+ locust (bool): If True, execute Locust load tests against the
41
+ specified STAC API.
42
+ taurus (bool): If True, perform Taurus performance
43
+ testing with custom settings against the specified STAC API.
44
+ concurrency (int): Specifies the number of concurrent users for Taurus testing. Default is 10.
45
+ ramp_up (str): Specifies the ramp-up period for Taurus testing, in Taurus notation (e.g., '1m' for 1 minute). Default is '1m'.
46
+ iterations (int): Specifies the number of iterations each virtual user will
47
+ execute in Taurus testing. Default is 100.
48
+ api_address (str): The base URL of the STAC API to be tested.
49
+
50
+ Options:
51
+ -i, --ingest Ingest sample data into the STAC API.
52
+ --collections INTEGER Number of collections to create for --ingest.
53
+ --items-per-collection INTEGER
54
+ Items per collection for --ingest; beyond the
55
+ 100 bundled items, duplicates are synthesized
56
+ with new ids and shifted geometry.
57
+ -l, --locust Run Locust load tests against the STAC API.
58
+ -t, --taurus Run the Taurus wrapper for performance testing
59
+ against the STAC API.
60
+ -c, --concurrency INTEGER Number of concurrent users for Taurus option.
61
+ -r, --ramp-up TEXT Ramp-up time for Taurus option.
62
+ -n, --iterations INTEGER Number of iterations for Taurus option.
63
+ -a, --api-address TEXT Specify the STAC API URL to test against.
64
+ Accepts a comma-separated list for --benchmark.
65
+ -b, --benchmark Run a headless locust benchmark sweep against the
66
+ STAC API.
67
+ -u, --users TEXT Comma-separated concurrency levels for
68
+ --benchmark.
69
+ --spawn-rate INTEGER Users spawned per second for --benchmark.
70
+ --run-time TEXT Duration of each --benchmark run, in locust
71
+ notation (e.g. '30s', '1m').
72
+ -w, --workers INTEGER Locust worker processes for --benchmark; >1
73
+ distributes load across cores on this machine.
74
+ -o, --output PATH Write aggregated --benchmark results to a CSV
75
+ file.
76
+ --version Show the version and exit.
77
+ --help Show this message and exit.
78
+ ```
79
+
80
+ ## Ingest test data - http://localhost:8084 is just an example url
81
+ ```$ stac-api-load-testing --ingest --api-address http://localhost:8084```
82
+
83
+ Larger synthetic datasets: `--collections` creates `test-collection`, `test-collection-2`, ..., and `--items-per-collection` controls items per collection. Above the 100 bundled items, duplicates are derived from the sample features with new ids and randomly relocated geometry:
84
+
85
+ ```$ stac-api-load-testing --ingest --api-address http://localhost:8084 --collections 2 --items-per-collection 500```
86
+
87
+ ## Run Locust Load Testing Ouside of Taurus Wrapper
88
+ ```$ stac-api-load-testing --locust --api-address http://localhost:8084```
89
+ - go to ```http://localhost:8089``` and start with desired settings
90
+ - or run headless: ```$ locust --locustfile stac_api_load_testing/config_files/locustfile.py --host http://localhost:8084 --headless -u 10 -r 1 -t 1m```
91
+
92
+ ## Inside of Taurus Wrapper
93
+ ```$ stac-api-load-testing --taurus --api-address http://localhost:8084```
94
+
95
+ ## Benchmark / compare backends
96
+ Runs headless locust sweeps at each concurrency level and prints a comparison
97
+ table. Ingest into each backend first, then run them one at a time:
98
+
99
+ ```$ stac-api-load-testing --benchmark --api-address http://localhost:8085 --users 10,50,100 --run-time 1m```
100
+
101
+ Compare multiple backends in one command (comma-separated addresses):
102
+
103
+ ```$ stac-api-load-testing --benchmark --api-address http://localhost:8085,http://localhost:8087 --users 10,50,100 --run-time 1m -o results.csv```
104
+
105
+ ## Example benchmark: pgstac vs opensearch vs stac-os-rs
106
+
107
+ Methodology used for the comparison below:
108
+
109
+ 1. Start all three backends, each with its own datastore on a ~2GB memory budget (OpenSearch 3.8.0 `-Xms2g -Xmx2g`, pgstac `shared_buffers=2GB`):
110
+
111
+ ```$ docker compose up -d opensearch opensearch-rs app-opensearch app-stac-os-rs pgstac app-pgstac```
112
+
113
+ 2. Ingest the same dataset into each backend — 4 collections with 500 Sentinel-2 L2A items each (2,000 items total):
114
+
115
+ ```$ stac-api-load-testing --ingest --api-address http://localhost:8083 --collections 4 --items-per-collection 500```
116
+ ```$ stac-api-load-testing --ingest --api-address http://localhost:8085 --collections 4 --items-per-collection 500```
117
+ ```$ stac-api-load-testing --ingest --api-address http://localhost:8087 --collections 4 --items-per-collection 500```
118
+
119
+ 3. Run the benchmark sweep against all three in one command (10/50/100/150 users, spawn rate 10 users/s, 1 minute per run, 8 locust worker processes):
120
+
121
+ ```$ stac-api-load-testing --benchmark --api-address stac-fastapi-pgstac=http://localhost:8083,stac-fastapi-os=http://localhost:8085,stac-os-rs=http://localhost:8087 --users 10,50,100,150 --run-time 1m -w 8 -o results.csv```
122
+
123
+ Environment: AMD Ryzen 7 8845HS (16 threads), 28GB RAM, Docker 29.8.0, locust 2.47.0 (`-w 8`), stac-fastapi.pgstac 7.2.0, stac-fastapi-os v8.1.1, stac-os-rs 0.5.0. The locustfile drives a read/search workload — landing page, collections, collection items, single items across all collections, GET/POST `/search` with bbox, intersects, sortby, datetime, CQL2, and ids queries, one level of `next`-link pagination, and filtered item lists; every search uses `limit=10`; write tasks are disabled.
124
+
125
+ Note on parity: stac-fastapi-os and stac-os-rs compute `numberMatched` (total-hit counting) on every search, while this stac-fastapi-pgstac version ships no context extension and skips count work entirely — so pgstac's reported numbers do not include a cost the OpenSearch-backed APIs pay per query.
126
+
127
+ ### Results
128
+
129
+ | api | collections | items | users | requests | failures | median (ms) | p95 (ms) | p99 (ms) | req/s |
130
+ | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
131
+ | stac-fastapi-pgstac (:8083) | 4 | 2,000 | 10 | 11,252 | 0 | 27 | 140 | 170 | 187 |
132
+ | stac-fastapi-pgstac (:8083) | 4 | 2,000 | 50 | 13,817 | 0 | 54 | 780 | 2,500 | 230 |
133
+ | stac-fastapi-pgstac (:8083) | 4 | 2,000 | 100 | 13,629 | 0 | 120 | 1,300 | 5,900 | 227 |
134
+ | stac-fastapi-pgstac (:8083) | 4 | 2,000 | 150 | 13,619 | 0 | 250 | 1,600 | 7,300 | 227 |
135
+ | stac-fastapi-os (:8085) | 4 | 2,000 | 10 | 49,492 | 0 | 9 | 25 | 32 | 824 |
136
+ | stac-fastapi-os (:8085) | 4 | 2,000 | 50 | 59,998 | 0 | 43 | 98 | 140 | 999 |
137
+ | stac-fastapi-os (:8085) | 4 | 2,000 | 100 | 58,755 | 0 | 90 | 170 | 220 | 978 |
138
+ | stac-fastapi-os (:8085) | 4 | 2,000 | 150 | 51,455 | 0 | 150 | 270 | 330 | 857 |
139
+ | stac-os-rs (:8087) | 4 | 2,000 | 10 | 100,517 | 0 | 5 | 10 | 12 | 1,674 |
140
+ | stac-os-rs (:8087) | 4 | 2,000 | 50 | 131,865 | 0 | 20 | 38 | 47 | 2,196 |
141
+ | stac-os-rs (:8087) | 4 | 2,000 | 100 | 130,389 | 0 | 40 | 68 | 82 | 2,170 |
142
+ | stac-os-rs (:8087) | 4 | 2,000 | 150 | 105,080 | 0 | 72 | 120 | 150 | 1,748 |
143
+
144
+ ### Summary
145
+
146
+ stac-os-rs sustained ~2.2x the throughput of stac-fastapi-os and ~8.8x stac-fastapi-pgstac, peaking at ~2,200 req/s with zero failures across all twelve runs. All three backends saturate by ~100 users on this box — throughput knees at ~250 (pgstac), ~1,000 (fastapi-os), and ~2,200 (stac-os-rs) req/s; at 150 users each shows the classic overload curve with declining throughput and rising latency. The OpenSearch-backed APIs degrade without errors, as does pgstac — its earlier failures (CQL2 500s, dropped connections) traced to Docker's 64MB `/dev/shm` default starving Postgres parallel-query workers, fixed by `shm_size: 1gb` on the pgstac container; with that resolved its ~230 req/s ceiling is clean but its p99 still reaches 7.3s at 150 users. Running enough locust workers matters: in single-process runs the same setup measured only ~1,000 req/s because the load generator itself was CPU-bound — with `-w 8` no CPU warnings appeared and reported numbers reflect the backends. These numbers reflect a 2,000-item dataset on a single machine and should be treated as a relative comparison, not absolute capacity.
147
+
148
+ ## Debugging Locust tasks using a single simulated user
149
+ ```$ python stac_api_load_testing/config_files/locustfile.py```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "stac-api-load-testing"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Load testing for stac compliant apis"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,6 +1,7 @@
1
1
  """stac-api-load-testing cli tool."""
2
2
 
3
3
  import csv
4
+ import json
4
5
  import os
5
6
  import re
6
7
  import shutil
@@ -11,6 +12,7 @@ from importlib import resources
11
12
  from pathlib import Path
12
13
 
13
14
  import click
15
+ import requests
14
16
  import yaml
15
17
 
16
18
  from .data_loader import data_loader
@@ -74,12 +76,81 @@ def _read_aggregated_stats(csv_path: str) -> dict:
74
76
  return {}
75
77
 
76
78
 
79
+ def _collection_ids(base_url: str) -> list:
80
+ """Return all collection ids served by a STAC API."""
81
+ response = requests.get(f"{base_url}/collections", timeout=30)
82
+ response.raise_for_status()
83
+ return [c["id"] for c in response.json().get("collections", [])]
84
+
85
+
86
+ def _collection_item_count(base_url: str, collection_id: str) -> int:
87
+ """Count items in one collection, preferring the cheap numberMatched field."""
88
+ response = requests.post(
89
+ f"{base_url}/search",
90
+ json={"collections": [collection_id], "limit": 1},
91
+ timeout=30,
92
+ )
93
+ response.raise_for_status()
94
+ matched = response.json().get("numberMatched")
95
+ if matched is not None:
96
+ return matched
97
+
98
+ # APIs without the context extension: count by paging the items endpoint.
99
+ count = 0
100
+ url = f"{base_url}/collections/{collection_id}/items?limit=500"
101
+ for _ in range(100):
102
+ response = requests.get(url, timeout=60)
103
+ response.raise_for_status()
104
+ page = response.json()
105
+ count += len(page.get("features", []))
106
+ url = next(
107
+ (
108
+ link["href"]
109
+ for link in page.get("links", [])
110
+ if link.get("rel") == "next" and link.get("href")
111
+ ),
112
+ None,
113
+ )
114
+ if url is None:
115
+ return count
116
+ return count
117
+
118
+
119
+ def _api_metadata(base_url: str) -> dict:
120
+ """Fetch the catalog title and dataset size from a STAC API.
121
+
122
+ Returns a dict with 'name', 'collections' and 'items' keys; fields are
123
+ empty when the API is unreachable or doesn't report them.
124
+ """
125
+ meta: dict[str, object] = {"name": "", "collections": "", "items": ""}
126
+ try:
127
+ response = requests.get(base_url, timeout=30)
128
+ response.raise_for_status()
129
+ meta["name"] = response.json().get("title", "")
130
+ except requests.RequestException as exc:
131
+ click.secho(f"could not fetch catalog info from {base_url}: {exc}", fg="yellow")
132
+
133
+ try:
134
+ collection_ids = _collection_ids(base_url)
135
+ meta["collections"] = len(collection_ids)
136
+ counts = {
137
+ collection_id: _collection_item_count(base_url, collection_id)
138
+ for collection_id in collection_ids
139
+ }
140
+ meta["items"] = sum(counts.values())
141
+ meta["collection_counts"] = counts
142
+ except requests.RequestException as exc:
143
+ click.secho(f"could not count items in {base_url}: {exc}", fg="yellow")
144
+ return meta
145
+
146
+
77
147
  def run_benchmark(
78
148
  api_addresses: list,
79
149
  concurrency_levels: list,
80
150
  spawn_rate: int,
81
151
  run_time: str,
82
152
  output: str,
153
+ workers: int,
83
154
  ):
84
155
  """
85
156
  Run headless Locust benchmark sweeps against one or more STAC APIs.
@@ -94,37 +165,56 @@ def run_benchmark(
94
165
  spawn_rate (int): Users spawned per second in each run.
95
166
  run_time (str): Duration of each run, in locust notation (e.g. '30s').
96
167
  output (str): Optional path to write aggregated results as CSV.
168
+ workers (int): Locust worker processes (--processes); >1 runs
169
+ distributed on this machine to avoid a single-process CPU ceiling.
97
170
  """
98
171
  locustfile_path = _package_resource("config_files", "locustfile.py")
99
172
  results = []
100
173
 
101
- for address in api_addresses:
174
+ for target in api_addresses:
175
+ # Targets can be labelled: "name=http://..." overrides the catalog title.
176
+ name, _, address = target.partition("=")
177
+ if not address:
178
+ name, address = "", target
179
+
180
+ meta = _api_metadata(address)
181
+ api_name = name or meta["name"] or address
182
+ # Locust workers derive item ids locally and need only the count.
183
+ os.environ["STAC_COLLECTION_COUNTS"] = json.dumps(
184
+ meta.get("collection_counts") or {}
185
+ )
186
+ click.secho(
187
+ f"\n=== {api_name} ({address}) — "
188
+ f"{meta['collections']} collections, {meta['items']} items ===",
189
+ fg="cyan",
190
+ )
102
191
  for users in concurrency_levels:
103
192
  click.secho(f"\n>>> {address} @ {users} users for {run_time}", fg="cyan")
104
193
  with tempfile.TemporaryDirectory() as tmpdir:
105
194
  csv_prefix = os.path.join(tmpdir, "locust")
106
195
  stats_csv = f"{csv_prefix}_stats.csv"
196
+ cmd = [
197
+ _tool_path("locust"),
198
+ "--locustfile",
199
+ locustfile_path,
200
+ "--host",
201
+ address,
202
+ "--headless",
203
+ "-u",
204
+ str(users),
205
+ "-r",
206
+ str(spawn_rate),
207
+ "-t",
208
+ run_time,
209
+ "--csv",
210
+ csv_prefix,
211
+ "--only-summary",
212
+ ]
213
+ if workers > 1:
214
+ cmd += ["--processes", str(workers)]
107
215
  # locust exits non-zero when requests fail; under load that is
108
216
  # data (backend errors), not a benchmark tool error.
109
- subprocess.run(
110
- [
111
- _tool_path("locust"),
112
- "--locustfile",
113
- locustfile_path,
114
- "--host",
115
- address,
116
- "--headless",
117
- "-u",
118
- str(users),
119
- "-r",
120
- str(spawn_rate),
121
- "-t",
122
- run_time,
123
- "--csv",
124
- csv_prefix,
125
- "--only-summary",
126
- ]
127
- )
217
+ subprocess.run(cmd)
128
218
 
129
219
  if not os.path.exists(stats_csv):
130
220
  click.secho(
@@ -137,7 +227,10 @@ def run_benchmark(
137
227
  if stats:
138
228
  results.append(
139
229
  {
140
- "api": address,
230
+ "api": api_name,
231
+ "url": address,
232
+ "collections": meta["collections"],
233
+ "items": meta["items"],
141
234
  "users": users,
142
235
  "requests": stats.get("Request Count"),
143
236
  "failures": stats.get("Failure Count"),
@@ -152,12 +245,16 @@ def run_benchmark(
152
245
  click.secho("No benchmark results collected.", fg="red")
153
246
  return
154
247
 
155
- header = f"{'api':<26}{'users':>7}{'reqs':>9}{'fails':>7}{'med':>8}{'p95':>8}{'p99':>8}{'rps':>9}"
248
+ header = (
249
+ f"{'api':<36}{'colls':>6}{'items':>7}{'users':>7}{'reqs':>9}"
250
+ f"{'fails':>7}{'med':>8}{'p95':>8}{'p99':>8}{'rps':>9}"
251
+ )
156
252
  click.echo("\n" + header)
157
253
  click.echo("-" * len(header))
158
254
  for row in results:
159
255
  click.echo(
160
- f"{row['api']:<26}{row['users']:>7}{row['requests']:>9}"
256
+ f"{row['api']:<36}{row['collections']:>6}{row['items']:>7}"
257
+ f"{row['users']:>7}{row['requests']:>9}"
161
258
  f"{row['failures']:>7}{row['median_ms']:>8}{row['p95_ms']:>8}"
162
259
  f"{row['p99_ms']:>8}{row['rps']:>9}"
163
260
  )
@@ -202,7 +299,19 @@ def run_benchmark(
202
299
  "-a",
203
300
  "--api-address",
204
301
  default="http://localhost:8080",
205
- help="Specify the STAC API URL to test against. Accepts a comma-separated list for --benchmark.",
302
+ help="Specify the STAC API URL to test against. Accepts a comma-separated list for --benchmark; prefix with 'name=' to label a target in results.",
303
+ )
304
+ @click.option(
305
+ "--collections",
306
+ default=1,
307
+ help="Number of collections to create for --ingest (test-collection, test-collection-2, ...).",
308
+ type=int,
309
+ )
310
+ @click.option(
311
+ "--items-per-collection",
312
+ default=100,
313
+ help="Items to load per collection for --ingest; beyond the 100 bundled items, duplicates are synthesized with new ids and shifted geometry.",
314
+ type=int,
206
315
  )
207
316
  @click.option(
208
317
  "-b",
@@ -227,6 +336,13 @@ def run_benchmark(
227
336
  default="30s",
228
337
  help="Duration of each --benchmark run, in locust notation (e.g. '30s', '1m').",
229
338
  )
339
+ @click.option(
340
+ "-w",
341
+ "--workers",
342
+ default=1,
343
+ help="Locust worker processes for --benchmark (--processes); >1 distributes load across cores on this machine.",
344
+ type=int,
345
+ )
230
346
  @click.option(
231
347
  "-o",
232
348
  "--output",
@@ -234,7 +350,7 @@ def run_benchmark(
234
350
  help="Write aggregated --benchmark results to a CSV file.",
235
351
  type=click.Path(),
236
352
  )
237
- @click.version_option(version="0.3.0")
353
+ @click.version_option(version="0.4.0")
238
354
  def main(
239
355
  ingest: bool,
240
356
  locust: bool,
@@ -243,10 +359,13 @@ def main(
243
359
  concurrency: int,
244
360
  ramp_up: str,
245
361
  iterations: int,
362
+ collections: int,
363
+ items_per_collection: int,
246
364
  benchmark: bool,
247
365
  users: str,
248
366
  spawn_rate: int,
249
367
  run_time: str,
368
+ workers: int,
250
369
  output: str,
251
370
  ):
252
371
  """
@@ -262,18 +381,26 @@ def main(
262
381
  concurrency (int): Specifies the number of concurrent users for Taurus testing. Default is 10.
263
382
  ramp_up (str): Specifies the ramp-up period for Taurus testing, in Taurus notation (e.g., '1m' for 1 minute). Default is '1m'.
264
383
  iterations (int): Specifies the number of iterations each virtual user will execute in Taurus testing. Default is 100.
384
+ collections (int): Number of collections to create for ingest. Default 1.
385
+ items_per_collection (int): Items per collection for ingest. Default 100;
386
+ above 100, duplicates are synthesized with new ids and shifted geometry.
265
387
  api_address (str): The base URL of the STAC API to be tested. Comma-separated list allowed for benchmark.
266
388
  benchmark (bool): If True, run headless locust runs sweeping --users levels and print a comparison table.
267
389
  users (str): Comma-separated concurrency levels for benchmark. Default '10,50,100'.
268
390
  spawn_rate (int): Users spawned per second in each benchmark run. Default 10.
269
391
  run_time (str): Duration of each benchmark run in locust notation. Default '30s'.
392
+ workers (int): Locust worker processes per benchmark run. Default 1.
270
393
  output (str): Optional CSV file path for aggregated benchmark results.
271
394
  """
272
395
  os.environ["LOCUST_HOST"] = api_address
273
396
 
274
397
  if ingest:
275
398
  # Load data into the STAC API
276
- data_loader.load_items(stac_api_base_url=api_address)
399
+ data_loader.load_items(
400
+ stac_api_base_url=api_address,
401
+ num_collections=collections,
402
+ items_per_collection=items_per_collection,
403
+ )
277
404
  elif locust:
278
405
  # Execute Locust load tests
279
406
  locust_file_path = _package_resource("config_files", "locustfile.py")
@@ -301,7 +428,9 @@ def main(
301
428
  elif benchmark:
302
429
  concurrency_levels = [int(x) for x in users.split(",")]
303
430
  api_addresses = [a.strip() for a in api_address.split(",")]
304
- run_benchmark(api_addresses, concurrency_levels, spawn_rate, run_time, output)
431
+ run_benchmark(
432
+ api_addresses, concurrency_levels, spawn_rate, run_time, output, workers
433
+ )
305
434
 
306
435
 
307
436
  if __name__ == "__main__":